Merge branch 'master' into feat/manager-advance-payment
All checks were successful
PR checks / checks (pull_request) Successful in 44s
PR checks / integration (pull_request) Successful in 30s

This commit is contained in:
shad0w 2026-06-23 20:26:09 +00:00
commit 0e0e377718
19 changed files with 237 additions and 88 deletions

View file

@ -62,6 +62,13 @@ FORGEJO_URL=https://git.pelagiamarine.com
FORGEJO_REPO=shad0w/pelagia-portal FORGEJO_REPO=shad0w/pelagia-portal
FORGEJO_TOKEN= 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 ───────────────────────────────────── # ── Non-production banner ─────────────────────────────────────
# When set, a fixed "internal dev / staging" banner is shown (EnvBanner). # When set, a fixed "internal dev / staging" banner is shown (EnvBanner).
# Leave UNSET in production. Staging sets this automatically. # Leave UNSET in production. Staging sets this automatically.

View file

@ -234,6 +234,7 @@ FORGEJO_URL, FORGEJO_REPO, FORGEJO_TOKEN
GST_SERVICE_URL # GstService microservice (defaults to localhost:3003) GST_SERVICE_URL # GstService microservice (defaults to localhost:3003)
EPFO_SERVICE_URL # EpfoService microservice for UAN lookup (defaults to localhost:3004) EPFO_SERVICE_URL # EpfoService microservice for UAN lookup (defaults to localhost:3004)
NEXT_PUBLIC_INVENTORY_ENABLED # Inventory feature flag 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_CREWING_ENABLED # Crewing module feature flag (opt-in "true"; off by default) NEXT_PUBLIC_CREWING_ENABLED # Crewing module feature flag (opt-in "true"; off by default)
NEXT_PUBLIC_ENV_LABEL # When set, shows a non-prod banner (EnvBanner). Leave unset in prod. NEXT_PUBLIC_ENV_LABEL # When set, shows a non-prod banner (EnvBanner). Leave unset in prod.
``` ```

View file

