Final slice of Phase 3 (stacked on 3b pipeline). The onboarding transaction that turns a SELECTED candidate into active crew, per Crewing-Implementation-Spec §8.5/§9/§11. Behind NEXT_PUBLIC_CREWING_ENABLED; production unchanged. What's in - Schema (crewing_onboarding migration): CrewAssignment + AssignmentStatus (ACTIVE/ON_LEAVE/SIGNED_OFF — leave/sign-off are Phase 4); ContractLetter (salaryRestricted); SalaryStructure += assignmentId; CrewActionType += CREW_ONBOARDED. Employee numbers CRW-xxxx via lib/employee-number.ts. - Action (onboardCandidate, onboard_crew): one transaction off a SELECTED application — assign employeeId, create CrewAssignment(ACTIVE, signOnDate), bind the approved SalaryStructure (assignmentId + effectiveFrom), Application → ONBOARDED, Requisition → FILLED, CrewMember → EMPLOYEE (+ currentRank); contract letter stored after. Guards flag + permission + SELECTED state. - Screen: the SELECTED action card's "Onboard to crew" modal (joining date, contract upload, starts-automatically chips); the CRW- number shows on the ONBOARDED card. Tests & docs - Integration: onboarding.test.ts (5) — full transaction, requisition FILLED + salary binding, joining-date + SELECTED-only guards, permission gating, sequential CRW- ids. type-check clean; full unit (234) + integration (168) green. - CLAUDE.md updated with the Phase 3c surface. Deferred: SITE_STAFF login creation for management ranks (grantsLogin) — a follow-up; attendance/experience/PPE records begin in Phase 4. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
144 lines
6.5 KiB
TypeScript
144 lines
6.5 KiB
TypeScript
import { auth } from "@/auth";
|
|
import { db } from "@/lib/db";
|
|
import { hasPermission } from "@/lib/permissions";
|
|
import { CREWING_ENABLED } from "@/lib/feature-flags";
|
|
import { redirect, notFound } from "next/navigation";
|
|
import Link from "next/link";
|
|
import { ArrowLeft, Check } from "lucide-react";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { ApplicationActionCard } from "../application-action-card";
|
|
import { STAGE_ORDER, STAGE_LABEL, STAGE_VARIANT, stageIndex } from "../application-ui";
|
|
import { experienceLabel } from "../../candidates/candidate-ui";
|
|
import { cn } from "@/lib/utils";
|
|
import type { Metadata } from "next";
|
|
|
|
export const metadata: Metadata = { title: "Application" };
|
|
|
|
export default async function ApplicationDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
|
if (!CREWING_ENABLED) notFound();
|
|
|
|
const session = await auth();
|
|
if (!session?.user) redirect("/login");
|
|
const role = session.user.role;
|
|
if (!hasPermission(role, "view_requisitions") && !hasPermission(role, "manage_candidates")) redirect("/dashboard");
|
|
|
|
const { id } = await params;
|
|
const app = await db.application.findUnique({
|
|
where: { id },
|
|
include: {
|
|
requisition: { include: { rank: { select: { name: true } }, vessel: { select: { name: true } }, site: { select: { name: true } } } },
|
|
crewMember: { include: { appliedRank: { select: { name: true } }, currentRank: { select: { name: true } } } },
|
|
gates: true,
|
|
salaryStructures: { orderBy: { createdAt: "desc" } },
|
|
},
|
|
});
|
|
if (!app) notFound();
|
|
|
|
const gate = (t: string) => app.gates.find((g) => g.gate === t);
|
|
const salaryPending = gate("SALARY")?.result === "PENDING";
|
|
const waiverPending = gate("WAIVER")?.result === "PENDING";
|
|
const selectionPending = gate("SELECTION")?.result === "PENDING";
|
|
const proposed = app.salaryStructures.find((s) => !s.approvedById) ?? app.salaryStructures[0] ?? null;
|
|
|
|
const loc = app.requisition.vessel?.name ?? app.requisition.site?.name ?? "—";
|
|
const curIdx = stageIndex(app.stage);
|
|
|
|
return (
|
|
<div className="max-w-4xl">
|
|
<Link href={`/crewing/requisitions/${app.requisition.id}/pipeline`} 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" /> Pipeline · {app.requisition.code}
|
|
</Link>
|
|
|
|
<div className="mb-6 flex items-center gap-3">
|
|
<h1 className="text-2xl font-semibold text-neutral-900">{app.crewMember.name}</h1>
|
|
<Badge variant={STAGE_VARIANT[app.stage]}>{STAGE_LABEL[app.stage]}</Badge>
|
|
{app.crewMember.type === "EX_HAND" && (
|
|
<span className="rounded-full bg-purple-100 text-purple-700 px-2.5 py-0.5 text-xs font-medium">Returning crew</span>
|
|
)}
|
|
</div>
|
|
<p className="text-sm text-neutral-500 -mt-4 mb-6">
|
|
{app.requisition.rank.name} · {loc} · <span className="font-mono">{app.requisition.code}</span>
|
|
</p>
|
|
|
|
{/* 7-step stepper */}
|
|
<div className="mb-6 flex flex-wrap gap-2">
|
|
{STAGE_ORDER.map((s, i) => {
|
|
const done = curIdx > i || app.stage === "ONBOARDED";
|
|
const current = curIdx === i;
|
|
return (
|
|
<div key={s} className={cn(
|
|
"flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium",
|
|
done ? "bg-success-100 text-success-700" : current ? "bg-primary-100 text-primary-700" : "bg-neutral-100 text-neutral-400"
|
|
)}>
|
|
{done && <Check className="h-3 w-3" />}
|
|
{STAGE_LABEL[s]}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
{/* Adaptive action card */}
|
|
<ApplicationActionCard
|
|
id={app.id}
|
|
stage={app.stage}
|
|
isExHand={app.crewMember.type === "EX_HAND"}
|
|
interviewResult={app.interviewResult}
|
|
interviewWaived={app.interviewWaived}
|
|
rejectedReason={app.rejectedReason}
|
|
salaryPending={salaryPending}
|
|
waiverPending={waiverPending}
|
|
selectionPending={selectionPending}
|
|
employeeNo={app.crewMember.employeeId}
|
|
salary={proposed ? {
|
|
rateBasis: proposed.rateBasis,
|
|
basic: Number(proposed.basic),
|
|
victualingPerDay: Number(proposed.victualingPerDay),
|
|
currency: proposed.currency,
|
|
approved: Boolean(proposed.approvedById),
|
|
} : null}
|
|
perms={{
|
|
manage: hasPermission(role, "manage_candidates"),
|
|
recordReference: hasPermission(role, "record_reference_check"),
|
|
recordInterview: hasPermission(role, "record_interview_result"),
|
|
requestWaiver: hasPermission(role, "request_interview_waiver"),
|
|
approveSalary: hasPermission(role, "approve_salary_structure"),
|
|
approveWaiver: hasPermission(role, "approve_interview_waiver"),
|
|
select: hasPermission(role, "select_candidate"),
|
|
onboard: hasPermission(role, "onboard_crew"),
|
|
}}
|
|
/>
|
|
|
|
{/* Profile */}
|
|
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden h-fit">
|
|
<div className="px-4 py-3 border-b border-neutral-200 bg-neutral-50">
|
|
<h2 className="text-sm font-semibold text-neutral-900">Profile</h2>
|
|
</div>
|
|
<dl className="divide-y divide-neutral-100">
|
|
{([
|
|
["Rank applied", app.crewMember.appliedRank?.name ?? app.requisition.rank.name],
|
|
["Last rank held", app.crewMember.currentRank?.name ?? "—"],
|
|
["Experience", experienceLabel(app.crewMember.experienceMonths)],
|
|
["Source", app.crewMember.source],
|
|
] as [string, string][]).map(([k, v]) => (
|
|
<div key={k} className="flex justify-between gap-4 px-4 py-2.5">
|
|
<dt className="text-sm text-neutral-500">{k}</dt>
|
|
<dd className="text-sm text-neutral-900 text-right">{v}</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
{app.crewMember.type === "EX_HAND" && (
|
|
<div className="px-4 py-3 border-t border-neutral-100 text-xs text-purple-700 bg-purple-50">
|
|
Returning crew — prior docs/bank/tour on file; interview may be waived with Manager approval.
|
|
</div>
|
|
)}
|
|
<div className="px-4 py-3 border-t border-neutral-100">
|
|
<Link href={`/crewing/candidates/${app.crewMember.id}`} className="text-sm text-primary-600 hover:underline">
|
|
View full candidate profile →
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|