From da2d856b738ba2f26d4aac49d7e1f53bf0a3e4dd Mon Sep 17 00:00:00 2001 From: Hardik Date: Mon, 22 Jun 2026 04:57:11 +0530 Subject: [PATCH 1/5] feat(po): submitter view-all of POs + History + export (feature-flagged) Gated behind NEXT_PUBLIC_SUBMITTER_VIEW_ALL_ENABLED (opt-in, "true"). When on, submitter roles (TECHNICAL/MANNING) get read-only access to every PO: the History page + report export, any other user's PO detail page, and the per-PO Export PDF/XLSX buttons. No approval/payment/edit rights are added. - lib/feature-flags.ts: SUBMITTER_VIEW_ALL_ENABLED flag - lib/permissions.ts: isSubmitterRole / submitterCanViewAll / canViewAllPos - po/[id] page + export route: gate via canViewAllPos - history page + reports/export route: OR submitterCanViewAll into export_reports - sidebar: show History to submitters when flag on - tests: permission helpers, both flag states - docs: .env.example, CLAUDE.md (wiki updated separately) Co-Authored-By: Claude Opus 4.8 --- App/.env.example | 7 +++ App/CLAUDE.md | 1 + App/app/(portal)/history/page.tsx | 11 ++++- App/app/(portal)/po/[id]/page.tsx | 11 ++--- App/app/api/po/[id]/export/route.ts | 6 ++- App/app/api/reports/export/route.ts | 7 ++- App/components/layout/sidebar.tsx | 11 ++++- App/lib/feature-flags.ts | 9 ++++ App/lib/permissions.ts | 29 ++++++++++++ App/tests/unit/permissions.test.ts | 68 ++++++++++++++++++++++++++++- 10 files changed, 145 insertions(+), 15 deletions(-) diff --git a/App/.env.example b/App/.env.example index a22649f..a80b2c7 100644 --- a/App/.env.example +++ b/App/.env.example @@ -55,6 +55,13 @@ FORGEJO_URL=https://git.pelagiamarine.com FORGEJO_REPO=shad0w/pelagia-portal FORGEJO_TOKEN= +# ── Feature flags (NEXT_PUBLIC_, available to client + server) ─ +# Inventory tracking (site stock / consumption). On unless explicitly "false". +# NEXT_PUBLIC_INVENTORY_ENABLED=false +# Let submitters (TECHNICAL/MANNING) read & export every PO and open the History +# page (read-only). Opt-in — on only when exactly "true". +# NEXT_PUBLIC_SUBMITTER_VIEW_ALL_ENABLED=true + # ── Non-production banner ───────────────────────────────────── # When set, a fixed "internal dev / staging" banner is shown (EnvBanner). # Leave UNSET in production. Staging sets this automatically. diff --git a/App/CLAUDE.md b/App/CLAUDE.md index 6037430..dfc1951 100644 --- a/App/CLAUDE.md +++ b/App/CLAUDE.md @@ -142,6 +142,7 @@ FORGEJO_URL, FORGEJO_REPO, FORGEJO_TOKEN GST_SERVICE_URL # GstService microservice (defaults to localhost:3003) NEXT_PUBLIC_INVENTORY_ENABLED # Inventory feature flag +NEXT_PUBLIC_SUBMITTER_VIEW_ALL_ENABLED # Opt-in ("true"): submitters (TECHNICAL/MANNING) read & export every PO + History (read-only) NEXT_PUBLIC_ENV_LABEL # When set, shows a non-prod banner (EnvBanner). Leave unset in prod. ``` diff --git a/App/app/(portal)/history/page.tsx b/App/app/(portal)/history/page.tsx index 56b91bf..23d7f17 100644 --- a/App/app/(portal)/history/page.tsx +++ b/App/app/(portal)/history/page.tsx @@ -1,6 +1,6 @@ import { auth } from "@/auth"; import { db } from "@/lib/db"; -import { hasPermission } from "@/lib/permissions"; +import { hasPermission, submitterCanViewAll } from "@/lib/permissions"; import { redirect } from "next/navigation"; import Link from "next/link"; import { formatCurrency, formatDate } from "@/lib/utils"; @@ -27,7 +27,14 @@ export default async function HistoryPage({ searchParams }: Props) { const session = await auth(); if (!session?.user) redirect("/login"); - if (!hasPermission(session.user.role, "export_reports")) redirect("/dashboard"); + // Report-export holders see History; submitters get read+export access when the + // submitter-view-all feature flag is on. + if ( + !hasPermission(session.user.role, "export_reports") && + !submitterCanViewAll(session.user.role) + ) { + redirect("/dashboard"); + } const { dateFrom, dateTo, approvedFrom, approvedTo, vesselId, status } = await searchParams; diff --git a/App/app/(portal)/po/[id]/page.tsx b/App/app/(portal)/po/[id]/page.tsx index e61d47b..cb5cabd 100644 --- a/App/app/(portal)/po/[id]/page.tsx +++ b/App/app/(portal)/po/[id]/page.tsx @@ -2,6 +2,7 @@ import { auth } from "@/auth"; import { db } from "@/lib/db"; import { notFound, redirect } from "next/navigation"; import { PoDetail } from "@/components/po/po-detail"; +import { canViewAllPos } from "@/lib/permissions"; import { VendorIdForm } from "./vendor-id-form"; import type { Metadata } from "next"; @@ -39,11 +40,11 @@ export default async function PoDetailPage({ params }: Props) { if (!po) notFound(); - // Submitters can only view their own POs (unless they have view_all_pos) - const canViewAll = ["ACCOUNTS", "MANAGER", "SUPERUSER", "AUDITOR", "ADMIN"].includes( - session.user.role - ); - if (!canViewAll && po.submitterId !== session.user.id) redirect("/dashboard"); + // Submitters can only view their own POs — unless they hold view_all_pos, or the + // submitter-view-all feature flag grants them read access to every PO. + if (!canViewAllPos(session.user.role) && po.submitterId !== session.user.id) { + redirect("/dashboard"); + } const canProvideVendorId = po.status === "VENDOR_ID_PENDING" && diff --git a/App/app/api/po/[id]/export/route.ts b/App/app/api/po/[id]/export/route.ts index 054a9e4..41e895e 100644 --- a/App/app/api/po/[id]/export/route.ts +++ b/App/app/api/po/[id]/export/route.ts @@ -7,6 +7,7 @@ import { downloadBuffer } from "@/lib/storage"; import { CANCELLED_WATERMARK_PNG_BASE64, CANCELLED_WATERMARK_W, CANCELLED_WATERMARK_H } from "@/lib/cancelled-watermark"; import { getImageSize, scaleToBox } from "@/lib/image-size"; import { signatoryLayout } from "@/lib/po-export-layout"; +import { canViewAllPos } from "@/lib/permissions"; // ── Company fallback constants (used when no company is linked to a PO) ────── @@ -66,8 +67,9 @@ export async function GET(request: NextRequest, { params }: Props) { }); if (!po) return NextResponse.json({ error: "Not found" }, { status: 404 }); - const canViewAll = ["ACCOUNTS", "MANAGER", "SUPERUSER", "AUDITOR", "ADMIN"].includes(session.user.role); - if (!canViewAll && po.submitterId !== session.user.id) { + // view_all_pos holders, or submitters when the view-all feature flag is on, may export + // any PO; everyone else only their own. + if (!canViewAllPos(session.user.role) && po.submitterId !== session.user.id) { return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } diff --git a/App/app/api/reports/export/route.ts b/App/app/api/reports/export/route.ts index 19bdf4b..ee82eae 100644 --- a/App/app/api/reports/export/route.ts +++ b/App/app/api/reports/export/route.ts @@ -1,6 +1,6 @@ import { auth } from "@/auth"; import { db } from "@/lib/db"; -import { hasPermission } from "@/lib/permissions"; +import { hasPermission, submitterCanViewAll } from "@/lib/permissions"; import { NextRequest, NextResponse } from "next/server"; import type { POStatus } from "@prisma/client"; @@ -16,7 +16,10 @@ export async function GET(request: NextRequest) { if (!session?.user) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - if (!hasPermission(session.user.role, "export_reports")) { + if ( + !hasPermission(session.user.role, "export_reports") && + !submitterCanViewAll(session.user.role) + ) { return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } diff --git a/App/components/layout/sidebar.tsx b/App/components/layout/sidebar.tsx index fcd17bc..95aaa64 100644 --- a/App/components/layout/sidebar.tsx +++ b/App/components/layout/sidebar.tsx @@ -2,7 +2,7 @@ import { usePathname } from "next/navigation"; import Link from "next/link"; -import { INVENTORY_ENABLED } from "@/lib/feature-flags"; +import { INVENTORY_ENABLED, SUBMITTER_VIEW_ALL_ENABLED } from "@/lib/feature-flags"; import { cn } from "@/lib/utils"; import { LayoutDashboard, @@ -34,6 +34,13 @@ interface NavItem { roles?: Role[]; } +// History is open to all-PO viewers; when the submitter-view-all flag is on, submitters +// (TECHNICAL / MANNING) get read+export access to it too. +const HISTORY_ROLES: Role[] = [ + "MANAGER", "SUPERUSER", "AUDITOR", "ADMIN", + ...(SUBMITTER_VIEW_ALL_ENABLED ? (["TECHNICAL", "MANNING"] as Role[]) : []), +]; + const NAV_ITEMS: NavItem[] = [ { href: "/dashboard", label: "Dashboard", icon: LayoutDashboard }, { href: "/po/new", label: "New PO", icon: Plus, roles: ["TECHNICAL", "MANNING", "MANAGER", "SUPERUSER"] }, @@ -42,7 +49,7 @@ const NAV_ITEMS: NavItem[] = [ { href: "/approvals", label: "Approvals", icon: CheckSquare, roles: ["MANAGER", "SUPERUSER"] }, { href: "/payments", label: "Payments", icon: CreditCard, roles: ["ACCOUNTS"] }, { href: "/payments/history", label: "Payment History", icon: Receipt, roles: ["ACCOUNTS", "SUPERUSER"] }, - { href: "/history", label: "History", icon: History, roles: ["MANAGER", "SUPERUSER", "AUDITOR", "ADMIN"] }, + { href: "/history", label: "History", icon: History, roles: HISTORY_ROLES }, { href: "/profile", label: "My Profile", icon: UserCircle }, ]; diff --git a/App/lib/feature-flags.ts b/App/lib/feature-flags.ts index 3b662a2..cd442f6 100644 --- a/App/lib/feature-flags.ts +++ b/App/lib/feature-flags.ts @@ -4,7 +4,16 @@ * * NEXT_PUBLIC_INVENTORY_ENABLED=false → hides inventory tracking (site qty/consumption) * Vendor list, product catalogue, and cart remain available for PO creation regardless. + * + * NEXT_PUBLIC_SUBMITTER_VIEW_ALL_ENABLED=true → lets submitters (TECHNICAL / MANNING) + * read every PO (not just their own), open the History page, and use the export buttons. + * Opt-in (off unless explicitly "true") because it widens read access. Submitters stay + * read-only — it grants no approval, payment, or edit rights. See lib/permissions.ts + * (canViewAllPos / submitterCanViewAll). */ export const INVENTORY_ENABLED = process.env.NEXT_PUBLIC_INVENTORY_ENABLED !== "false"; + +export const SUBMITTER_VIEW_ALL_ENABLED = + process.env.NEXT_PUBLIC_SUBMITTER_VIEW_ALL_ENABLED === "true"; diff --git a/App/lib/permissions.ts b/App/lib/permissions.ts index 24c549c..ebfec96 100644 --- a/App/lib/permissions.ts +++ b/App/lib/permissions.ts @@ -1,4 +1,5 @@ import type { Role } from "@prisma/client"; +import { SUBMITTER_VIEW_ALL_ENABLED } from "./feature-flags"; export type Permission = | "create_po" @@ -92,3 +93,31 @@ export function requirePermission(role: Role, permission: Permission): void { export function getPermissions(role: Role): Permission[] { return ROLE_PERMISSIONS[role] ?? []; } + +// ── Submitter roles & feature-flagged view-all ──────────────────────────────── +// Submitters raise and track their own POs. The two "submitter" roles below hold +// `view_own_pos` but not `view_all_pos`. + +export const SUBMITTER_ROLES: Role[] = ["TECHNICAL", "MANNING"]; + +export function isSubmitterRole(role: Role): boolean { + return SUBMITTER_ROLES.includes(role); +} + +/** + * Feature-flagged: when NEXT_PUBLIC_SUBMITTER_VIEW_ALL_ENABLED=true, submitters may + * read & export every PO (not just their own) and reach the History page. This is a + * read-only widening — it does not grant approval, payment, or edit rights. + */ +export function submitterCanViewAll(role: Role): boolean { + return SUBMITTER_VIEW_ALL_ENABLED && isSubmitterRole(role); +} + +/** + * Whether a role may view/export any PO, not just the ones they submitted. + * True for `view_all_pos` holders (ACCOUNTS, MANAGER, SUPERUSER, AUDITOR, ADMIN) and, + * when the feature flag is on, for submitters too. + */ +export function canViewAllPos(role: Role): boolean { + return hasPermission(role, "view_all_pos") || submitterCanViewAll(role); +} diff --git a/App/tests/unit/permissions.test.ts b/App/tests/unit/permissions.test.ts index ef514bd..2afdf4d 100644 --- a/App/tests/unit/permissions.test.ts +++ b/App/tests/unit/permissions.test.ts @@ -1,5 +1,11 @@ -import { describe, it, expect } from "vitest"; -import { hasPermission, requirePermission } from "@/lib/permissions"; +import { describe, it, expect, vi, afterEach } from "vitest"; +import { + hasPermission, + requirePermission, + isSubmitterRole, + submitterCanViewAll, + canViewAllPos, +} from "@/lib/permissions"; describe("Permissions", () => { describe("hasPermission", () => { @@ -99,6 +105,64 @@ describe("Permissions", () => { }); }); + // ── Submitter view-all (feature-flagged) ────────────────────────────────── + describe("isSubmitterRole", () => { + it("is true for the two submitter roles", () => { + expect(isSubmitterRole("TECHNICAL")).toBe(true); + expect(isSubmitterRole("MANNING")).toBe(true); + }); + + it("is false for every other role", () => { + for (const role of ["ACCOUNTS", "MANAGER", "SUPERUSER", "AUDITOR", "ADMIN"] as const) { + expect(isSubmitterRole(role)).toBe(false); + } + }); + }); + + describe("canViewAllPos / submitterCanViewAll — flag OFF (default)", () => { + it("submitters cannot view all POs", () => { + expect(canViewAllPos("TECHNICAL")).toBe(false); + expect(canViewAllPos("MANNING")).toBe(false); + expect(submitterCanViewAll("TECHNICAL")).toBe(false); + }); + + it("view_all_pos holders can still view all POs", () => { + for (const role of ["ACCOUNTS", "MANAGER", "SUPERUSER", "AUDITOR", "ADMIN"] as const) { + expect(canViewAllPos(role)).toBe(true); + } + }); + }); + + describe("canViewAllPos / submitterCanViewAll — flag ON", () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + it("submitters gain view-all when NEXT_PUBLIC_SUBMITTER_VIEW_ALL_ENABLED=true", async () => { + vi.resetModules(); + vi.stubEnv("NEXT_PUBLIC_SUBMITTER_VIEW_ALL_ENABLED", "true"); + const perms = await import("@/lib/permissions"); + + expect(perms.submitterCanViewAll("TECHNICAL")).toBe(true); + expect(perms.submitterCanViewAll("MANNING")).toBe(true); + expect(perms.canViewAllPos("TECHNICAL")).toBe(true); + expect(perms.canViewAllPos("MANNING")).toBe(true); + }); + + it("does not widen non-submitter roles, and is read-only (no approve/edit)", async () => { + vi.resetModules(); + vi.stubEnv("NEXT_PUBLIC_SUBMITTER_VIEW_ALL_ENABLED", "true"); + const perms = await import("@/lib/permissions"); + + expect(perms.submitterCanViewAll("MANAGER")).toBe(false); + expect(perms.canViewAllPos("ACCOUNTS")).toBe(true); // unchanged + // The flag grants read access only — no approval or edit rights. + expect(perms.hasPermission("TECHNICAL", "approve_po")).toBe(false); + expect(perms.hasPermission("TECHNICAL", "view_all_pos")).toBe(false); + }); + }); + describe("requirePermission", () => { it("does not throw when permission is granted", () => { expect(() => requirePermission("MANAGER", "approve_po")).not.toThrow(); From e951a44a674af1bb840db054fd4b83e041d5e663 Mon Sep 17 00:00:00 2001 From: Hardik Date: Tue, 23 Jun 2026 21:33:50 +0530 Subject: [PATCH 2/5] fix(crewing): make rank-held universal, ex-hand an admin-only flag Rank held applies to every candidate, not just ex-hands; it auto-updates for returning crew on sign-off. Ex-hand designation is decoupled from the Source dropdown and owned by the office: - Candidate form: drop the EX_HAND source option, relabel "Rank held (ex-hands)" to "Rank held". addCandidate always intakes NEW/CANDIDATE (ex-hand recognition still reuses an existing EX_HAND row); updateCandidate no longer rewrites type/status, so an admin-set EX_HAND or onboarded EMPLOYEE is never clobbered by a candidate edit. - Admin crew form: the type NEW/EX_HAND select becomes an "Ex-hand (returning crew)" checkbox -- the only place ex-hand is tagged. - List/detail ex-hand indicators key on type === EX_HAND (not source). - Sign-off preserves the original recruitment source when flipping to EX_HAND. - Tests seed EX_HAND rows directly; assert candidate intake stays NEW. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../admin/crew/admin-crew-manager.tsx | 6 +- .../(portal)/crewing/candidates/[id]/page.tsx | 4 +- .../(portal)/crewing/candidates/actions.ts | 85 +++++++++---------- .../crewing/candidates/candidate-form.tsx | 10 ++- .../crewing/candidates/candidate-ui.ts | 5 ++ .../crewing/candidates/candidates-manager.tsx | 12 +-- App/app/(portal)/crewing/candidates/page.tsx | 1 + App/app/(portal)/crewing/crew/actions.ts | 7 +- App/tests/integration/candidates.test.ts | 35 +++++--- 9 files changed, 92 insertions(+), 73 deletions(-) diff --git a/App/app/(portal)/admin/crew/admin-crew-manager.tsx b/App/app/(portal)/admin/crew/admin-crew-manager.tsx index 2fe7d7d..5dc725c 100644 --- a/App/app/(portal)/admin/crew/admin-crew-manager.tsx +++ b/App/app/(portal)/admin/crew/admin-crew-manager.tsx @@ -15,7 +15,6 @@ const SECONDARY = "rounded-lg border border-neutral-300 px-4 py-2 text-sm font-m const STATUSES: CrewStatus[] = ["PROSPECT", "CANDIDATE", "EMPLOYEE", "EX_HAND", "BLACKLISTED"]; const SOURCES: CandidateSource[] = ["CAREERS", "EX_HAND", "WALK_IN", "REFERRAL", "OTHER"]; -const TYPES: CandidateType[] = ["NEW", "EX_HAND"]; const label = (s: string) => s.replace(/_/g, " ").toLowerCase().replace(/\b\w/g, (m) => m.toUpperCase()); type Opt = { id: string; name: string }; @@ -132,7 +131,10 @@ function CrewFormButton({ ranks, editing, open, onOpenChange }: { ranks: RankOpt setF({ ...f, name: e.target.value })} required /> - + setF({ ...f, email: e.target.value })} /> diff --git a/App/app/(portal)/crewing/candidates/[id]/page.tsx b/App/app/(portal)/crewing/candidates/[id]/page.tsx index 16884e5..f8c8e00 100644 --- a/App/app/(portal)/crewing/candidates/[id]/page.tsx +++ b/App/app/(portal)/crewing/candidates/[id]/page.tsx @@ -51,13 +51,13 @@ export default async function CandidateDetailPage({ params }: { params: Promise<