@ -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 STATUSES: CrewStatus[] = ["PROSPECT", "CANDIDATE", "EMPLOYEE", "EX_HAND", "BLACKLISTED"];
const SOURCES: CandidateSource[] = ["CAREERS", "EX_HAND", "WALK_IN", "REFERRAL", "OTHER"]; 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()); const label = (s: string) => s.replace(/_/g, " ").toLowerCase().replace(/\b\w/g, (m) => m.toUpperCase());
type Opt = { id: string; name: string }; type Opt = { id: string; name: string };
@ -132,7 +131,10 @@ function CrewFormButton({ ranks, editing, open, onOpenChange }: { ranks: RankOpt
<input className={INPUT} placeholder="Name" value={f.name} onChange={(e) => setF({ ...f, name: e.target.value })} required /> <input className={INPUT} placeholder="Name" value={f.name} onChange={(e) => setF({ ...f, name: e.target.value })} required />
<select className={INPUT} value={f.status} onChange={(e) => setF({ ...f, status: e.target.value as CrewStatus })}>{STATUSES.map((s) => <option key={s} value={s}>{label(s)}</option>)}</select> <select className={INPUT} value={f.status} onChange={(e) => setF({ ...f, status: e.target.value as CrewStatus })}>{STATUSES.map((s) => <option key={s} value={s}>{label(s)}</option>)}</select>
<select className={INPUT} value={f.source} onChange={(e) => setF({ ...f, source: e.target.value as CandidateSource })}>{SOURCES.map((s) => <option key={s} value={s}>{label(s)}</option>)}</select> <select className={INPUT} value={f.source} onChange={(e) => setF({ ...f, source: e.target.value as CandidateSource })}>{SOURCES.map((s) => <option key={s} value={s}>{label(s)}</option>)}</select>
<select className={INPUT} value={f.type} onChange={(e) => setF({ ...f, type: e.target.value as CandidateType })}>{TYPES.map((s) => <option key={s} value={s}>{label(s)}</option>)}</select> <label className="flex items-center gap-2 px-1 text-sm text-neutral-700">
<input type="checkbox" className="h-4 w-4 rounded border-neutral-300 text-primary-600 focus:ring-primary-500/30" checked={f.type === "EX_HAND"} onChange={(e) => setF({ ...f, type: e.target.checked ? "EX_HAND" : "NEW" })} />
Ex-hand (returning crew)
</label>
<select className={INPUT} value={f.appliedRankId} onChange={(e) => setF({ ...f, appliedRankId: e.target.value })}><option value="">Rank applied</option>{ranks.map((r) => <option key={r.id} value={r.id}>{r.code} {r.name}</option>)}</select> <select className={INPUT} value={f.appliedRankId} onChange={(e) => setF({ ...f, appliedRankId: e.target.value })}><option value="">Rank applied</option>{ranks.map((r) => <option key={r.id} value={r.id}>{r.code} {r.name}</option>)}</select>
<select className={INPUT} value={f.currentRankId} onChange={(e) => setF({ ...f, currentRankId: e.target.value })}><option value="">Rank held</option>{ranks.map((r) => <option key={r.id} value={r.id}>{r.code} {r.name}</option>)}</select> <select className={INPUT} value={f.currentRankId} onChange={(e) => setF({ ...f, currentRankId: e.target.value })}><option value="">Rank held</option>{ranks.map((r) => <option key={r.id} value={r.id}>{r.code} {r.name}</option>)}</select>
<input className={INPUT} placeholder="Email" value={f.email} onChange={(e) => setF({ ...f, email: e.target.value })} /> <input className={INPUT} placeholder="Email" value={f.email} onChange={(e) => setF({ ...f, email: e.target.value })} />

View file

@ -51,13 +51,13 @@ export default async function CandidateDetailPage({ params }: { params: Promise<
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold text-neutral-900">{c.name}</h1> <h1 className="text-2xl font-semibold text-neutral-900">{c.name}</h1>
<Badge variant={STATUS_VARIANT[c.status]}>{STATUS_LABEL[c.status]}</Badge> <Badge variant={STATUS_VARIANT[c.status]}>{STATUS_LABEL[c.status]}</Badge>
{c.source === "EX_HAND" && ( {c.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> <span className="rounded-full bg-purple-100 text-purple-700 px-2.5 py-0.5 text-xs font-medium">Returning crew</span>
)} )}
</div> </div>
</div> </div>
{c.source === "EX_HAND" && ( {c.type === "EX_HAND" && (
<div className="mb-6 rounded-lg border border-purple-200 bg-purple-50 px-4 py-3 text-sm text-purple-800"> <div className="mb-6 rounded-lg border border-purple-200 bg-purple-50 px-4 py-3 text-sm text-purple-800">
<strong>Returning crew.</strong> The interview may be waived with Manager approval.{" "} <strong>Returning crew.</strong> The interview may be waived with Manager approval.{" "}
{c.experienceRecords.length === 0 && c.documents.length === 0 ? ( {c.experienceRecords.length === 0 && c.documents.length === 0 ? (

View file

@ -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). // Store an optional CV upload and return its storage key (null if none).
async function storeCv(formData: FormData, crewMemberId: string): Promise<string | null> { async function storeCv(formData: FormData, crewMemberId: string): Promise<string | null> {
const file = formData.get("cv"); const file = formData.get("cv");
@ -74,53 +67,53 @@ export async function addCandidate(formData: FormData): Promise<ActionResult> {
const parsed = parse(formData); const parsed = parse(formData);
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" }; if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
const d = parsed.data; const d = parsed.data;
const { type, status } = derive(d.source);
// B3 AC1 — ex-hand recognition: a returning person re-entered as a fresh // 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 // candidate is matched to their existing EX_HAND pool record by a stable key —
// pool record by a stable key — email when given, else an exact name match — // email when given, else an exact name match — and the SAME row is reused (so
// and the SAME row is reused (so their tour history, documents and bank stay on // their tour history, documents and bank stay on file) rather than creating a
// file) rather than creating a duplicate. (Heuristic: with no DOB on file 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.) // name-only match can in theory collide; email is preferred when available.)
if (d.source !== "EX_HAND") { const match = await db.crewMember.findFirst({
const match = await db.crewMember.findFirst({ where: {
where: { status: "EX_HAND",
status: "EX_HAND", ...(d.email
...(d.email ? { email: { equals: d.email, mode: "insensitive" } }
? { email: { equals: d.email, mode: "insensitive" } } : { name: { equals: d.name, 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 cvKey = await storeCv(formData, updated.id);
const updated = await db.crewMember.update({ if (cvKey) await db.crewMember.update({ where: { id: updated.id }, data: { cvKey } });
where: { id: match.id }, revalidatePath(LIST_PATH);
data: { return { ok: true, id: updated.id };
// 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 candidate = await db.crewMember.create({ const candidate = await db.crewMember.create({
data: { data: {
name: d.name, name: d.name,
source: d.source, source: d.source,
type, // The candidate form always intakes a fresh NEW candidate. Ex-hand status
status, // is an office/admin designation set on the crew record, not here.
type: "NEW",
status: "CANDIDATE",
appliedRankId: d.appliedRankId || null, appliedRankId: d.appliedRankId || null,
currentRankId: d.currentRankId || null, currentRankId: d.currentRankId || null,
experienceMonths: d.experienceMonths, experienceMonths: d.experienceMonths,
@ -149,7 +142,6 @@ export async function updateCandidate(formData: FormData): Promise<ActionResult>
const parsed = parse(formData); const parsed = parse(formData);
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" }; if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
const d = parsed.data; const d = parsed.data;
const { type, status } = derive(d.source);
const existing = await db.crewMember.findUnique({ where: { id }, select: { status: true } }); const existing = await db.crewMember.findUnique({ where: { id }, select: { status: true } });
if (!existing) return { error: "Candidate not found" }; if (!existing) return { error: "Candidate not found" };
@ -161,9 +153,8 @@ export async function updateCandidate(formData: FormData): Promise<ActionResult>
data: { data: {
name: d.name, name: d.name,
source: d.source, source: d.source,
// Don't downgrade an onboarded employee back to a candidate via an edit. // type/status are left untouched — ex-hand / employee designation is owned
type, // by the office (admin crew record + sign-off), never by a candidate edit.
status: existing.status === "EMPLOYEE" ? existing.status : status,
appliedRankId: d.appliedRankId || null, appliedRankId: d.appliedRankId || null,
currentRankId: d.currentRankId || null, currentRankId: d.currentRankId || null,
experienceMonths: d.experienceMonths, experienceMonths: d.experienceMonths,

View file

@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
import type { CandidateSource } from "@prisma/client"; import type { CandidateSource } from "@prisma/client";
import { AdminDialog } from "@/components/ui/admin-dialog"; import { AdminDialog } from "@/components/ui/admin-dialog";
import { addCandidate, updateCandidate } from "./actions"; import { addCandidate, updateCandidate } from "./actions";
import { SOURCE_OPTIONS, SOURCE_LABEL } from "./candidate-ui"; import { FORM_SOURCE_OPTIONS, SOURCE_LABEL } from "./candidate-ui";
const INPUT = 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"; "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({
<div> <div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Source</label> <label className="block text-xs font-medium text-neutral-700 mb-1">Source</label>
<select className={INPUT} value={state.source} onChange={(e) => set("source", e.target.value as CandidateSource)}> <select className={INPUT} value={state.source} onChange={(e) => set("source", e.target.value as CandidateSource)}>
{SOURCE_OPTIONS.map((s) => ( {FORM_SOURCE_OPTIONS.map((s) => (
<option key={s} value={s}>{SOURCE_LABEL[s]}</option> <option key={s} value={s}>{SOURCE_LABEL[s]}</option>
))} ))}
</select> </select>
@ -64,7 +64,7 @@ function CandidateFields({
</select> </select>
</div> </div>
<div> <div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Rank held (ex-hands)</label> <label className="block text-xs font-medium text-neutral-700 mb-1">Rank held</label>
<select className={INPUT} value={state.currentRankId} onChange={(e) => set("currentRankId", e.target.value)}> <select className={INPUT} value={state.currentRankId} onChange={(e) => set("currentRankId", e.target.value)}>
<option value=""></option> <option value=""></option>
{ranks.map((r) => ( {ranks.map((r) => (
@ -131,7 +131,9 @@ function emptyState(): FieldState {
function stateFrom(c: EditableCandidate): FieldState { function stateFrom(c: EditableCandidate): FieldState {
return { return {
name: c.name, name: c.name,
source: c.source, // Ex-hand is an admin-only designation; the candidate form only edits origin.
// Legacy rows may carry the EX_HAND source — show a sensible origin instead.
source: c.source === "EX_HAND" ? "CAREERS" : c.source,
appliedRankId: c.appliedRankId ?? "", appliedRankId: c.appliedRankId ?? "",
currentRankId: c.currentRankId ?? "", currentRankId: c.currentRankId ?? "",
experienceMonths: String(c.experienceMonths), experienceMonths: String(c.experienceMonths),

View file

@ -13,6 +13,11 @@ export const SOURCE_LABEL: Record<CandidateSource, string> = {
export const SOURCE_OPTIONS: CandidateSource[] = ["CAREERS", "EX_HAND", "WALK_IN", "REFERRAL", "OTHER"]; export const SOURCE_OPTIONS: CandidateSource[] = ["CAREERS", "EX_HAND", "WALK_IN", "REFERRAL", "OTHER"];
// Ex-hand is now its own checkbox (not a source) — the Add/Edit form offers only
// the real origins. EX_HAND stays in the enum/label for legacy rows created
// before the split.
export const FORM_SOURCE_OPTIONS: CandidateSource[] = ["CAREERS", "WALK_IN", "REFERRAL", "OTHER"];
export const STATUS_LABEL: Record<CrewStatus, string> = { export const STATUS_LABEL: Record<CrewStatus, string> = {
PROSPECT: "Prospect", PROSPECT: "Prospect",
CANDIDATE: "Candidate", CANDIDATE: "Candidate",

View file

@ -2,7 +2,7 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import type { CandidateSource, CrewStatus } from "@prisma/client"; import type { CandidateSource, CandidateType, CrewStatus } from "@prisma/client";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { RowActionsMenu, RowActionsItem } from "@/components/ui/row-actions-menu"; import { RowActionsMenu, RowActionsItem } from "@/components/ui/row-actions-menu";
import { AddCandidateButton, EditCandidateButton, type EditableCandidate } from "./candidate-form"; import { AddCandidateButton, EditCandidateButton, type EditableCandidate } from "./candidate-form";
@ -12,6 +12,7 @@ type CandidateRow = {
id: string; id: string;
name: string; name: string;
source: CandidateSource; source: CandidateSource;
type: CandidateType;
status: CrewStatus; status: CrewStatus;
appliedRankId: string | null; appliedRankId: string | null;
appliedRank: string | null; appliedRank: string | null;
@ -54,13 +55,12 @@ function CandidateRowView({ c, ranks }: { c: CandidateRow; ranks: RankOpt[] }) {
<tr className="border-b border-neutral-100 last:border-0 hover:bg-neutral-50"> <tr className="border-b border-neutral-100 last:border-0 hover:bg-neutral-50">
<td className="px-4 py-3"> <td className="px-4 py-3">
<Link href={`/crewing/candidates/${c.id}`} className="font-medium text-neutral-900 hover:text-primary-700">{c.name}</Link> <Link href={`/crewing/candidates/${c.id}`} className="font-medium text-neutral-900 hover:text-primary-700">{c.name}</Link>
{c.type === "EX_HAND" && (
<span className="ml-2 rounded-full bg-purple-100 text-purple-700 px-2 py-0.5 text-[10px] font-medium align-middle">Ex-hand</span>
)}
{c.hasCv && <span className="ml-2 text-xs text-neutral-400">CV</span>} {c.hasCv && <span className="ml-2 text-xs text-neutral-400">CV</span>}
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3 text-neutral-600 text-sm">{SOURCE_LABEL[c.source]}</td>
<span className={c.source === "EX_HAND" ? "text-purple-700 font-medium text-sm" : "text-neutral-600 text-sm"}>
{SOURCE_LABEL[c.source]}
</span>
</td>
<td className="px-4 py-3 text-neutral-600 text-sm">{c.currentRank ?? "—"}</td> <td className="px-4 py-3 text-neutral-600 text-sm">{c.currentRank ?? "—"}</td>
<td className="px-4 py-3 text-neutral-600 text-sm">{c.appliedRank ?? "—"}</td> <td className="px-4 py-3 text-neutral-600 text-sm">{c.appliedRank ?? "—"}</td>
<td className="px-4 py-3 text-neutral-600 text-sm">{experienceLabel(c.experienceMonths)}</td> <td className="px-4 py-3 text-neutral-600 text-sm">{experienceLabel(c.experienceMonths)}</td>

View file

@ -33,6 +33,7 @@ export default async function CandidatesPage() {
id: c.id, id: c.id,
name: c.name, name: c.name,
source: c.source, source: c.source,
type: c.type,
status: c.status, status: c.status,
appliedRankId: c.appliedRankId, appliedRankId: c.appliedRankId,
appliedRank: c.appliedRank?.name ?? null, appliedRank: c.appliedRank?.name ?? null,

View file

@ -304,10 +304,13 @@ export async function signOffCrew(assignmentId: string, signOffDate: string, rem
source: "internal", source: "internal",
}, },
}); });
// Same entity: flip EMPLOYEE → EX_HAND; they reappear in Candidates as a returning hand. // Same entity: flip EMPLOYEE → EX_HAND; they reappear in Candidates as a
// returning hand. The ex-hand flag lives on type/status — their original
// source (how they were first recruited) is preserved. currentRank (rank
// held) is refreshed to the tour they just signed off from.
await tx.crewMember.update({ await tx.crewMember.update({
where: { id: assignment.crewMemberId }, where: { id: assignment.crewMemberId },
data: { status: "EX_HAND", type: "EX_HAND", source: "EX_HAND", currentRankId: assignment.rankId }, data: { status: "EX_HAND", type: "EX_HAND", currentRankId: assignment.rankId },
}); });
await tx.crewAction.create({ await tx.crewAction.create({
data: { actionType: "CREW_SIGNED_OFF", actorId: g.userId, crewMemberId: assignment.crewMemberId, note: remarks?.trim() || null }, data: { actionType: "CREW_SIGNED_OFF", actorId: g.userId, crewMemberId: assignment.crewMemberId, note: remarks?.trim() || null },

View file

@ -1,6 +1,6 @@
import { auth } from "@/auth"; import { auth } from "@/auth";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions"; import { hasPermission, submitterCanViewAll } from "@/lib/permissions";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { formatCurrency, formatDate } from "@/lib/utils"; import { formatCurrency, formatDate } from "@/lib/utils";
@ -27,7 +27,14 @@ export default async function HistoryPage({ searchParams }: Props) {
const session = await auth(); const session = await auth();
if (!session?.user) redirect("/login"); 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; const { dateFrom, dateTo, approvedFrom, approvedTo, vesselId, status } = await searchParams;

View file

@ -2,6 +2,7 @@ import { auth } from "@/auth";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { notFound, redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { PoDetail } from "@/components/po/po-detail"; import { PoDetail } from "@/components/po/po-detail";
import { canViewAllPos } from "@/lib/permissions";
import { VendorIdForm } from "./vendor-id-form"; import { VendorIdForm } from "./vendor-id-form";
import type { Metadata } from "next"; import type { Metadata } from "next";
@ -39,11 +40,11 @@ export default async function PoDetailPage({ params }: Props) {
if (!po) notFound(); if (!po) notFound();
// Submitters can only view their own POs (unless they have view_all_pos) // Submitters can only view their own POs — unless they hold view_all_pos, or the
const canViewAll = ["ACCOUNTS", "MANAGER", "SUPERUSER", "AUDITOR", "ADMIN"].includes( // submitter-view-all feature flag grants them read access to every PO.
session.user.role if (!canViewAllPos(session.user.role) && po.submitterId !== session.user.id) {
); redirect("/dashboard");
if (!canViewAll && po.submitterId !== session.user.id) redirect("/dashboard"); }
const canProvideVendorId = const canProvideVendorId =
po.status === "VENDOR_ID_PENDING" && po.status === "VENDOR_ID_PENDING" &&

View file

@ -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 { CANCELLED_WATERMARK_PNG_BASE64, CANCELLED_WATERMARK_W, CANCELLED_WATERMARK_H } from "@/lib/cancelled-watermark";
import { getImageSize, scaleToBox } from "@/lib/image-size"; import { getImageSize, scaleToBox } from "@/lib/image-size";
import { signatoryLayout } from "@/lib/po-export-layout"; import { signatoryLayout } from "@/lib/po-export-layout";
import { canViewAllPos } from "@/lib/permissions";
// ── Company fallback constants (used when no company is linked to a PO) ────── // ── 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 }); if (!po) return NextResponse.json({ error: "Not found" }, { status: 404 });
const canViewAll = ["ACCOUNTS", "MANAGER", "SUPERUSER", "AUDITOR", "ADMIN"].includes(session.user.role); // view_all_pos holders, or submitters when the view-all feature flag is on, may export
if (!canViewAll && po.submitterId !== session.user.id) { // any PO; everyone else only their own.
if (!canViewAllPos(session.user.role) && po.submitterId !== session.user.id) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 }); return NextResponse.json({ error: "Forbidden" }, { status: 403 });
} }

View file

@ -1,6 +1,6 @@
import { auth } from "@/auth"; import { auth } from "@/auth";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions"; import { hasPermission, submitterCanViewAll } from "@/lib/permissions";
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import type { POStatus } from "@prisma/client"; import type { POStatus } from "@prisma/client";
@ -16,7 +16,10 @@ export async function GET(request: NextRequest) {
if (!session?.user) { if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); 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 }); return NextResponse.json({ error: "Forbidden" }, { status: 403 });
} }

View file

@ -3,7 +3,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { INVENTORY_ENABLED, CREWING_ENABLED } from "@/lib/feature-flags"; import { INVENTORY_ENABLED, SUBMITTER_VIEW_ALL_ENABLED, CREWING_ENABLED } from "@/lib/feature-flags";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
LayoutDashboard, LayoutDashboard,
@ -45,6 +45,13 @@ interface NavItem {
roles?: Role[]; 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[] = [ const NAV_ITEMS: NavItem[] = [
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard }, { href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
{ href: "/po/new", label: "New PO", icon: Plus, roles: ["TECHNICAL", "MANNING", "MANAGER", "SUPERUSER"] }, { href: "/po/new", label: "New PO", icon: Plus, roles: ["TECHNICAL", "MANNING", "MANAGER", "SUPERUSER"] },
@ -53,7 +60,7 @@ const NAV_ITEMS: NavItem[] = [
{ href: "/approvals", label: "Approvals", icon: CheckSquare, roles: ["MANAGER", "SUPERUSER"] }, { href: "/approvals", label: "Approvals", icon: CheckSquare, roles: ["MANAGER", "SUPERUSER"] },
{ href: "/payments", label: "Payments", icon: CreditCard, roles: ["ACCOUNTS"] }, { href: "/payments", label: "Payments", icon: CreditCard, roles: ["ACCOUNTS"] },
{ href: "/payments/history", label: "Payment History", icon: Receipt, roles: ["ACCOUNTS", "SUPERUSER"] }, { 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 }, { href: "/profile", label: "My Profile", icon: UserCircle },
]; ];

View file

@ -5,6 +5,12 @@
* NEXT_PUBLIC_INVENTORY_ENABLED=false hides inventory tracking (site qty/consumption) * NEXT_PUBLIC_INVENTORY_ENABLED=false hides inventory tracking (site qty/consumption)
* Vendor list, product catalogue, and cart remain available for PO creation regardless. * 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).
*
* NEXT_PUBLIC_CREWING_ENABLED=true exposes the Crewing module (crew/ranks/requisitions * NEXT_PUBLIC_CREWING_ENABLED=true exposes the Crewing module (crew/ranks/requisitions
* etc.). Opt-in (off unless explicitly "true") because the feature is built incrementally; * etc.). Opt-in (off unless explicitly "true") because the feature is built incrementally;
* keeping it dark by default leaves production unchanged. See lib/permissions.ts (§6 matrix) * keeping it dark by default leaves production unchanged. See lib/permissions.ts (§6 matrix)
@ -14,5 +20,8 @@
export const INVENTORY_ENABLED = export const INVENTORY_ENABLED =
process.env.NEXT_PUBLIC_INVENTORY_ENABLED !== "false"; process.env.NEXT_PUBLIC_INVENTORY_ENABLED !== "false";
export const SUBMITTER_VIEW_ALL_ENABLED =
process.env.NEXT_PUBLIC_SUBMITTER_VIEW_ALL_ENABLED === "true";
export const CREWING_ENABLED = export const CREWING_ENABLED =
process.env.NEXT_PUBLIC_CREWING_ENABLED === "true"; process.env.NEXT_PUBLIC_CREWING_ENABLED === "true";

View file

@ -1,4 +1,5 @@
import type { Role } from "@prisma/client"; import type { Role } from "@prisma/client";
import { SUBMITTER_VIEW_ALL_ENABLED } from "./feature-flags";
export type Permission = export type Permission =
| "create_po" | "create_po"
@ -237,3 +238,31 @@ export function requirePermission(role: Role, permission: Permission): void {
export function getPermissions(role: Role): Permission[] { export function getPermissions(role: Role): Permission[] {
return ROLE_PERMISSIONS[role] ?? []; 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);
}

View file

@ -33,6 +33,21 @@ const SS_EMAIL = "sitestaff@itcand.local";
const as = (userId: string, role: Role) => const as = (userId: string, role: Role) =>
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(userId, role)); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(userId, role));
// Ex-hand is an office/admin designation (set on the admin crew record, not the
// candidate form) — seed such rows directly for the recognition tests.
const seedExHand = (data: { name: string; email?: string; experienceMonths?: number }) =>
db.crewMember.create({
data: {
name: data.name,
type: "EX_HAND",
status: "EX_HAND",
source: "CAREERS",
email: data.email ?? null,
experienceMonths: data.experienceMonths ?? 0,
actions: { create: { actionType: "CANDIDATE_ADDED", actorId: managerId } },
},
});
beforeAll(async () => { beforeAll(async () => {
managerId = (await getSeedUser("manager@pelagia.local")).id; managerId = (await getSeedUser("manager@pelagia.local")).id;
const ss = await db.user.upsert({ const ss = await db.user.upsert({
@ -71,12 +86,14 @@ describe("addCandidate", () => {
expect(c.actions[0].actorId).toBe(managerId); expect(c.actions[0].actorId).toBe(managerId);
}); });
it("an EX_HAND source yields type EX_HAND and status EX_HAND", async () => { it("candidate intake always creates a NEW candidate — ex-hand is admin-only", async () => {
as(managerId, "MANAGER"); as(managerId, "MANAGER");
await addCandidate(fd({ name: "Returning Ravi", source: "EX_HAND" })); // Even if an ex-hand hint is smuggled into the form data, intake stays
// NEW/CANDIDATE; ex-hand is set only on the admin crew record.
await addCandidate(fd({ name: "Returning Ravi", source: "CAREERS", isExHand: "true" }));
const c = await db.crewMember.findFirstOrThrow(); const c = await db.crewMember.findFirstOrThrow();
expect(c.type).toBe("EX_HAND"); expect(c.type).toBe("NEW");
expect(c.status).toBe("EX_HAND"); expect(c.status).toBe("CANDIDATE");
}); });
it("requires a name", async () => { it("requires a name", async () => {
@ -98,8 +115,7 @@ describe("addCandidate", () => {
describe("ex-hand recognition + ordering (B3)", () => { describe("ex-hand recognition + ordering (B3)", () => {
it("recognizes a returning hand by email and reuses the same row (AC1)", async () => { it("recognizes a returning hand by email and reuses the same row (AC1)", async () => {
as(managerId, "MANAGER"); as(managerId, "MANAGER");
await addCandidate(fd({ name: "Ravi Old", source: "EX_HAND", email: "ravi@ex.com", experienceMonths: "120" })); const exhand = await seedExHand({ name: "Ravi Old", email: "ravi@ex.com", experienceMonths: 120 });
const exhand = await db.crewMember.findFirstOrThrow({ where: { status: "EX_HAND" } });
// Re-applies as a fresh careers candidate with the same email → recognized. // Re-applies as a fresh careers candidate with the same email → recognized.
const res = await addCandidate(fd({ name: "Ravi Returning", source: "CAREERS", email: "ravi@ex.com", appliedRankId: rankId })); const res = await addCandidate(fd({ name: "Ravi Returning", source: "CAREERS", email: "ravi@ex.com", appliedRankId: rankId }));
@ -115,16 +131,15 @@ describe("ex-hand recognition + ordering (B3)", () => {
it("recognizes a returning hand by exact name when no email is given (AC1)", async () => { it("recognizes a returning hand by exact name when no email is given (AC1)", async () => {
as(managerId, "MANAGER"); as(managerId, "MANAGER");
await addCandidate(fd({ name: "Returning Ravi", source: "EX_HAND" })); const exhand = await seedExHand({ name: "Returning Ravi" });
const res = await addCandidate(fd({ name: "returning ravi", source: "REFERRAL" })); // case-insensitive const res = await addCandidate(fd({ name: "returning ravi", source: "REFERRAL" })); // case-insensitive
const exhand = await db.crewMember.findFirstOrThrow({ where: { status: "EX_HAND" } });
expect("ok" in res && res.id).toBe(exhand.id); expect("ok" in res && res.id).toBe(exhand.id);
expect(await db.crewMember.count()).toBe(1); expect(await db.crewMember.count()).toBe(1);
}); });
it("does not match a different person → creates a new candidate", async () => { it("does not match a different person → creates a new candidate", async () => {
as(managerId, "MANAGER"); as(managerId, "MANAGER");
await addCandidate(fd({ name: "Ex One", source: "EX_HAND", email: "one@ex.com" })); await seedExHand({ name: "Ex One", email: "one@ex.com" });
await addCandidate(fd({ name: "Brand New", source: "CAREERS", email: "new@ex.com" })); await addCandidate(fd({ name: "Brand New", source: "CAREERS", email: "new@ex.com" }));
expect(await db.crewMember.count()).toBe(2); expect(await db.crewMember.count()).toBe(2);
}); });
@ -132,7 +147,7 @@ describe("ex-hand recognition + ordering (B3)", () => {
it("lists ex-hands above new candidates by default (AC2)", async () => { it("lists ex-hands above new candidates by default (AC2)", async () => {
as(managerId, "MANAGER"); as(managerId, "MANAGER");
await addCandidate(fd({ name: "New First", source: "CAREERS" })); await addCandidate(fd({ name: "New First", source: "CAREERS" }));
await addCandidate(fd({ name: "Ex Second", source: "EX_HAND" })); await seedExHand({ name: "Ex Second" });
const el = (await CandidatesPage()) as unknown as { props: { candidates: Array<{ name: string; status: string }> } }; const el = (await CandidatesPage()) as unknown as { props: { candidates: Array<{ name: string; status: string }> } };
expect(el.props.candidates[0].status).toBe("EX_HAND"); expect(el.props.candidates[0].status).toBe("EX_HAND");
expect(el.props.candidates[0].name).toBe("Ex Second"); expect(el.props.candidates[0].name).toBe("Ex Second");

View file

@ -1,5 +1,11 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect, vi, afterEach } from "vitest";
import { hasPermission, requirePermission } from "@/lib/permissions"; import {
hasPermission,
requirePermission,
isSubmitterRole,
submitterCanViewAll,
canViewAllPos,
} from "@/lib/permissions";
describe("Permissions", () => { describe("Permissions", () => {
describe("hasPermission", () => { 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", () => { describe("requirePermission", () => {
it("does not throw when permission is granted", () => { it("does not throw when permission is granted", () => {
expect(() => requirePermission("MANAGER", "approve_po")).not.toThrow(); expect(() => requirePermission("MANAGER", "approve_po")).not.toThrow();