Final slice of Phase 5. The appraisal lifecycle raise → verify → approve across three role-gated surfaces, per Crewing-Implementation-Spec §5.4/§8.14. Stacks on 5a verification. Behind NEXT_PUBLIC_CREWING_ENABLED. Completes Phase 5. What's in - Schema: Appraisal (on CrewAssignment) + AppraisalStatus (DRAFT/SUBMITTED/MPO_VERIFIED/MANAGER_APPROVED/REJECTED); CrewActionType += APPRAISAL_SUBMITTED/VERIFIED/APPROVED/REJECTED. Migration crewing_appraisal. - State machine lib/appraisal-state-machine.ts: verify (SUBMITTED→MPO_VERIFIED, MPO/Manager), approve (MPO_VERIFIED→MANAGER_APPROVED, Manager); orthogonal reject. - Actions (crewing/appraisals/actions.ts): raiseAppraisal (raise_appraisal — PM/ site staff), verifyAppraisal (verify_appraisal — MPO), approveAppraisal (approve_appraisal — Manager); reject paths require remarks; notifications APPRAISAL_FOR_VERIFICATION / APPRAISAL_FOR_APPROVAL. - Three surfaces (§8.14): PM raises + tracks status on the crew-profile Appraisals tab; MPO verifies in the Verification queue (Appraisals section); Manager approves in the central /approvals queue (Appraisal kind). Tests & docs - Unit: appraisal-state-machine.test.ts (4). Integration: appraisal.test.ts (4) — raise→verify→approve happy path, MPO reject, permission gating (MPO can't raise, site staff can't verify, MPO can't approve). type-check clean; full unit (245) + integration (205) green (verified with RESEND_API_KEY unset). - CLAUDE.md updated — completes Phase 5 (I + H). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
423 lines
26 KiB
TypeScript
423 lines
26 KiB
TypeScript
"use client";
|
||
|
||
import { useState } from "react";
|
||
import Link from "next/link";
|
||
import { useRouter } from "next/navigation";
|
||
import { ArrowLeft } from "lucide-react";
|
||
import type { AssignmentStatus, GateResult, PpeItem, SeafarerDocType, SalaryRateBasis, AppraisalStatus } from "@prisma/client";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { AdminDialog } from "@/components/ui/admin-dialog";
|
||
import { cn } from "@/lib/utils";
|
||
import {
|
||
uploadDocument, deleteDocument, saveBankEpf,
|
||
addNextOfKin, deleteNextOfKin, issuePpe, returnPpe, addExperience, signOffCrew,
|
||
} from "../actions";
|
||
import { raiseAppraisal } from "../../appraisals/actions";
|
||
|
||
const INPUT = "w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20";
|
||
const BTN = "rounded-lg bg-primary-600 px-3 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60";
|
||
const LINKBTN = "text-xs font-medium text-danger-600 hover:underline";
|
||
|
||
const DOC_TYPES: SeafarerDocType[] = ["STCW","AADHAAR","PAN","PASSPORT","CDC","COC","PHOTOGRAPH","DRIVING_LICENSE","MEDICAL_FITNESS","CONTRACT_LETTER"];
|
||
const PPE_ITEMS: PpeItem[] = ["BOILER_SUIT","SAFETY_SHOES","HELMET","VEST","GLOVES","MASK","GOGGLES","TIFFIN","TORCH","WALKIE_TALKIE"];
|
||
const label = (s: string) => s.replace(/_/g, " ").toLowerCase().replace(/\b\w/g, (m) => m.toUpperCase());
|
||
const fmtDate = (iso: string | null) => (iso ? new Date(iso).toLocaleDateString() : "—");
|
||
|
||
type Doc = { id: string; docType: SeafarerDocType; number: string | null; issueDate: string | null; expiryDate: string | null; verificationStatus: GateResult; hasFile: boolean };
|
||
type Nok = { id: string; name: string; relationship: string | null; phone: string | null; address: string | null; isEmergency: boolean };
|
||
type Ppe = { id: string; item: PpeItem; size: string | null; quantity: number; issuedDate: string; returnedDate: string | null };
|
||
type Exp = { id: string; vesselType: string | null; rank: string | null; fromDate: string | null; toDate: string | null; durationMonths: number | null; source: string };
|
||
|
||
type Props = {
|
||
crew: { id: string; name: string; employeeId: string; rank: string; location: string; status: AssignmentStatus | null };
|
||
documents: Doc[];
|
||
bank: { accountName: string | null; accountNumber: string; ifsc: string | null; bankName: string | null };
|
||
epf: { uan: string | null; aadhaar: string; pfNumber: string | null };
|
||
nextOfKin: Nok[];
|
||
ppe: Ppe[];
|
||
experience: Exp[];
|
||
paystatus: { showSalary: boolean; salary: { basic: number; rateBasis: SalaryRateBasis; victualingPerDay: number; currency: string } | null };
|
||
ranks: { id: string; name: string }[];
|
||
perms: { editRecords: boolean; issuePpe: boolean };
|
||
signOff: { assignmentId: string | null; canSignOff: boolean };
|
||
appraisals: Appr[];
|
||
appraisalCtx: { assignmentId: string | null; canRaise: boolean };
|
||
};
|
||
|
||
type Appr = { id: string; period: string; status: AppraisalStatus; comments: string | null; ratings: { competence: number | null; conduct: number | null; safety: number | null } | null };
|
||
|
||
const TABS = ["Documents", "Bank & EPF", "Next of kin", "PPE", "Experience", "Pay status", "Appraisals"] as const;
|
||
type Tab = (typeof TABS)[number];
|
||
|
||
const APPRAISAL_VARIANT: Record<AppraisalStatus, "outline" | "warning" | "default" | "success" | "danger"> = {
|
||
DRAFT: "outline", SUBMITTED: "warning", MPO_VERIFIED: "default", MANAGER_APPROVED: "success", REJECTED: "danger",
|
||
};
|
||
|
||
export function CrewProfile(p: Props) {
|
||
const [tab, setTab] = useState<Tab>("Documents");
|
||
const router = useRouter();
|
||
const refresh = () => router.refresh();
|
||
|
||
return (
|
||
<div className="max-w-4xl">
|
||
<Link href="/crewing/crew" className="inline-flex items-center gap-1.5 text-sm text-neutral-500 hover:text-neutral-800 mb-4">
|
||
<ArrowLeft className="h-4 w-4" /> Crew
|
||
</Link>
|
||
|
||
<div className="mb-1 flex items-center justify-between gap-3">
|
||
<div className="flex items-center gap-3">
|
||
<h1 className="text-2xl font-semibold text-neutral-900">{p.crew.name}</h1>
|
||
{p.crew.status === "ACTIVE" && <Badge variant="success">Active</Badge>}
|
||
{p.crew.status === "ON_LEAVE" && <Badge variant="warning">On leave</Badge>}
|
||
</div>
|
||
{p.signOff.canSignOff && p.signOff.assignmentId && <SignOffButton assignmentId={p.signOff.assignmentId} crewName={p.crew.name} />}
|
||
</div>
|
||
<p className="text-sm text-neutral-500 mb-6"><span className="font-mono">{p.crew.employeeId}</span> · {p.crew.rank} · {p.crew.location}</p>
|
||
|
||
<div className="mb-5 flex flex-wrap gap-1 border-b border-neutral-200">
|
||
{TABS.map((t) => (
|
||
<button key={t} onClick={() => setTab(t)} className={cn("px-3 py-2 text-sm font-medium border-b-2 -mb-px", tab === t ? "border-primary-600 text-primary-700" : "border-transparent text-neutral-500 hover:text-neutral-800")}>
|
||
{t}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{tab === "Documents" && <Documents crewId={p.crew.id} docs={p.documents} canEdit={p.perms.editRecords} onDone={refresh} />}
|
||
{tab === "Bank & EPF" && <BankEpf crewId={p.crew.id} bank={p.bank} epf={p.epf} canEdit={p.perms.editRecords} onDone={refresh} />}
|
||
{tab === "Next of kin" && <NextOfKinTab crewId={p.crew.id} rows={p.nextOfKin} canEdit={p.perms.editRecords} onDone={refresh} />}
|
||
{tab === "PPE" && <PpeTab crewId={p.crew.id} rows={p.ppe} canIssue={p.perms.issuePpe} onDone={refresh} />}
|
||
{tab === "Experience" && <ExperienceTab crewId={p.crew.id} rows={p.experience} ranks={p.ranks} canEdit={p.perms.editRecords} onDone={refresh} />}
|
||
{tab === "Pay status" && <PayStatus paystatus={p.paystatus} />}
|
||
{tab === "Appraisals" && <Appraisals rows={p.appraisals} ctx={p.appraisalCtx} onDone={refresh} />}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Appraisals({ rows, ctx, onDone }: { rows: Appr[]; ctx: { assignmentId: string | null; canRaise: boolean }; onDone: () => void }) {
|
||
const { pending, error, run } = useRun(onDone);
|
||
const [f, setF] = useState({ period: "", competence: "3", conduct: "3", safety: "3", comments: "" });
|
||
|
||
function submit(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
if (!ctx.assignmentId) return;
|
||
const fd = new FormData();
|
||
fd.set("assignmentId", ctx.assignmentId);
|
||
Object.entries(f).forEach(([k, v]) => v && fd.set(k, v));
|
||
run(() => raiseAppraisal(fd), () => setF({ period: "", competence: "3", conduct: "3", safety: "3", comments: "" }));
|
||
}
|
||
|
||
return (
|
||
<Section>
|
||
{rows.length === 0 ? <p className="text-sm text-neutral-400">No appraisals.</p> : rows.map((a) => (
|
||
<div key={a.id} className="flex items-start justify-between border-b border-neutral-50 last:border-0 py-2">
|
||
<div>
|
||
<p className="text-sm text-neutral-900">{a.period} <Badge variant={APPRAISAL_VARIANT[a.status]}>{a.status.replace(/_/g, " ").toLowerCase()}</Badge></p>
|
||
<p className="text-xs text-neutral-500">
|
||
{a.ratings ? `Competence ${a.ratings.competence ?? "—"} · Conduct ${a.ratings.conduct ?? "—"} · Safety ${a.ratings.safety ?? "—"}` : "—"}
|
||
{a.comments ? ` · ${a.comments}` : ""}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
))}
|
||
{ctx.canRaise && ctx.assignmentId && (
|
||
<form onSubmit={submit} className="border-t border-neutral-100 pt-3 grid grid-cols-2 gap-2">
|
||
<input className={INPUT} placeholder="Period (e.g. 2026 or 2026-Q2)" value={f.period} onChange={(e) => setF({ ...f, period: e.target.value })} required />
|
||
<input className={INPUT} placeholder="Comments" value={f.comments} onChange={(e) => setF({ ...f, comments: e.target.value })} />
|
||
{(["competence", "conduct", "safety"] as const).map((k) => (
|
||
<label key={k} className="text-xs text-neutral-500 capitalize">{k}
|
||
<select className={INPUT} value={f[k]} onChange={(e) => setF({ ...f, [k]: e.target.value })}>{[1, 2, 3, 4, 5].map((n) => <option key={n} value={n}>{n}</option>)}</select>
|
||
</label>
|
||
))}
|
||
<div className="col-span-2"><Err msg={error} /><button className={BTN} disabled={pending || !f.period}>{pending ? "Submitting…" : "Submit appraisal"}</button></div>
|
||
</form>
|
||
)}
|
||
{!ctx.canRaise && <p className="text-xs text-neutral-400 border-t border-neutral-100 pt-3">Appraisals are raised by the PM and verified by the MPO, then approved by the Manager.</p>}
|
||
</Section>
|
||
);
|
||
}
|
||
|
||
function Section({ children }: { children: React.ReactNode }) {
|
||
return <div className="rounded-lg border border-neutral-200 bg-white p-4 space-y-3">{children}</div>;
|
||
}
|
||
function Err({ msg }: { msg: string }) { return msg ? <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{msg}</p> : null; }
|
||
|
||
function useRun(onDone: () => void) {
|
||
const [pending, setPending] = useState(false);
|
||
const [error, setError] = useState("");
|
||
async function run(fn: () => Promise<{ ok: true } | { error: string }>, after?: () => void) {
|
||
setPending(true); setError("");
|
||
const res = await fn();
|
||
setPending(false);
|
||
if ("error" in res) setError(res.error); else { after?.(); onDone(); }
|
||
}
|
||
return { pending, error, run };
|
||
}
|
||
|
||
function docStatus(d: Doc): { label: string; variant: "success" | "warning" | "danger" | "secondary" } {
|
||
if (d.expiryDate && new Date(d.expiryDate) < new Date()) return { label: "Expired", variant: "danger" };
|
||
if (d.verificationStatus === "VERIFIED") return { label: "Verified", variant: "success" };
|
||
if (d.verificationStatus === "REJECTED") return { label: "Rejected", variant: "danger" };
|
||
return { label: "Pending", variant: "warning" };
|
||
}
|
||
|
||
function Documents({ crewId, docs, canEdit, onDone }: { crewId: string; docs: Doc[]; canEdit: boolean; onDone: () => void }) {
|
||
const { pending, error, run } = useRun(onDone);
|
||
const [f, setF] = useState({ docType: "PASSPORT", number: "", issueDate: "", expiryDate: "" });
|
||
const [file, setFile] = useState<File | null>(null);
|
||
|
||
function submit(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
const fd = new FormData();
|
||
fd.set("crewMemberId", crewId);
|
||
Object.entries(f).forEach(([k, v]) => v && fd.set(k, v));
|
||
if (file) fd.set("file", file);
|
||
run(() => uploadDocument(fd), () => { setF({ docType: "PASSPORT", number: "", issueDate: "", expiryDate: "" }); setFile(null); });
|
||
}
|
||
|
||
return (
|
||
<Section>
|
||
{docs.length === 0 ? <p className="text-sm text-neutral-400">No documents.</p> : (
|
||
<table className="w-full text-sm">
|
||
<thead><tr className="text-left text-xs text-neutral-500 border-b border-neutral-100"><th className="py-2">Document</th><th>Number</th><th>Issued</th><th>Expires</th><th>Status</th><th></th></tr></thead>
|
||
<tbody>
|
||
{docs.map((d) => { const s = docStatus(d); return (
|
||
<tr key={d.id} className="border-b border-neutral-50 last:border-0">
|
||
<td className="py-2 text-neutral-800">{label(d.docType)}{d.hasFile && <span className="ml-1 text-xs text-neutral-400">file</span>}</td>
|
||
<td className="text-neutral-600">{d.number ?? "—"}</td>
|
||
<td className="text-neutral-600">{fmtDate(d.issueDate)}</td>
|
||
<td className="text-neutral-600">{fmtDate(d.expiryDate)}</td>
|
||
<td><Badge variant={s.variant}>{s.label}</Badge></td>
|
||
<td className="text-right">{canEdit && <button className={LINKBTN} onClick={() => run(() => deleteDocument(d.id))}>Remove</button>}</td>
|
||
</tr>
|
||
); })}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
{canEdit && (
|
||
<form onSubmit={submit} className="border-t border-neutral-100 pt-3 grid grid-cols-2 gap-2">
|
||
<select className={INPUT} value={f.docType} onChange={(e) => setF({ ...f, docType: e.target.value })}>
|
||
{DOC_TYPES.map((t) => <option key={t} value={t}>{label(t)}</option>)}
|
||
</select>
|
||
<input className={INPUT} placeholder="Number" value={f.number} onChange={(e) => setF({ ...f, number: e.target.value })} />
|
||
<label className="text-xs text-neutral-500">Issue date<input type="date" className={INPUT} value={f.issueDate} onChange={(e) => setF({ ...f, issueDate: e.target.value })} /></label>
|
||
<label className="text-xs text-neutral-500">Expiry date<input type="date" className={INPUT} value={f.expiryDate} onChange={(e) => setF({ ...f, expiryDate: e.target.value })} /></label>
|
||
<input type="file" className="col-span-2 text-sm" onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
|
||
<div className="col-span-2"><Err msg={error} /><button className={BTN} disabled={pending}>{pending ? "Adding…" : "Add document"}</button></div>
|
||
</form>
|
||
)}
|
||
</Section>
|
||
);
|
||
}
|
||
|
||
function Row({ k, v }: { k: string; v: string | null }) {
|
||
return <div className="flex justify-between gap-4 py-1.5 border-b border-neutral-50 last:border-0"><span className="text-sm text-neutral-500">{k}</span><span className="text-sm text-neutral-900 font-mono">{v ?? "—"}</span></div>;
|
||
}
|
||
|
||
function BankEpf({ crewId, bank, epf, canEdit, onDone }: { crewId: string; bank: Props["bank"]; epf: Props["epf"]; canEdit: boolean; onDone: () => void }) {
|
||
const { pending, error, run } = useRun(onDone);
|
||
const [edit, setEdit] = useState(false);
|
||
const [f, setF] = useState({ accountName: bank.accountName ?? "", accountNumber: "", ifsc: bank.ifsc ?? "", bankName: bank.bankName ?? "", uan: epf.uan ?? "", aadhaarLast4: "", pfNumber: epf.pfNumber ?? "" });
|
||
|
||
function submit(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
const fd = new FormData();
|
||
fd.set("crewMemberId", crewId);
|
||
Object.entries(f).forEach(([k, v]) => v && fd.set(k, v));
|
||
run(() => saveBankEpf(fd), () => setEdit(false));
|
||
}
|
||
|
||
return (
|
||
<Section>
|
||
<div className="rounded-md bg-warning-50 border border-warning-200 px-3 py-2 text-xs text-warning-800">Sensitive — account and Aadhaar numbers are masked unless you are Accounts.</div>
|
||
<Row k="Account name" v={bank.accountName} />
|
||
<Row k="Account number" v={bank.accountNumber} />
|
||
<Row k="IFSC" v={bank.ifsc} />
|
||
<Row k="Bank" v={bank.bankName} />
|
||
<Row k="UAN" v={epf.uan} />
|
||
<Row k="Aadhaar" v={epf.aadhaar} />
|
||
<Row k="PF number" v={epf.pfNumber} />
|
||
{canEdit && !edit && <button className="text-sm text-primary-600 hover:underline" onClick={() => setEdit(true)}>Edit bank & EPF</button>}
|
||
{canEdit && edit && (
|
||
<form onSubmit={submit} className="border-t border-neutral-100 pt-3 grid grid-cols-2 gap-2">
|
||
<input className={INPUT} placeholder="Account name" value={f.accountName} onChange={(e) => setF({ ...f, accountName: e.target.value })} />
|
||
<input className={INPUT} placeholder="Account number" value={f.accountNumber} onChange={(e) => setF({ ...f, accountNumber: e.target.value })} />
|
||
<input className={INPUT} placeholder="IFSC" value={f.ifsc} onChange={(e) => setF({ ...f, ifsc: e.target.value })} />
|
||
<input className={INPUT} placeholder="Bank name" value={f.bankName} onChange={(e) => setF({ ...f, bankName: e.target.value })} />
|
||
<input className={INPUT} placeholder="UAN" value={f.uan} onChange={(e) => setF({ ...f, uan: e.target.value })} />
|
||
<input className={INPUT} placeholder="Aadhaar (last 4)" value={f.aadhaarLast4} onChange={(e) => setF({ ...f, aadhaarLast4: e.target.value })} />
|
||
<input className={INPUT} placeholder="PF number" value={f.pfNumber} onChange={(e) => setF({ ...f, pfNumber: e.target.value })} />
|
||
<div className="col-span-2"><Err msg={error} /><div className="flex gap-2"><button className={BTN} disabled={pending}>{pending ? "Saving…" : "Save"}</button><button type="button" className="text-sm text-neutral-500" onClick={() => setEdit(false)}>Cancel</button></div></div>
|
||
</form>
|
||
)}
|
||
</Section>
|
||
);
|
||
}
|
||
|
||
function NextOfKinTab({ crewId, rows, canEdit, onDone }: { crewId: string; rows: Nok[]; canEdit: boolean; onDone: () => void }) {
|
||
const { pending, error, run } = useRun(onDone);
|
||
const [f, setF] = useState({ name: "", relationship: "", phone: "", address: "", isEmergency: false });
|
||
function submit(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
const fd = new FormData();
|
||
fd.set("crewMemberId", crewId);
|
||
fd.set("name", f.name); if (f.relationship) fd.set("relationship", f.relationship); if (f.phone) fd.set("phone", f.phone); if (f.address) fd.set("address", f.address); if (f.isEmergency) fd.set("isEmergency", "true");
|
||
run(() => addNextOfKin(fd), () => setF({ name: "", relationship: "", phone: "", address: "", isEmergency: false }));
|
||
}
|
||
return (
|
||
<Section>
|
||
{rows.length === 0 ? <p className="text-sm text-neutral-400">No next of kin recorded.</p> : rows.map((n) => (
|
||
<div key={n.id} className="flex items-start justify-between border-b border-neutral-50 last:border-0 py-2">
|
||
<div>
|
||
<p className="text-sm text-neutral-900">{n.name} {n.isEmergency && <Badge variant="danger">Emergency</Badge>}</p>
|
||
<p className="text-xs text-neutral-500">{[n.relationship, n.phone, n.address].filter(Boolean).join(" · ") || "—"}</p>
|
||
</div>
|
||
{canEdit && <button className={LINKBTN} onClick={() => run(() => deleteNextOfKin(n.id))}>Remove</button>}
|
||
</div>
|
||
))}
|
||
{canEdit && (
|
||
<form onSubmit={submit} className="border-t border-neutral-100 pt-3 grid grid-cols-2 gap-2">
|
||
<input className={INPUT} placeholder="Name" value={f.name} onChange={(e) => setF({ ...f, name: e.target.value })} required />
|
||
<input className={INPUT} placeholder="Relationship" value={f.relationship} onChange={(e) => setF({ ...f, relationship: e.target.value })} />
|
||
<input className={INPUT} placeholder="Phone" value={f.phone} onChange={(e) => setF({ ...f, phone: e.target.value })} />
|
||
<input className={INPUT} placeholder="Address" value={f.address} onChange={(e) => setF({ ...f, address: e.target.value })} />
|
||
<label className="col-span-2 flex items-center gap-2 text-sm text-neutral-600"><input type="checkbox" checked={f.isEmergency} onChange={(e) => setF({ ...f, isEmergency: e.target.checked })} /> Emergency contact</label>
|
||
<div className="col-span-2"><Err msg={error} /><button className={BTN} disabled={pending || !f.name}>{pending ? "Adding…" : "Add"}</button></div>
|
||
</form>
|
||
)}
|
||
</Section>
|
||
);
|
||
}
|
||
|
||
function PpeTab({ crewId, rows, canIssue, onDone }: { crewId: string; rows: Ppe[]; canIssue: boolean; onDone: () => void }) {
|
||
const { pending, error, run } = useRun(onDone);
|
||
const [f, setF] = useState({ item: "BOILER_SUIT", size: "", quantity: "1", comment: "" });
|
||
function submit(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
const fd = new FormData();
|
||
fd.set("crewMemberId", crewId);
|
||
Object.entries(f).forEach(([k, v]) => v && fd.set(k, v));
|
||
run(() => issuePpe(fd), () => setF({ item: "BOILER_SUIT", size: "", quantity: "1", comment: "" }));
|
||
}
|
||
return (
|
||
<Section>
|
||
{rows.length === 0 ? <p className="text-sm text-neutral-400">No PPE issued.</p> : (
|
||
<table className="w-full text-sm">
|
||
<thead><tr className="text-left text-xs text-neutral-500 border-b border-neutral-100"><th className="py-2">Item</th><th>Size</th><th>Qty</th><th>Issued</th><th>Status</th><th></th></tr></thead>
|
||
<tbody>
|
||
{rows.map((r) => (
|
||
<tr key={r.id} className="border-b border-neutral-50 last:border-0">
|
||
<td className="py-2 text-neutral-800">{label(r.item)}</td>
|
||
<td className="text-neutral-600">{r.size ?? "—"}</td>
|
||
<td className="text-neutral-600">{r.quantity}</td>
|
||
<td className="text-neutral-600">{fmtDate(r.issuedDate)}</td>
|
||
<td>{r.returnedDate ? <Badge variant="secondary">Returned</Badge> : <Badge variant="success">Issued</Badge>}</td>
|
||
<td className="text-right">{canIssue && !r.returnedDate && <button className="text-xs text-primary-600 hover:underline" onClick={() => run(() => returnPpe(r.id))}>Mark returned</button>}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
{canIssue && (
|
||
<form onSubmit={submit} className="border-t border-neutral-100 pt-3 grid grid-cols-2 gap-2">
|
||
<select className={INPUT} value={f.item} onChange={(e) => setF({ ...f, item: e.target.value })}>{PPE_ITEMS.map((i) => <option key={i} value={i}>{label(i)}</option>)}</select>
|
||
<input className={INPUT} placeholder="Size" value={f.size} onChange={(e) => setF({ ...f, size: e.target.value })} />
|
||
<input className={INPUT} type="number" min={1} placeholder="Qty" value={f.quantity} onChange={(e) => setF({ ...f, quantity: e.target.value })} />
|
||
<input className={INPUT} placeholder="Comment" value={f.comment} onChange={(e) => setF({ ...f, comment: e.target.value })} />
|
||
<div className="col-span-2"><Err msg={error} /><button className={BTN} disabled={pending}>{pending ? "Issuing…" : "Issue PPE"}</button></div>
|
||
</form>
|
||
)}
|
||
</Section>
|
||
);
|
||
}
|
||
|
||
function ExperienceTab({ crewId, rows, ranks, canEdit, onDone }: { crewId: string; rows: Exp[]; ranks: { id: string; name: string }[]; canEdit: boolean; onDone: () => void }) {
|
||
const { pending, error, run } = useRun(onDone);
|
||
const [f, setF] = useState({ vesselType: "", rankId: "", fromDate: "", toDate: "", durationMonths: "" });
|
||
function submit(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
const fd = new FormData();
|
||
fd.set("crewMemberId", crewId);
|
||
Object.entries(f).forEach(([k, v]) => v && fd.set(k, v));
|
||
run(() => addExperience(fd), () => setF({ vesselType: "", rankId: "", fromDate: "", toDate: "", durationMonths: "" }));
|
||
}
|
||
return (
|
||
<Section>
|
||
{rows.length === 0 ? <p className="text-sm text-neutral-400">No experience records.</p> : rows.map((r) => (
|
||
<div key={r.id} className="border-b border-neutral-50 last:border-0 py-2">
|
||
<p className="text-sm text-neutral-900">{r.rank ?? "—"}{r.vesselType ? ` · ${r.vesselType}` : ""}</p>
|
||
<p className="text-xs text-neutral-500">{fmtDate(r.fromDate)} – {fmtDate(r.toDate)}{r.durationMonths ? ` · ${r.durationMonths} mo` : ""} · {r.source}</p>
|
||
</div>
|
||
))}
|
||
{canEdit && (
|
||
<form onSubmit={submit} className="border-t border-neutral-100 pt-3 grid grid-cols-2 gap-2">
|
||
<select className={INPUT} value={f.rankId} onChange={(e) => setF({ ...f, rankId: e.target.value })}><option value="">Rank…</option>{ranks.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}</select>
|
||
<input className={INPUT} placeholder="Vessel type" value={f.vesselType} onChange={(e) => setF({ ...f, vesselType: e.target.value })} />
|
||
<label className="text-xs text-neutral-500">From<input type="date" className={INPUT} value={f.fromDate} onChange={(e) => setF({ ...f, fromDate: e.target.value })} /></label>
|
||
<label className="text-xs text-neutral-500">To<input type="date" className={INPUT} value={f.toDate} onChange={(e) => setF({ ...f, toDate: e.target.value })} /></label>
|
||
<input className={INPUT} type="number" min={0} placeholder="Duration (months)" value={f.durationMonths} onChange={(e) => setF({ ...f, durationMonths: e.target.value })} />
|
||
<div className="col-span-2"><Err msg={error} /><button className={BTN} disabled={pending}>{pending ? "Adding…" : "Add experience"}</button></div>
|
||
</form>
|
||
)}
|
||
</Section>
|
||
);
|
||
}
|
||
|
||
function PayStatus({ paystatus }: { paystatus: Props["paystatus"] }) {
|
||
return (
|
||
<Section>
|
||
{!paystatus.showSalary ? (
|
||
<p className="text-sm text-neutral-500">Net pay is visible to office roles only. Site staff see pay <em>status</em> once monthly wage reports are generated.</p>
|
||
) : paystatus.salary ? (
|
||
<>
|
||
<Row k="Basic" v={`${paystatus.salary.currency} ${paystatus.salary.basic.toLocaleString("en-IN")} / ${paystatus.salary.rateBasis.toLowerCase()}`} />
|
||
<Row k="Victualing / day" v={`${paystatus.salary.currency} ${paystatus.salary.victualingPerDay.toLocaleString("en-IN")}`} />
|
||
</>
|
||
) : (
|
||
<p className="text-sm text-neutral-400">No salary structure on file.</p>
|
||
)}
|
||
<p className="text-xs text-neutral-400 border-t border-neutral-100 pt-3">Monthly pay rows (paid / processing) arrive with payroll wage reports in a later phase.</p>
|
||
</Section>
|
||
);
|
||
}
|
||
|
||
function SignOffButton({ assignmentId, crewName }: { assignmentId: string; crewName: string }) {
|
||
const router = useRouter();
|
||
const [open, setOpen] = useState(false);
|
||
const [date, setDate] = useState("");
|
||
const [remarks, setRemarks] = useState("");
|
||
const [pending, setPending] = useState(false);
|
||
const [error, setError] = useState("");
|
||
|
||
async function submit(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
setPending(true); setError("");
|
||
const res = await signOffCrew(assignmentId, date, remarks);
|
||
setPending(false);
|
||
if ("error" in res) setError(res.error);
|
||
else { setOpen(false); router.push("/crewing/crew"); }
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<button onClick={() => setOpen(true)} className="rounded-lg border border-danger-300 px-4 py-2 text-sm font-medium text-danger-700 hover:bg-danger-50">Sign off</button>
|
||
<AdminDialog title={`Sign off ${crewName}`} open={open} onClose={() => setOpen(false)}>
|
||
<form onSubmit={submit} className="space-y-4">
|
||
<p className="text-sm text-neutral-600">Ends this tour: the assignment closes, a tour record is added to Experience, and the crew member returns to the Candidates pool as an ex-hand. A backfill requisition is auto-raised.</p>
|
||
<div>
|
||
<label className="block text-xs font-medium text-neutral-700 mb-1">Sign-off date *</label>
|
||
<input type="date" className={INPUT} value={date} onChange={(e) => setDate(e.target.value)} required />
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-medium text-neutral-700 mb-1">Remarks</label>
|
||
<input className={INPUT} value={remarks} onChange={(e) => setRemarks(e.target.value)} placeholder="Optional" />
|
||
</div>
|
||
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
|
||
<div className="flex justify-end gap-3">
|
||
<button type="button" className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50" onClick={() => setOpen(false)}>Cancel</button>
|
||
<button type="submit" disabled={pending || !date} className="rounded-lg bg-danger px-4 py-2 text-sm font-semibold text-white hover:opacity-90 disabled:opacity-60">{pending ? "Signing off…" : "Sign off"}</button>
|
||
</div>
|
||
</form>
|
||
</AdminDialog>
|
||
</>
|
||
);
|
||
}
|