pelagia-portal/App/app/(portal)/crewing/crew/crew-directory.tsx
Hardik 37b1debc9d
All checks were successful
PR checks / checks (pull_request) Successful in 40s
PR checks / integration (pull_request) Successful in 28s
feat(crewing): Phase 4a — crew records & profile + PPE (flagged)
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>
2026-06-22 19:27:21 +05:30

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>
);
}