pelagia-portal/App/components/layout/sidebar.tsx
Hardik be6db075dc
All checks were successful
PR checks / checks (pull_request) Successful in 36s
PR checks / integration (pull_request) Successful in 27s
feat(crewing): Phase 3a — candidates / talent pool (flagged)
First slice of Phase 3 (Epics B/C/D shipped as stacked sub-PRs). Adds the
CrewMember talent-pool spine and the Candidates screens. Behind
NEXT_PUBLIC_CREWING_ENABLED; production unchanged. Stacks on the requisitions
branch (Phase 2).

What's in
- Schema (crewing_candidates migration): CrewMember (spine) + CrewStatus,
  CandidateType, CandidateSource enums; CrewAction gains a nullable crewMemberId;
  CrewActionType += CANDIDATE_ADDED/UPDATED. employeeId is assigned at onboarding
  (3c), so it's nullable here.
- Actions (crewing/candidates/actions.ts): addCandidate / updateCandidate —
  guard flag + manage_candidates, write a CrewAction, optional CV upload via
  buildStorageKey("cv", …) + uploadBuffer (no parsing — A2 deferred). EX_HAND
  source ⇒ type/status EX_HAND; edits never downgrade an EMPLOYEE.
- Screens: /crewing/candidates (master list with search/source/rank-applied/
  min-experience filters as removable chips + match count + Clear all; Add-candidate
  modal) and /crewing/candidates/[id] (profile; pipeline stepper is 3b). Candidates
  added to the flag-gated Crewing nav (Manager + MPO).

Tests & docs
- Integration: candidates.test.ts (7) — add/update, ex-hand derivation, employee
  no-downgrade, permission gating. type-check clean; full unit (225) + integration
  (153) suites green.
- CLAUDE.md "Crewing" section updated with the Phase 3a surface.

Deferred: public careers intake API (A2, §13 open question); CV parsing.

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

197 lines
8.3 KiB
TypeScript