{c.name}

{STATUS_LABEL[c.status]} - {c.source === "EX_HAND" && ( + {c.type === "EX_HAND" && ( Returning crew )}
- {c.source === "EX_HAND" && ( + {c.type === "EX_HAND" && (
Returning crew. The interview may be waived with Manager approval.{" "} {c.experienceRecords.length === 0 && c.documents.length === 0 ? ( diff --git a/App/app/(portal)/crewing/candidates/actions.ts b/App/app/(portal)/crewing/candidates/actions.ts index 63972ca..68263b1 100644 --- a/App/app/(portal)/crewing/candidates/actions.ts +++ b/App/app/(portal)/crewing/candidates/actions.ts @@ -50,13 +50,6 @@ function parse(formData: FormData) { }); } -// An EX_HAND source means a returning crew member; everyone else is NEW. The -// CrewStatus follows: ex-hands sit in the pool as EX_HAND, the rest as CANDIDATE. -function derive(source: CandidateSource) { - const isExHand = source === "EX_HAND"; - return { type: isExHand ? "EX_HAND" : "NEW", status: isExHand ? "EX_HAND" : "CANDIDATE" } as const; -} - // Store an optional CV upload and return its storage key (null if none). async function storeCv(formData: FormData, crewMemberId: string): Promise { const file = formData.get("cv"); @@ -74,53 +67,53 @@ export async function addCandidate(formData: FormData): Promise { const parsed = parse(formData); if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" }; const d = parsed.data; - const { type, status } = derive(d.source); // B3 AC1 — ex-hand recognition: a returning person re-entered as a fresh - // candidate (not already tagged EX_HAND) is matched to their existing EX_HAND - // pool record by a stable key — email when given, else an exact name match — - // and the SAME row is reused (so their tour history, documents and bank stay on - // file) rather than creating a duplicate. (Heuristic: with no DOB on file a + // candidate is matched to their existing EX_HAND pool record by a stable key — + // email when given, else an exact name match — and the SAME row is reused (so + // their tour history, documents and bank stay on file) rather than creating a + // duplicate. (Ex-hand is set by the office on the admin crew record; the + // candidate form never tags it directly. Heuristic: with no DOB on file a // name-only match can in theory collide; email is preferred when available.) - if (d.source !== "EX_HAND") { - const match = await db.crewMember.findFirst({ - where: { - status: "EX_HAND", - ...(d.email - ? { email: { equals: d.email, mode: "insensitive" } } - : { name: { equals: d.name, mode: "insensitive" } }), + const match = await db.crewMember.findFirst({ + where: { + status: "EX_HAND", + ...(d.email + ? { email: { equals: d.email, mode: "insensitive" } } + : { name: { equals: d.name, mode: "insensitive" } }), + }, + select: { id: true, appliedRankId: true, currentRankId: true, email: true, phone: true, notes: true, experienceMonths: true, vesselTypeExperience: true }, + }); + if (match) { + const updated = await db.crewMember.update({ + where: { id: match.id }, + data: { + // Keep EX_HAND type/status; refresh the application's details, never + // discarding prior history (take the larger recorded experience). + appliedRankId: d.appliedRankId || match.appliedRankId, + currentRankId: d.currentRankId || match.currentRankId, + email: d.email || match.email, + phone: d.phone || match.phone, + notes: d.notes || match.notes, + experienceMonths: Math.max(d.experienceMonths, match.experienceMonths), + vesselTypeExperience: d.vesselTypeExperience || match.vesselTypeExperience, + actions: { create: { actionType: "CANDIDATE_UPDATED", actorId: g.userId, metadata: { exHandRecognized: true } } }, }, - select: { id: true, appliedRankId: true, currentRankId: true, email: true, phone: true, notes: true, experienceMonths: true, vesselTypeExperience: true }, }); - if (match) { - const updated = await db.crewMember.update({ - where: { id: match.id }, - data: { - // Keep EX_HAND type/status; refresh the application's details, never - // discarding prior history (take the larger recorded experience). - appliedRankId: d.appliedRankId || match.appliedRankId, - currentRankId: d.currentRankId || match.currentRankId, - email: d.email || match.email, - phone: d.phone || match.phone, - notes: d.notes || match.notes, - experienceMonths: Math.max(d.experienceMonths, match.experienceMonths), - vesselTypeExperience: d.vesselTypeExperience || match.vesselTypeExperience, - actions: { create: { actionType: "CANDIDATE_UPDATED", actorId: g.userId, metadata: { exHandRecognized: true } } }, - }, - }); - const cvKey = await storeCv(formData, updated.id); - if (cvKey) await db.crewMember.update({ where: { id: updated.id }, data: { cvKey } }); - revalidatePath(LIST_PATH); - return { ok: true, id: updated.id }; - } + const cvKey = await storeCv(formData, updated.id); + if (cvKey) await db.crewMember.update({ where: { id: updated.id }, data: { cvKey } }); + revalidatePath(LIST_PATH); + return { ok: true, id: updated.id }; } const candidate = await db.crewMember.create({ data: { name: d.name, source: d.source, - type, - status, + // The candidate form always intakes a fresh NEW candidate. Ex-hand status + // is an office/admin designation set on the crew record, not here. + type: "NEW", + status: "CANDIDATE", appliedRankId: d.appliedRankId || null, currentRankId: d.currentRankId || null, experienceMonths: d.experienceMonths, @@ -149,7 +142,6 @@ export async function updateCandidate(formData: FormData): Promise const parsed = parse(formData); if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" }; const d = parsed.data; - const { type, status } = derive(d.source); const existing = await db.crewMember.findUnique({ where: { id }, select: { status: true } }); if (!existing) return { error: "Candidate not found" }; @@ -161,9 +153,8 @@ export async function updateCandidate(formData: FormData): Promise data: { name: d.name, source: d.source, - // Don't downgrade an onboarded employee back to a candidate via an edit. - type, - status: existing.status === "EMPLOYEE" ? existing.status : status, + // type/status are left untouched — ex-hand / employee designation is owned + // by the office (admin crew record + sign-off), never by a candidate edit. appliedRankId: d.appliedRankId || null, currentRankId: d.currentRankId || null, experienceMonths: d.experienceMonths, diff --git a/App/app/(portal)/crewing/candidates/candidate-form.tsx b/App/app/(portal)/crewing/candidates/candidate-form.tsx index ed71b1e..f5bac50 100644 --- a/App/app/(portal)/crewing/candidates/candidate-form.tsx +++ b/App/app/(portal)/crewing/candidates/candidate-form.tsx @@ -5,7 +5,7 @@ import { useRouter } from "next/navigation"; import type { CandidateSource } from "@prisma/client"; import { AdminDialog } from "@/components/ui/admin-dialog"; import { addCandidate, updateCandidate } from "./actions"; -import { SOURCE_OPTIONS, SOURCE_LABEL } from "./candidate-ui"; +import { FORM_SOURCE_OPTIONS, SOURCE_LABEL } from "./candidate-ui"; 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"; @@ -46,7 +46,7 @@ function CandidateFields({
@@ -64,7 +64,7 @@ function CandidateFields({
- + setAdvancePercent(Number(e.target.value))} + className="w-full accent-primary-600" + /> +

+ {advancePercent >= 100 + ? "Full payment — Accounts will be prompted to pay the whole PO value." + : `Accounts will be prompted to pay ${formatCurrency(advance, currency)} first; the balance of ${formatCurrency( + Math.max(totalAmount - advance, 0), + currency + )} follows the usual part-payment flow.`} +

+
+ {(activeAction === "reject" || activeAction === "request_edits" || activeAction === "approve_note") && (
diff --git a/App/app/(portal)/payments/payment-actions.tsx b/App/app/(portal)/payments/payment-actions.tsx index 489be81..7c4209c 100644 --- a/App/app/(portal)/payments/payment-actions.tsx +++ b/App/app/(portal)/payments/payment-actions.tsx @@ -10,6 +10,9 @@ interface Props { poStatus: POStatus; totalAmount?: number; paidAmount?: number; + // Manager's advance decision (issue #92) — absolute amount. Prefills the FIRST + // payment's amount field; ignored once any payment has been recorded. + suggestedAdvancePayment?: number | null; } // Today's date as a local yyyy-mm-dd string (for default + max) @@ -19,15 +22,33 @@ function todayLocal(): string { return new Date(d.getTime() - off * 60_000).toISOString().slice(0, 10); } -export function PaymentActions({ poId, poStatus, totalAmount = 0, paidAmount = 0 }: Props) { +export function PaymentActions({ + poId, + poStatus, + totalAmount = 0, + paidAmount = 0, + suggestedAdvancePayment = null, +}: Props) { const router = useRouter(); + const remaining = totalAmount - paidAmount; + + // Prefill the first payment with the Manager's advance, when it's a genuine + // partial of the (untouched) total. Nothing paid yet ⇒ first payment; a full + // (>= total) advance leaves the field blank so "Confirm Full Payment" is used. + const advancePrefill = + paidAmount === 0 && + suggestedAdvancePayment != null && + suggestedAdvancePayment > 0 && + suggestedAdvancePayment < remaining + ? String(suggestedAdvancePayment) + : ""; + const [ref, setRef] = useState(""); - const [amount, setAmount] = useState(""); + const [amount, setAmount] = useState(advancePrefill); const [paymentDate, setPaymentDate] = useState(todayLocal()); const [pending, setPending] = useState(false); const [error, setError] = useState(""); - const remaining = totalAmount - paidAmount; const today = todayLocal(); async function handleProcessPayment() { @@ -120,6 +141,11 @@ export function PaymentActions({ poId, poStatus, totalAmount = 0, paidAmount = 0 className="w-full sm:w-36 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" />
+ {advancePrefill && ( + + Manager set an advance of {Number(suggestedAdvancePayment).toFixed(2)} — prefilled below; adjust if needed. + + )} {error && {error}}
{isPartialPayment && ( diff --git a/App/components/po/po-detail.tsx b/App/components/po/po-detail.tsx index 8af02fc..1b6ea8c 100644 --- a/App/components/po/po-detail.tsx +++ b/App/components/po/po-detail.tsx @@ -25,6 +25,7 @@ type PoWithRelations = { paymentRef: string | null; paymentDate?: Date | null; paidAmount?: import("@prisma/client").Prisma.Decimal | null; + suggestedAdvancePayment?: import("@prisma/client").Prisma.Decimal | null; piQuotationNo?: string | null; piQuotationDate?: Date | null; requisitionNo?: string | null; @@ -290,6 +291,21 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
)} + {/* Manager's advance-payment decision (issue #92) — a partial advance set + at approval. Shown to Accounts/Manager from approval through payment. */} + {po.suggestedAdvancePayment != null && + Number(po.suggestedAdvancePayment) < Number(po.totalAmount) && + ["MGR_APPROVED", "SENT_FOR_PAYMENT", "PARTIALLY_PAID"].includes(po.status) && ( +
+

Advance payment requested

+

+ Pay {formatCurrency(Number(po.suggestedAdvancePayment), po.currency)} first (of{" "} + {formatCurrency(Number(po.totalAmount), po.currency)}). The balance follows the usual + part-payment flow. +

+
+ )} + {/* Submitter changes banner — shown to managers when PO is resubmitted after edits */} {resubmitSnapshot && po.status === "MGR_REVIEW" && diff --git a/App/lib/validations/po.ts b/App/lib/validations/po.ts index bd57712..7be1042 100644 --- a/App/lib/validations/po.ts +++ b/App/lib/validations/po.ts @@ -53,6 +53,13 @@ export const createPoSchema = z.object({ export const approvePoSchema = z.object({ note: z.string().optional(), + // Absolute advance amount the Manager wants paid first (issue #92). The UI + // slider works in whole percent of totalAmount; the resolved amount is what we + // persist. Validated against the PO total in the action. Omitted ⇒ full payment. + suggestedAdvancePayment: z.coerce + .number() + .nonnegative("Advance payment cannot be negative") + .optional(), }); export const rejectPoSchema = z.object({ diff --git a/App/prisma/migrations/20260624120000_po_suggested_advance_payment/migration.sql b/App/prisma/migrations/20260624120000_po_suggested_advance_payment/migration.sql new file mode 100644 index 0000000..4675582 --- /dev/null +++ b/App/prisma/migrations/20260624120000_po_suggested_advance_payment/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "PurchaseOrder" ADD COLUMN "suggestedAdvancePayment" DECIMAL(12,2); diff --git a/App/prisma/schema.prisma b/App/prisma/schema.prisma index 8016b48..8d58048 100644 --- a/App/prisma/schema.prisma +++ b/App/prisma/schema.prisma @@ -512,6 +512,12 @@ model PurchaseOrder { paymentRef String? paymentDate DateTime? paidAmount Decimal? @db.Decimal(12, 2) + // Advance the approving Manager wants paid first (absolute amount, not %). + // The approval slider (0–100% of totalAmount) is convenience only — the + // resolved amount is stored here. Null on legacy/pre-feature POs ⇒ no explicit + // advance, so Accounts defaults to the full remaining balance. Set once at + // approval and not edited afterwards (issue #92). + suggestedAdvancePayment Decimal? @db.Decimal(12, 2) piQuotationNo String? piQuotationDate DateTime? requisitionNo String? diff --git a/App/tests/integration/approval-actions.test.ts b/App/tests/integration/approval-actions.test.ts index fd040d8..5006743 100644 --- a/App/tests/integration/approval-actions.test.ts +++ b/App/tests/integration/approval-actions.test.ts @@ -119,6 +119,46 @@ describe("M-02 — approve PO", () => { }); }); +// ── #92: Advance payment decided at approval ───────────────────────────────── + +describe("issue #92 — advance payment on approval", () => { + it("persists the manager's advance amount and records it on the audit row", async () => { + const poId = await createSubmittedPo(`${PREFIX}Advance`); + vi.mocked(auth as unknown as () => Promise).mockResolvedValue(makeSession(managerId, "MANAGER")); + const before = await db.purchaseOrder.findUniqueOrThrow({ where: { id: poId } }); + const half = Math.round(Number(before.totalAmount) / 2); + + const result = await approvePo({ poId, suggestedAdvancePayment: half }); + expect(result).toEqual({ ok: true }); + + const po = await db.purchaseOrder.findUniqueOrThrow({ where: { id: poId } }); + expect(po.status).toBe("MGR_APPROVED"); + expect(Number(po.suggestedAdvancePayment)).toBe(half); + + const action = await db.pOAction.findFirst({ where: { poId, actionType: "APPROVED" } }); + expect((action?.metadata as { suggestedAdvancePayment?: number } | null)?.suggestedAdvancePayment).toBe(half); + }); + + it("defaults to null (full payment) when no advance is provided", async () => { + const poId = await createSubmittedPo(`${PREFIX}AdvanceNone`); + vi.mocked(auth as unknown as () => Promise).mockResolvedValue(makeSession(managerId, "MANAGER")); + await approvePo({ poId }); + const po = await db.purchaseOrder.findUniqueOrThrow({ where: { id: poId } }); + expect(po.suggestedAdvancePayment).toBeNull(); + }); + + it("clamps an advance above the PO total down to the total", async () => { + const poId = await createSubmittedPo(`${PREFIX}AdvanceClamp`); + vi.mocked(auth as unknown as () => Promise).mockResolvedValue(makeSession(managerId, "MANAGER")); + const before = await db.purchaseOrder.findUniqueOrThrow({ where: { id: poId } }); + const total = Number(before.totalAmount); + + await approvePo({ poId, suggestedAdvancePayment: total + 5000 }); + const po = await db.purchaseOrder.findUniqueOrThrow({ where: { id: poId } }); + expect(Number(po.suggestedAdvancePayment)).toBe(total); + }); +}); + // ── M-03: Reject ────────────────────────────────────────────────────────────── describe("M-03 — reject PO", () => { From 455d2689252b727bd5ec4f8aae4ac6376fc35e8a Mon Sep 17 00:00:00 2001 From: Hardik Date: Wed, 24 Jun 2026 01:45:56 +0530 Subject: [PATCH 4/5] docs(schema): note suggestedAdvancePayment is the reuse point for issue #91 The structured payment-request lane (#91) should extend this column for the ADVANCE/PART 'exact sum due', not add a parallel field. Co-Authored-By: Claude Opus 4.8 (1M context) --- App/prisma/schema.prisma | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/App/prisma/schema.prisma b/App/prisma/schema.prisma index 8d58048..33a680d 100644 --- a/App/prisma/schema.prisma +++ b/App/prisma/schema.prisma @@ -517,6 +517,11 @@ model PurchaseOrder { // resolved amount is stored here. Null on legacy/pre-feature POs ⇒ no explicit // advance, so Accounts defaults to the full remaining balance. Set once at // approval and not edited afterwards (issue #92). + // + // NOTE (issue #91): this IS the "exact sum due for payment" for an ADVANCE/PART + // request. When the structured payment-request lane (payment-term enum + + // separate approval) is built, reuse this column for the requested amount + // rather than adding a parallel "exact sum" field. suggestedAdvancePayment Decimal? @db.Decimal(12, 2) piQuotationNo String? piQuotationDate DateTime? From 5aae45299bd7df586701c7f11b52304964033764 Mon Sep 17 00:00:00 2001 From: Hardik Date: Wed, 24 Jun 2026 02:08:59 +0530 Subject: [PATCH 5/5] feat(po): admin-managed delivery locations + Place of Delivery dropdown (#19) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the free-text "Place of Delivery" with a dropdown sourced from a new admin-managed Delivery Locations list (each = a Company FK + free-text address). - schema + migration: new DeliveryLocation model (companyId, address, isActive). - permission: manage_delivery_locations granted to Manager + SuperUser + Admin (Manager-accessible, not admin-only, per the issue). - admin screen /admin/delivery-locations: table + Add/Edit dialogs + activate/deactivate + delete (mirrors /admin/sites); sidebar link under Administration for Manager/SuperUser/Admin. - PO forms (new / edit / manager-edit): shared native select populated from active locations, formatted "Company — address". - PurchaseOrder.placeOfDelivery stays a free-text SNAPSHOT (no FK) — the dropdown only changes how the value is picked, so export/import/historical POs are unchanged, and an edit preserves a current value not in the list as a "(current)" option. Deleting a location is therefore always safe. - tests: delivery-location CRUD + permission guard (6). Co-Authored-By: Claude Opus 4.8 (1M context) --- App/CLAUDE.md | 6 + .../admin/delivery-locations/actions.ts | 77 ++++++++++ .../delivery-location-form.tsx | 110 ++++++++++++++ .../delivery-locations-table.tsx | 140 ++++++++++++++++++ .../admin/delivery-locations/page.tsx | 35 +++++ .../approvals/[id]/manager-edit-po-form.tsx | 6 +- App/app/(portal)/approvals/[id]/page.tsx | 6 +- .../(portal)/po/[id]/edit/edit-po-form.tsx | 6 +- App/app/(portal)/po/[id]/edit/page.tsx | 6 +- App/app/(portal)/po/new/new-po-form.tsx | 16 +- App/app/(portal)/po/new/page.tsx | 6 +- App/components/layout/sidebar.tsx | 6 +- App/components/po/delivery-location-field.tsx | 36 +++++ App/lib/delivery-location.ts | 9 ++ App/lib/permissions.ts | 4 + .../migration.sql | 17 +++ App/prisma/schema.prisma | 20 ++- .../integration/delivery-locations.test.ts | 89 +++++++++++ 18 files changed, 578 insertions(+), 17 deletions(-) create mode 100644 App/app/(portal)/admin/delivery-locations/actions.ts create mode 100644 App/app/(portal)/admin/delivery-locations/delivery-location-form.tsx create mode 100644 App/app/(portal)/admin/delivery-locations/delivery-locations-table.tsx create mode 100644 App/app/(portal)/admin/delivery-locations/page.tsx create mode 100644 App/components/po/delivery-location-field.tsx create mode 100644 App/lib/delivery-location.ts create mode 100644 App/prisma/migrations/20260624130000_delivery_locations/migration.sql create mode 100644 App/tests/integration/delivery-locations.test.ts diff --git a/App/CLAUDE.md b/App/CLAUDE.md index ed2a1f5..0e104df 100644 --- a/App/CLAUDE.md +++ b/App/CLAUDE.md @@ -98,6 +98,12 @@ A PO's **cost centre is a Vessel** (the `Vessel` model). `PurchaseOrder.vesselId `Company` represents the sister company a PO is billed under (`PurchaseOrder.companyId`, optional). Fields: `name`, `code` (unique short code, e.g. `PMS`), `gstNumber`, `address`, `telephone`, `mobile`, `email`, `invoiceEmail`, `invoiceAddress`. Managed at `/admin/companies`. The selected company's details populate the **exported PO header / invoice block** (falling back to hardcoded Pelagia defaults when no company is linked). +### Delivery Locations (issue #19) + +`DeliveryLocation` (a `Company` FK + free-text `address` + `isActive`) is an admin-managed list that backs the PO **Place of Delivery** dropdown. Managed at `/admin/delivery-locations`, gated by the **`manage_delivery_locations`** permission (Manager + SuperUser + Admin — explicitly **not** admin-only, per the issue). The CRUD mirrors `/admin/sites` (table + Add/Edit dialogs + activate/deactivate + delete). + +The three PO forms (`new-po-form`, `edit-po-form`, `manager-edit-po-form`) render a shared `` — a native ` + + {companies.map((c) => ( + + ))} + + +
+ +