First slice of Phase 4 (stacked on 3c onboarding). The Crew directory and tabbed crew profile with documents, bank/EPF (role-masked), next of kin, PPE and experience, per Crewing-Implementation-Spec §8.7–8.8. Behind NEXT_PUBLIC_CREWING_ENABLED; production unchanged. What's in - Schema (crewing_crew_records migration): SeafarerDocument, NextOfKin (isEmergency), ExperienceRecord, PpeIssue (PpeItem enum) — all on CrewMember; CrewActionType += DOCUMENT_UPLOADED/RECORD_UPDATED/PPE_ISSUED/PPE_RETURNED/ EXPERIENCE_ADDED. - PII masking (lib/crew-pii.ts, §6/§8.8): bank account + Aadhaar full only for Accounts/SuperUser, masked otherwise; salary hidden from site staff. Applied server-side before crossing to the client. - Actions (crewing/crew/actions.ts): uploadDocument/deleteDocument, saveBankEpf, addNextOfKin/deleteNextOfKin, issuePpe/returnPpe, addExperience — guarded by upload_crew_records / issue_ppe, each writes a CrewAction. - Screens: /crewing/crew (directory, search + vessel filter, ex-hands excluded) and /crewing/crew/[id] (tabbed profile: Documents · Bank & EPF · Next of kin · PPE · Experience · Pay status). Crew added to the flag-gated nav (MGR/MPO/Site/ Accounts). Tests & docs - Unit: crew-pii.test.ts (6). Integration: crew-records.test.ts (7) — documents, bank/EPF upsert, NoK, PPE issue/return, experience + permission gating. type-check clean; full unit (240) + integration (175) green. - CLAUDE.md updated with the Phase 4a surface. Deferred: site-staff own-site scoping (needs a User↔Site link); the records verify queue (§8.11, Phase 5); Pay-status shows the salary structure only until payroll (Phase 6). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
93 lines
3.8 KiB
TypeScript
93 lines
3.8 KiB
TypeScript
"use client";
|
|
|
|
import { useMemo, useState } from "react";
|
|
import Link from "next/link";
|
|
import type { AssignmentStatus } from "@prisma/client";
|
|
import { Badge } from "@/components/ui/badge";
|
|
|
|
type CrewRow = {
|
|
id: string;
|
|
name: string;
|
|
employeeId: string;
|
|
rank: string;
|
|
location: string;
|
|
status: AssignmentStatus | null;
|
|
};
|
|
|
|
const INPUT =
|
|
"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";
|
|
|
|
function StatusBadge({ status }: { status: AssignmentStatus | null }) {
|
|
if (status === "ACTIVE") return <Badge variant="success">Active</Badge>;
|
|
if (status === "ON_LEAVE") return <Badge variant="warning">On leave</Badge>;
|
|
return <Badge variant="secondary">—</Badge>;
|
|
}
|
|
|
|
export function CrewDirectory({ crew }: { crew: CrewRow[] }) {
|
|
const [search, setSearch] = useState("");
|
|
const [location, setLocation] = useState("ALL");
|
|
|
|
const locations = useMemo(
|
|
() => Array.from(new Set(crew.map((c) => c.location).filter((l) => l !== "—"))).sort(),
|
|
[crew]
|
|
);
|
|
|
|
const filtered = useMemo(() => {
|
|
const q = search.trim().toLowerCase();
|
|
return crew.filter((c) => {
|
|
if (location !== "ALL" && c.location !== location) return false;
|
|
if (q && !`${c.name} ${c.employeeId} ${c.rank}`.toLowerCase().includes(q)) return false;
|
|
return true;
|
|
});
|
|
}, [crew, search, location]);
|
|
|
|
return (
|
|
<div>
|
|
<div className="mb-6">
|
|
<h1 className="text-2xl font-semibold text-neutral-900">Crew</h1>
|
|
<p className="text-sm text-neutral-500 mt-0.5">{crew.length} active crew member{crew.length === 1 ? "" : "s"}</p>
|
|
</div>
|
|
|
|
<div className="mb-4 flex flex-wrap items-center gap-3">
|
|
<input className={`${INPUT} flex-1 min-w-[200px]`} placeholder="Search name, employee no or rank…" value={search} onChange={(e) => setSearch(e.target.value)} />
|
|
<select className={INPUT} value={location} onChange={(e) => setLocation(e.target.value)}>
|
|
<option value="ALL">All vessels / sites</option>
|
|
{locations.map((l) => <option key={l} value={l}>{l}</option>)}
|
|
</select>
|
|
</div>
|
|
|
|
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-neutral-200 bg-neutral-50 text-left text-xs font-semibold text-neutral-500 uppercase tracking-wide">
|
|
<th className="px-4 py-3">Name</th>
|
|
<th className="px-4 py-3">Employee</th>
|
|
<th className="px-4 py-3">Rank</th>
|
|
<th className="px-4 py-3">Vessel / site</th>
|
|
<th className="px-4 py-3">Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{filtered.length === 0 ? (
|
|
<tr><td colSpan={5} className="px-4 py-12 text-center text-neutral-400">
|
|
{crew.length === 0 ? "No crew onboarded yet." : "No crew match these filters."}
|
|
</td></tr>
|
|
) : (
|
|
filtered.map((c) => (
|
|
<tr key={c.id} className="border-b border-neutral-100 last:border-0 hover:bg-neutral-50">
|
|
<td className="px-4 py-3">
|
|
<Link href={`/crewing/crew/${c.id}`} className="font-medium text-neutral-900 hover:text-primary-700">{c.name}</Link>
|
|
</td>
|
|
<td className="px-4 py-3 font-mono text-xs text-neutral-600">{c.employeeId}</td>
|
|
<td className="px-4 py-3 text-neutral-700">{c.rank}</td>
|
|
<td className="px-4 py-3 text-neutral-700">{c.location}</td>
|
|
<td className="px-4 py-3"><StatusBadge status={c.status} /></td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|