"use client";
import { usePathname } from "next/navigation";
import Link from "next/link";
import { INVENTORY_ENABLED, CREWING_ENABLED } from "@/lib/feature-flags";
import { cn } from "@/lib/utils";
import {
LayoutDashboard,
FileText,
Plus,
CheckSquare,
CreditCard,
History,
Receipt,
Users,
Ship,
Building2,
Briefcase,
Store,
Anchor,
Package,
Upload,
MapPin,
ShoppingCart,
UserCircle,
ShieldCheck,
Network,
ClipboardList,
UserSearch,
} from "lucide-react";
import type { Role } from "@prisma/client";
interface NavItem {
href: string;
label: string;
icon: React.ElementType;
roles?: Role[];
}
const NAV_ITEMS: NavItem[] = [
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
{ href: "/po/new", label: "New PO", icon: Plus, roles: ["TECHNICAL", "MANNING", "MANAGER", "SUPERUSER"] },
{ href: "/my-orders", label: "Closed Purchase Orders", icon: FileText, roles: ["TECHNICAL", "MANNING", "MANAGER", "SUPERUSER"] },
{ href: "/po/import", label: "Import PO", icon: Upload, roles: ["MANAGER", "SUPERUSER"] },
{ 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: "/profile", label: "My Profile", icon: UserCircle },
];
// ── Purchasing section ────────────────────────────────────────────────────────
// Staff browsing items (product catalogue + cart for PO creation)
const PURCHASING_STAFF: NavItem[] = [
{ href: "/inventory/items", label: "Items", icon: Package, roles: ["TECHNICAL", "MANNING", "SUPERUSER"] },
{ href: "/inventory/vendors", label: "Vendors", icon: Store, roles: ["TECHNICAL", "MANNING", "SUPERUSER"] },
{ href: "/inventory/cart", label: "Cart", icon: ShoppingCart, roles: ["TECHNICAL", "MANNING", "SUPERUSER", "MANAGER"] },
];
// Manager catalogue management — Sites conditionally shown
// Admin does not use Purchasing; their links live under Administration
const PURCHASING_MGMT: NavItem[] = [
{ href: "/inventory/vendors", label: "Vendors", icon: Store, roles: ["MANAGER"] },
{ href: "/inventory/items", label: "Items", icon: Package, roles: ["MANAGER"] },
{ href: "/admin/vessels", label: "Cost Centres", icon: Ship, roles: ["MANAGER"] },
...(INVENTORY_ENABLED
? [{ href: "/admin/sites", label: "Sites", icon: MapPin, roles: ["MANAGER"] as Role[] }]
: []),
];
const PURCHASING_ITEMS: NavItem[] = [...PURCHASING_STAFF, ...PURCHASING_MGMT];
// ── Crewing section (feature-flagged) ─────────────────────────────────────────
// Gated by CREWING_ENABLED. Phase 2 adds Requisitions (Manager + MPO, per
// Crewing-Implementation-Spec §7); later phases append Candidates / Crew / Leave
// / Attendance / Verification with their per-role visibility. "Ranks & documents"
// lives under Administration.
const CREWING_ITEMS: NavItem[] = CREWING_ENABLED
? [
{ href: "/crewing/requisitions", label: "Requisitions", icon: ClipboardList, roles: ["MANNING", "MANAGER", "SUPERUSER"] },
{ href: "/crewing/candidates", label: "Candidates", icon: UserSearch, roles: ["MANNING", "MANAGER", "SUPERUSER"] },
]
: [];
// ── Administration section ────────────────────────────────────────────────────
// Vendors shown to MANAGER / ACCOUNTS under their own Administration header
const MANAGER_ADMIN_ITEMS: NavItem[] = [
{ href: "/admin/vendors", label: "Vendors", icon: Store, roles: ["MANAGER", "ACCOUNTS", "ADMIN"] },
{ href: "/admin/products", label: "Items", icon: Package, roles: ["MANAGER", "ADMIN"] },
// Crewing reference data — gated by the crewing flag; held by manage_ranks (MGR/ADMIN).
...(CREWING_ENABLED
? [{ href: "/admin/ranks", label: "Ranks & documents", icon: Network, roles: ["MANAGER", "ADMIN"] as Role[] }]
: []),
];
// Full Administration section (ADMIN only)
const ADMIN_ITEMS: NavItem[] = [
{ href: "/admin/vessels", label: "Cost Centres", icon: Ship },
{ href: "/admin/sites", label: "Sites", icon: MapPin },
{ href: "/admin/users", label: "Users", icon: Users },
{ href: "/admin/superuser-requests", label: "SuperUser Requests",icon: ShieldCheck },
{ href: "/admin/accounts", label: "Accounting Codes", icon: Building2 },
{ href: "/admin/companies", label: "Companies", icon: Briefcase },
];
export function Sidebar({ userRole }: { userRole: Role }) {
const pathname = usePathname();
const isAdmin = userRole === "ADMIN";
const visibleMain = NAV_ITEMS.filter((i) => !i.roles || i.roles.includes(userRole));
const visiblePurchasing = PURCHASING_ITEMS.filter((i) => !i.roles || i.roles.includes(userRole));
const visibleCrewing = CREWING_ITEMS.filter((i) => !i.roles || i.roles.includes(userRole));
const visibleMgrAdmin = MANAGER_ADMIN_ITEMS.filter((i) => !i.roles || i.roles.includes(userRole));
return (
<aside className="flex h-screen w-60 shrink-0 flex-col border-r border-neutral-200 bg-white">
<div className="flex h-16 items-center gap-2.5 border-b border-neutral-200 px-4">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary-600">
<Anchor className="h-4 w-4 text-white" />
</div>
<span className="text-sm font-semibold text-neutral-900">PPMS</span>
</div>
<nav className="flex-1 overflow-y-auto px-3 py-4 space-y-0.5">
{visibleMain.map((item) => (
<NavLink key={item.href} item={item} pathname={pathname} />
))}
{visiblePurchasing.length > 0 && (
<>
<SectionHeader label="Purchasing" />
{visiblePurchasing.map((item) => (
<NavLink key={item.href} item={item} pathname={pathname} />
))}
</>
)}
{/* Crewing — only renders once the flag is on and items exist (later phases) */}
{visibleCrewing.length > 0 && (
<>
<SectionHeader label="Crewing" />
{visibleCrewing.map((item) => (
<NavLink key={item.href} item={item} pathname={pathname} />
))}
</>
)}
{/* Vendors under Administration for MANAGER / ACCOUNTS */}
{!isAdmin && visibleMgrAdmin.length > 0 && (
<>
<SectionHeader label="Administration" />
{visibleMgrAdmin.map((item) => (
<NavLink key={item.href} item={item} pathname={pathname} />
))}
</>
)}
{/* Full Administration section for ADMIN */}
{isAdmin && (
<>
<SectionHeader label="Administration" />
{[...MANAGER_ADMIN_ITEMS, ...ADMIN_ITEMS].map((item) => (
<NavLink key={item.href} item={item} pathname={pathname} />
))}
</>
)}
</nav>
</aside>
);
}
function SectionHeader({ label }: { label: string }) {
return (
<div className="pt-4 pb-1 px-3">
<p className="text-xs font-semibold text-neutral-400 uppercase tracking-wider">{label}</p>
</div>
);
}
function NavLink({ item, pathname }: { item: NavItem; pathname: string }) {
const isActive = pathname === item.href || pathname.startsWith(item.href + "/");
const Icon = item.icon;
return (
<Link
href={item.href}
className={cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
isActive
? "bg-primary-50 text-primary-700"
: "text-neutral-600 hover:bg-neutral-100 hover:text-neutral-900"
)}
>
<Icon className="h-4 w-4 shrink-0" />
{item.label}
</Link>
);
}