pelagia-portal/App/app/(portal)/approvals/page.tsx
Hardik aac31c6755 feat(crewing): Phase 4b — leave & attendance (flagged)
Second slice of Phase 4 (stacked on 4a crew records). Leave (site-applied,
Manager-decided) with clash auto-backfill, and the daily attendance calendar,
per Crewing-Implementation-Spec §5.3/§8.9–8.10. Behind NEXT_PUBLIC_CREWING_ENABLED.

What's in
- Schema (crewing_leave_attendance migration): LeaveRequest (LeaveType,
  LeaveStatus) + Attendance (AttendanceStatus, unique per assignment+date) on
  CrewAssignment; CrewActionType += LEAVE_APPLIED/LEAVE_DECIDED/ATTENDANCE_RECORDED.
- Leave (R1): site staff apply on behalf (apply_leave); Manager decides
  (decide_leave) → assignment ON_LEAVE; MPO has no leave role. Leave approvals also
  surface in the central /approvals queue (§8.13 Leave kind). Notification
  LEAVE_FOR_APPROVAL.
- Clash auto-backfill (R6): lib/leave-clash.ts, required strength = 1 — approving a
  leave that leaves the vessel with zero active same-rank cover auto-raises a LEAVE
  requisition via the Phase-2 autoRaiseRequisition.
- Attendance (R5): daily month calendar; site staff record (record_attendance),
  Manager views (view_attendance) but cannot edit, MPO neither. saveAttendance
  bulk-upserts dirty cells.
- Screens: /crewing/leave (apply-on-behalf + Manager Approve/Decline) and
  /crewing/attendance (tap-to-cycle calendar + Save). Leave + Attendance added to
  the flag-gated nav (Manager + Site staff).

Tests & docs
- Integration: leave-attendance.test.ts (7) — apply/decide, clash auto-raise (and
  no-raise when cover remains), MPO/Manager attendance lockout, permission gating.
  type-check clean; full unit (240) + integration (182) green.
- CLAUDE.md updated with the Phase 4b surface.

Deferred: the 6-month leave-planner timeline (lightweight list for now); hours/
overtime attendance (A7).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 21:07:15 +05:30

