diff --git a/App/CLAUDE.md b/App/CLAUDE.md index 0820d46..1238bf5 100644 --- a/App/CLAUDE.md +++ b/App/CLAUDE.md @@ -143,6 +143,14 @@ A crew-management module built incrementally per the **wiki `Crewing-Implementat - **Screens:** `/crewing/candidates` (master list with search / source / rank-applied / min-experience filters rendered as removable chips + match count + Clear all; Add-candidate modal) and `/crewing/candidates/[id]` (profile; the 7-stage pipeline/stepper is 3b). **Candidates** added to the flag-gated Crewing nav (Manager + MPO). - **Deferred:** the public careers intake API (A2, §13 open question) — 3a uses the internal Add-candidate modal only; CVs are stored but not parsed. +**Phase 3b — Recruitment pipeline (Epic C; spec §5.1/§8.4–8.5/§8.13):** + +- **Models:** `Application` (one per requisition+candidate) drives the 7-stage `ApplicationStage` (`SHORTLISTED → COMPETENCY_AND_REFERENCES → DOC_VERIFICATION → SALARY_AGREEMENT → PROPOSED → INTERVIEW → SELECTED`; `→ REJECTED`; `ONBOARDED` is 3c). `ApplicationGate` records each vetting gate — `SALARY` / `SELECTION` / `WAIVER` gates with `result=PENDING` are the Manager's queue items. `ReferenceCheck`, effective-dated `SalaryStructure` (attached to the Application in 3b; bound to the assignment in 3c), and minimal `BankDetail` / `EpfDetail` captured at DOC_VERIFICATION (PII encryption deferred to Phase 4). `CrewAction` gained `applicationId`. +- **State machine:** `lib/application-pipeline.ts` (mirrors po/requisition machines) — sourcing advances are MPO/Manager; `approve_salary` and `select` are Manager-only; `canReject` is orthogonal. `BOARD_STAGES` is the 7 columns. +- **Actions** (`app/(portal)/crewing/applications/actions.ts`): `addApplication` (first candidate moves the requisition OPEN→SHORTLISTING), `advanceStage`, `recordReferenceCheck`, `verifyDocuments` (captures bank/EPF), `agreeSalary`→`approveSalary`/`returnSalary`, `recordInterviewResult`, `requestInterviewWaiver`→`approveInterviewWaiver`/`declineInterviewWaiver`, `selectCandidate`/`returnSelection` (sets requisition→SELECTED), `rejectApplication`. Waiver is **never automatic** (R2). Notifications: `SALARY_FOR_APPROVAL` / `SELECTION_FOR_APPROVAL` / `WAIVER_REQUESTED` (+ `CANDIDATE_PROPOSED`). +- **Screens:** pipeline board per requisition (`/crewing/requisitions/[id]/pipeline`, 7 columns + Add-candidate), the application workhorse (`/crewing/applications/[id]` — 7-step stepper + adaptive per-stage action card), and an **"Open pipeline"** action on the requisition detail. +- **Central approvals (§8.13 R8):** `/approvals` now also lists pending crewing gates (Salary / Selection / Waiver) with inline Approve/Return, alongside POs — one unified Manager queue. + ### GST Calculation `totalAmount = sum(quantity × unitPrice × (1 + gstRate))` for each line item. The `gstRate` is stored as a decimal on `POLineItem` (e.g., `0.18` = 18%). This applies in Server Actions when computing `totalPrice` per line and the PO `totalAmount`. diff --git a/App/app/(portal)/approvals/crewing-approvals.tsx b/App/app/(portal)/approvals/crewing-approvals.tsx new file mode 100644 index 0000000..22ff719 --- /dev/null +++ b/App/app/(portal)/approvals/crewing-approvals.tsx @@ -0,0 +1,113 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { Badge } from "@/components/ui/badge"; +import { AdminDialog } from "@/components/ui/admin-dialog"; +import { + approveSalary, + returnSalary, + selectCandidate, + returnSelection, + approveInterviewWaiver, + declineInterviewWaiver, +} from "../crewing/applications/actions"; + +export type CrewApprovalKind = "SALARY" | "SELECTION" | "WAIVER"; + +export type CrewApprovalItem = { + applicationId: string; + kind: CrewApprovalKind; + candidateName: string; + rank: string; + requisitionCode: string; + detail: string; // amount for salary, etc. +}; + +const KIND_LABEL: Record = { SALARY: "Salary", SELECTION: "Selection", WAIVER: "Waiver" }; +const KIND_VARIANT = { SALARY: "warning", SELECTION: "default", WAIVER: "secondary" } as const; + +const approveFn: Record Promise<{ ok: true } | { error: string }>> = { + SALARY: approveSalary, + SELECTION: selectCandidate, + WAIVER: approveInterviewWaiver, +}; +const returnFn: Record Promise<{ ok: true } | { error: string }>> = { + SALARY: returnSalary, + SELECTION: returnSelection, + WAIVER: declineInterviewWaiver, +}; + +function Row({ item }: { item: CrewApprovalItem }) { + const router = useRouter(); + const [pending, setPending] = useState(false); + const [error, setError] = useState(""); + const [returnOpen, setReturnOpen] = useState(false); + const [reason, setReason] = useState(""); + + async function approve() { + setPending(true); setError(""); + const res = await approveFn[item.kind](item.applicationId); + setPending(false); + if ("error" in res) setError(res.error); else router.refresh(); + } + async function doReturn(e: React.FormEvent) { + e.preventDefault(); + setPending(true); setError(""); + const res = await returnFn[item.kind](item.applicationId, reason); + setPending(false); + if ("error" in res) setError(res.error); else { setReturnOpen(false); router.refresh(); } + } + + return ( + + {KIND_LABEL[item.kind]} + + {item.candidateName} + {item.rank} · {item.requisitionCode} + + {item.detail} + +
+ + +
+ {error &&

{error}

} + setReturnOpen(false)}> +
+