210 lines
9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { redirect } from "next/navigation";
import Link from "next/link";
import { formatCurrency, formatDate } from "@/lib/utils";
import { ApprovalsSearch } from "./approvals-search";
import { CREWING_ENABLED } from "@/lib/feature-flags";
import { CrewingApprovals, type CrewApprovalItem, type CrewApprovalKind } from "./crewing-approvals";
import { Suspense } from "react";
import type { Metadata } from "next";
export const metadata: Metadata = { title: "Approvals" };
interface Props {
searchParams: Promise<{
q?: string;
vesselId?: string;
dateFrom?: string;
}>;
}
export default async function ApprovalsPage({ searchParams }: Props) {
const session = await auth();
if (!session?.user) redirect("/login");
if (!hasPermission(session.user.role, "approve_po")) redirect("/dashboard");
const { q, vesselId, dateFrom } = await searchParams;
const where: NonNullable<Parameters<typeof db.purchaseOrder.findMany>[0]>["where"] = {
status: "MGR_REVIEW",
};
if (q?.trim()) {
where.OR = [
{ poNumber: { contains: q.trim(), mode: "insensitive" } },
{ submitter: { name: { contains: q.trim(), mode: "insensitive" } } },
{ title: { contains: q.trim(), mode: "insensitive" } },
];
}
if (vesselId) where.vesselId = vesselId;
if (dateFrom) where.submittedAt = { gte: new Date(dateFrom) };
const [pending, vessels] = await Promise.all([
db.purchaseOrder.findMany({
where,
include: { submitter: true, vessel: true, account: true },
orderBy: { submittedAt: "asc" },
}),
db.vessel.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true } }),
]);
// Crewing approvals (spec §8.13 R8) — the same unified Manager queue. Pending
// SALARY / SELECTION / WAIVER gates surface here alongside POs.
const role = session.user.role;
const showCrewing =
CREWING_ENABLED &&
(hasPermission(role, "approve_salary_structure") ||
hasPermission(role, "select_candidate") ||
hasPermission(role, "approve_interview_waiver") ||
hasPermission(role, "decide_leave"));
const crewGates = showCrewing
? await db.applicationGate.findMany({
where: { result: "PENDING", gate: { in: ["SALARY", "SELECTION", "WAIVER"] } },
orderBy: { createdAt: "asc" },
include: {
application: {
include: {
crewMember: { select: { name: true } },
requisition: { select: { code: true, rank: { select: { name: true } } } },
salaryStructures: { where: { approvedById: null }, orderBy: { createdAt: "desc" }, take: 1 },
},
},
},
})
: [];
const crewItems: CrewApprovalItem[] = crewGates.map((g) => {
const sal = g.application.salaryStructures[0];
const detail =
g.gate === "SALARY" && sal
? `${sal.currency} ${Number(sal.basic).toLocaleString("en-IN")} / ${sal.rateBasis.toLowerCase()}`
: g.gate === "WAIVER"
? "Returning crew — interview waiver"
: "Interview cleared";
return {
id: g.applicationId,
kind: g.gate as CrewApprovalKind,
candidateName: g.application.crewMember.name,
rank: g.application.requisition.rank.name,
requisitionCode: g.application.requisition.code,
detail,
link: `/crewing/applications/${g.applicationId}`,
};
});
// Pending leave requests (Manager decides) — the §8.13 "Leave" queue kind.
const leaveItems: CrewApprovalItem[] = (showCrewing && hasPermission(role, "decide_leave"))
? (await db.leaveRequest.findMany({
where: { status: "APPLIED" },
orderBy: { createdAt: "asc" },
include: { assignment: { include: { crewMember: { select: { name: true } }, rank: { select: { name: true } } } } },
})).map((l) => ({
id: l.id,
kind: "LEAVE" as CrewApprovalKind,
candidateName: l.assignment.crewMember.name,
rank: l.assignment.rank.name,
requisitionCode: `${l.fromDate.toLocaleDateString()}${l.toDate.toLocaleDateString()}`,
detail: l.type.toLowerCase(),
link: "/crewing/leave",
}))
: [];
const allCrewItems = [...crewItems, ...leaveItems];
return (
<div>
<div className="mb-4">
<h1 className="text-2xl font-semibold text-neutral-900">Approval Queue</h1>
<p className="mt-1 text-sm text-neutral-500">
{pending.length} order{pending.length !== 1 ? "s" : ""} awaiting your decision
</p>
</div>
<Suspense>
<ApprovalsSearch vessels={vessels} />
</Suspense>
{pending.length === 0 ? (
<div className="rounded-lg border border-neutral-200 bg-white p-12 text-center">
<p className="text-neutral-500">No purchase orders awaiting approval.</p>
</div>
) : (
<>
{/* ── Desktop table ─────────────────────────────────────────── */}
<div className="hidden md:block rounded-lg border border-neutral-200 bg-white overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-neutral-50 border-b border-neutral-200">
<tr>
<th className="px-4 py-3 text-left font-medium text-neutral-600">PO Number</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Title</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Submitter</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Cost Centre</th>
<th className="px-4 py-3 text-right font-medium text-neutral-600">Amount</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Submitted</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-100">
{pending.map((po) => (
<tr key={po.id} className="hover:bg-neutral-50">
<td className="px-4 py-3 font-mono text-xs text-neutral-600">{po.poNumber}</td>
<td className="px-4 py-3 font-medium text-neutral-900 max-w-xs truncate">{po.title}</td>
<td className="px-4 py-3 text-neutral-600">{po.submitter.name}</td>
<td className="px-4 py-3 text-neutral-600">{po.vessel.name}</td>
<td className="px-4 py-3 text-right font-mono text-sm">
{formatCurrency(Number(po.totalAmount), po.currency)}
</td>
<td className="px-4 py-3 text-neutral-500">
{po.submittedAt ? formatDate(po.submittedAt) : "—"}
</td>
<td className="px-4 py-3">
<Link href={`/approvals/${po.id}`} className="text-primary-600 hover:text-primary-700 font-medium">
Review
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* ── Mobile cards ──────────────────────────────────────────── */}
<div className="md:hidden space-y-3">
{pending.map((po) => (
<Link key={po.id} href={`/approvals/${po.id}`} className="block">
<div className="rounded-xl border border-neutral-200 bg-white p-4 shadow-sm active:bg-neutral-50 transition-colors">
<div className="flex items-start justify-between gap-2 mb-1">
<span className="font-mono text-xs text-neutral-500">{po.poNumber}</span>
<span className="text-xs text-neutral-400 shrink-0">
{po.submittedAt ? formatDate(po.submittedAt) : "—"}
</span>
</div>
<p className="font-semibold text-neutral-900 leading-snug mb-2">{po.title}</p>
<div className="flex items-center justify-between text-sm">
<span className="text-neutral-500 truncate max-w-[55%]">
{po.submitter.name} · {po.vessel.name}
</span>
<span className="font-mono font-semibold text-neutral-900 shrink-0">
{formatCurrency(Number(po.totalAmount), po.currency)}
</span>
</div>
<div className="mt-3">
<span className="block w-full rounded-lg bg-primary-600 py-2 text-center text-sm font-semibold text-white">
Review
</span>
</div>
</div>
</Link>
))}
</div>
</>
)}
{showCrewing && allCrewItems.length > 0 && <CrewingApprovals items={allCrewItems} />}
</div>
);
}