feat(sidebar+vessels): Purchasing section, split Administration, Cost Centre rename
Sidebar: - Inventory section renamed to Purchasing - Manager gets separate Administration section for Vendors only - Admin gets full Administration (Vendors + Users + Accounting Codes + Companies) - Sites hidden from Manager when NEXT_PUBLIC_INVENTORY_ENABLED=false - Cost Centres replaces Vessels in the Purchasing nav link Admin vessel pages: - All headings, titles, dialogs, breadcrumbs: Vessels -> Cost Centre - Error messages updated accordingly Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b2402a7e22
commit
e3851a1799
6 changed files with 79 additions and 60 deletions
|
|
@ -11,7 +11,7 @@ interface Props { params: Promise<{ id: string }> }
|
||||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const v = await db.vessel.findUnique({ where: { id }, select: { name: true } });
|
const v = await db.vessel.findUnique({ where: { id }, select: { name: true } });
|
||||||
return { title: v?.name ?? "Vessel Detail" };
|
return { title: v?.name ?? "Cost Centre Detail" };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function VesselDetailPage({ params }: Props) {
|
export default async function VesselDetailPage({ params }: Props) {
|
||||||
|
|
@ -46,7 +46,7 @@ export default async function VesselDetailPage({ params }: Props) {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-5xl space-y-6">
|
<div className="max-w-5xl space-y-6">
|
||||||
<div className="flex items-center gap-2 text-sm text-neutral-500">
|
<div className="flex items-center gap-2 text-sm text-neutral-500">
|
||||||
<Link href="/admin/vessels" className="hover:text-neutral-700">Vessels</Link>
|
<Link href="/admin/vessels" className="hover:text-neutral-700">Cost Centres</Link>
|
||||||
<span>/</span>
|
<span>/</span>
|
||||||
<span className="text-neutral-900 font-medium">{vessel.name}</span>
|
<span className="text-neutral-900 font-medium">{vessel.name}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import { nextId } from "@/lib/id-generators";
|
||||||
type ActionResult = { ok: true } | { error: string };
|
type ActionResult = { ok: true } | { error: string };
|
||||||
|
|
||||||
const vesselSchema = z.object({
|
const vesselSchema = z.object({
|
||||||
name: z.string().min(1, "Vessel name is required"),
|
name: z.string().min(1, "Cost centre name is required"),
|
||||||
code: z.string().optional(),
|
code: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -66,7 +66,7 @@ export async function deleteVessel(id: string): Promise<ActionResult> {
|
||||||
if (!session?.user || !hasPermission(session.user.role, "manage_vessels_accounts")) return { error: "Unauthorized" };
|
if (!session?.user || !hasPermission(session.user.role, "manage_vessels_accounts")) return { error: "Unauthorized" };
|
||||||
|
|
||||||
const inUse = await db.purchaseOrder.findFirst({ where: { vesselId: id } });
|
const inUse = await db.purchaseOrder.findFirst({ where: { vesselId: id } });
|
||||||
if (inUse) return { error: "Cannot delete: vessel is referenced in purchase orders. Remove those POs first." };
|
if (inUse) return { error: "Cannot delete: this cost centre is referenced in purchase orders." };
|
||||||
|
|
||||||
await db.vessel.delete({ where: { id } });
|
await db.vessel.delete({ where: { id } });
|
||||||
revalidatePath("/admin/vessels");
|
revalidatePath("/admin/vessels");
|
||||||
|
|
@ -80,7 +80,7 @@ export async function toggleVesselActive(vesselId: string): Promise<ActionResult
|
||||||
}
|
}
|
||||||
|
|
||||||
const vessel = await db.vessel.findUnique({ where: { id: vesselId }, select: { isActive: true } });
|
const vessel = await db.vessel.findUnique({ where: { id: vesselId }, select: { isActive: true } });
|
||||||
if (!vessel) return { error: "Vessel not found" };
|
if (!vessel) return { error: "Cost centre not found" };
|
||||||
|
|
||||||
await db.vessel.update({ where: { id: vesselId }, data: { isActive: !vessel.isActive } });
|
await db.vessel.update({ where: { id: vesselId }, data: { isActive: !vessel.isActive } });
|
||||||
revalidatePath("/admin/vessels");
|
revalidatePath("/admin/vessels");
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { VesselsTable } from "./vessels-table";
|
||||||
import { nextId } from "@/lib/id-generators";
|
import { nextId } from "@/lib/id-generators";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Vessel Management" };
|
export const metadata: Metadata = { title: "Cost Centre Management" };
|
||||||
|
|
||||||
export default async function AdminVesselsPage() {
|
export default async function AdminVesselsPage() {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ function VesselFormFields({ vessel, suggestedCode }: { vessel?: VesselRow; sugge
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-neutral-700 mb-1">Vessel Name *</label>
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Cost Centre Name *</label>
|
||||||
<input name="name" defaultValue={vessel?.name} required className={INPUT} />
|
<input name="name" defaultValue={vessel?.name} required className={INPUT} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -60,9 +60,9 @@ export function AddVesselButton({ suggestedCode }: { suggestedCode?: string }) {
|
||||||
<>
|
<>
|
||||||
<button onClick={() => setOpen(true)}
|
<button onClick={() => setOpen(true)}
|
||||||
className="rounded-lg bg-primary-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-primary-700 transition-colors">
|
className="rounded-lg bg-primary-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-primary-700 transition-colors">
|
||||||
+ Add Vessel
|
+ Add Cost Centre
|
||||||
</button>
|
</button>
|
||||||
<AdminDialog title="Add Vessel" open={open} onClose={() => setOpen(false)}>
|
<AdminDialog title="Add Cost Centre" open={open} onClose={() => setOpen(false)}>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<VesselFormFields suggestedCode={suggestedCode} />
|
<VesselFormFields suggestedCode={suggestedCode} />
|
||||||
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
|
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
|
||||||
|
|
@ -73,7 +73,7 @@ export function AddVesselButton({ suggestedCode }: { suggestedCode?: string }) {
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" disabled={pending}
|
<button type="submit" disabled={pending}
|
||||||
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">
|
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">
|
||||||
{pending ? "Creating…" : "Create Vessel"}
|
{pending ? "Creating…" : "Create Cost Centre"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
@ -119,7 +119,7 @@ export function EditVesselButton({
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<AdminDialog title="Edit Vessel" open={open} onClose={() => setOpen(false)}>
|
<AdminDialog title="Edit Cost Centre" open={open} onClose={() => setOpen(false)}>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<VesselFormFields vessel={vessel} />
|
<VesselFormFields vessel={vessel} />
|
||||||
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
|
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ function VesselActionsMenu({ vessel }: { vessel: VesselRow }) {
|
||||||
open={toggleOpen}
|
open={toggleOpen}
|
||||||
onOpenChange={setToggleOpen}
|
onOpenChange={setToggleOpen}
|
||||||
title={vessel.isActive ? `Deactivate ${vessel.name}?` : `Activate ${vessel.name}?`}
|
title={vessel.isActive ? `Deactivate ${vessel.name}?` : `Activate ${vessel.name}?`}
|
||||||
description={vessel.isActive ? `${vessel.name} will be hidden from new purchase orders.` : `${vessel.name} will become available for new purchase orders.`}
|
description={vessel.isActive ? `${vessel.name} will be hidden from new PO cost centre selection.` : `${vessel.name} will become available in PO cost centre selection.`}
|
||||||
confirmLabel={vessel.isActive ? "Deactivate" : "Activate"}
|
confirmLabel={vessel.isActive ? "Deactivate" : "Activate"}
|
||||||
onConfirm={() => toggleVesselActive(vessel.id)}
|
onConfirm={() => toggleVesselActive(vessel.id)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -79,14 +79,14 @@ export function VesselsTable({ vessels, suggestedCode }: { vessels: VesselRow[];
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-6 flex items-center justify-between">
|
<div className="mb-6 flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-semibold text-neutral-900">Vessel Management</h1>
|
<h1 className="text-2xl font-semibold text-neutral-900">Cost Centre Management</h1>
|
||||||
<AddVesselButton suggestedCode={suggestedCode} />
|
<AddVesselButton suggestedCode={suggestedCode} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TableControls
|
<TableControls
|
||||||
search={search}
|
search={search}
|
||||||
onSearch={setSearch}
|
onSearch={setSearch}
|
||||||
searchPlaceholder="Search vessels…"
|
searchPlaceholder="Search cost centres…"
|
||||||
chips={CHIPS}
|
chips={CHIPS}
|
||||||
activeFilters={activeFilters}
|
activeFilters={activeFilters}
|
||||||
onToggleFilter={toggleFilter}
|
onToggleFilter={toggleFilter}
|
||||||
|
|
@ -106,7 +106,7 @@ export function VesselsTable({ vessels, suggestedCode }: { vessels: VesselRow[];
|
||||||
{filtered.length === 0 && (
|
{filtered.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={4} className="px-4 py-8 text-center text-neutral-400">
|
<td colSpan={4} className="px-4 py-8 text-center text-neutral-400">
|
||||||
No vessels match your search.
|
No cost centres match your search.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -35,52 +35,57 @@ interface NavItem {
|
||||||
}
|
}
|
||||||
|
|
||||||
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"] },
|
||||||
{ href: "/my-orders", label: "Closed Purchase Orders", icon: FileText, 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: "/po/import", label: "Import PO", icon: Upload, roles: ["MANAGER", "SUPERUSER"] },
|
||||||
{ 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: ["MANAGER", "SUPERUSER", "AUDITOR", "ADMIN"] },
|
||||||
{ href: "/profile", label: "My Profile", icon: UserCircle },
|
{ href: "/profile", label: "My Profile", icon: UserCircle },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Vendor/product/cart nav — always visible (needed for PO creation)
|
// ── Purchasing section ────────────────────────────────────────────────────────
|
||||||
const PO_CATALOGUE_ITEMS: NavItem[] = [
|
// Staff browsing items (product catalogue + cart for PO creation)
|
||||||
{ href: "/inventory/items", label: "Items", icon: Package, roles: ["TECHNICAL", "MANNING", "SUPERUSER"] },
|
const PURCHASING_STAFF: NavItem[] = [
|
||||||
{ href: "/inventory/vendors", label: "Vendors", icon: Store, roles: ["TECHNICAL", "MANNING", "SUPERUSER"] },
|
{ href: "/inventory/items", label: "Items", icon: Package, roles: ["TECHNICAL", "MANNING", "SUPERUSER"] },
|
||||||
{ href: "/inventory/cart", label: "Cart", icon: ShoppingCart, roles: ["TECHNICAL", "MANNING", "SUPERUSER", "MANAGER"] },
|
{ href: "/inventory/vendors", label: "Vendors", icon: Store, roles: ["TECHNICAL", "MANNING", "SUPERUSER"] },
|
||||||
{ href: "/admin/vendors", label: "Vendors", icon: Store, roles: ["MANAGER", "ACCOUNTS", "ADMIN"] },
|
{ href: "/inventory/cart", label: "Cart", icon: ShoppingCart, roles: ["TECHNICAL", "MANNING", "SUPERUSER", "MANAGER"] },
|
||||||
{ href: "/admin/products", label: "Items", icon: Package, roles: ["MANAGER", "ADMIN"] },
|
|
||||||
{ href: "/admin/vessels", label: "Vessels", icon: Ship, roles: ["MANAGER", "ADMIN"] },
|
|
||||||
{ href: "/admin/sites", label: "Sites", icon: MapPin, roles: ["MANAGER", "ADMIN"] },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// Inventory tracking nav — hidden when NEXT_PUBLIC_INVENTORY_ENABLED=false
|
// Manager / Admin catalogue management — Sites conditionally shown
|
||||||
const INVENTORY_TRACKING_ITEMS: NavItem[] = []; // reserved for future inventory-only links
|
const PURCHASING_MGMT: NavItem[] = [
|
||||||
|
{ href: "/admin/products", label: "Items", icon: Package, roles: ["MANAGER", "ADMIN"] },
|
||||||
|
{ href: "/admin/vessels", label: "Cost Centres", icon: Ship, roles: ["MANAGER", "ADMIN"] },
|
||||||
|
...(INVENTORY_ENABLED
|
||||||
|
? [{ href: "/admin/sites", label: "Sites", icon: MapPin, roles: ["MANAGER", "ADMIN"] as Role[] }]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
|
||||||
const INVENTORY_ITEMS: NavItem[] = INVENTORY_ENABLED
|
const PURCHASING_ITEMS: NavItem[] = [...PURCHASING_STAFF, ...PURCHASING_MGMT];
|
||||||
? [...PO_CATALOGUE_ITEMS, ...INVENTORY_TRACKING_ITEMS]
|
|
||||||
: PO_CATALOGUE_ITEMS;
|
|
||||||
|
|
||||||
|
// ── 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"] },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Full Administration section (ADMIN only)
|
||||||
const ADMIN_ITEMS: NavItem[] = [
|
const ADMIN_ITEMS: NavItem[] = [
|
||||||
{ href: "/admin/users", label: "Users", icon: Users },
|
{ href: "/admin/users", label: "Users", icon: Users },
|
||||||
{ href: "/admin/superuser-requests", label: "SuperUser Requests", icon: ShieldCheck },
|
{ href: "/admin/superuser-requests", label: "SuperUser Requests",icon: ShieldCheck },
|
||||||
{ href: "/admin/accounts", label: "Accounting Codes", icon: Building2 },
|
{ href: "/admin/accounts", label: "Accounting Codes", icon: Building2 },
|
||||||
{ href: "/admin/companies", label: "Companies", icon: Briefcase },
|
{ href: "/admin/companies", label: "Companies", icon: Briefcase },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function Sidebar({ userRole }: { userRole: Role }) {
|
export function Sidebar({ userRole }: { userRole: Role }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const isAdmin = userRole === "ADMIN";
|
const isAdmin = userRole === "ADMIN";
|
||||||
|
|
||||||
const visible = NAV_ITEMS.filter(
|
const visibleMain = NAV_ITEMS.filter((i) => !i.roles || i.roles.includes(userRole));
|
||||||
(item) => !item.roles || item.roles.includes(userRole)
|
const visiblePurchasing = PURCHASING_ITEMS.filter((i) => !i.roles || i.roles.includes(userRole));
|
||||||
);
|
const visibleMgrAdmin = MANAGER_ADMIN_ITEMS.filter((i) => !i.roles || i.roles.includes(userRole));
|
||||||
const visibleInventory = INVENTORY_ITEMS.filter(
|
|
||||||
(item) => !item.roles || item.roles.includes(userRole)
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="flex h-screen w-60 shrink-0 flex-col border-r border-neutral-200 bg-white">
|
<aside className="flex h-screen w-60 shrink-0 flex-col border-r border-neutral-200 bg-white">
|
||||||
|
|
@ -92,27 +97,34 @@ export function Sidebar({ userRole }: { userRole: Role }) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="flex-1 overflow-y-auto px-3 py-4 space-y-0.5">
|
<nav className="flex-1 overflow-y-auto px-3 py-4 space-y-0.5">
|
||||||
{visible.map((item) => (
|
{visibleMain.map((item) => (
|
||||||
<NavLink key={item.href} item={item} pathname={pathname} />
|
<NavLink key={item.href} item={item} pathname={pathname} />
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{visibleInventory.length > 0 && (
|
{visiblePurchasing.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="pt-4 pb-1 px-3">
|
<SectionHeader label="Purchasing" />
|
||||||
<p className="text-xs font-semibold text-neutral-400 uppercase tracking-wider">Inventory</p>
|
{visiblePurchasing.map((item) => (
|
||||||
</div>
|
|
||||||
{visibleInventory.map((item) => (
|
|
||||||
<NavLink key={item.href} item={item} pathname={pathname} />
|
<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 && (
|
{isAdmin && (
|
||||||
<>
|
<>
|
||||||
<div className="pt-4 pb-1 px-3">
|
<SectionHeader label="Administration" />
|
||||||
<p className="text-xs font-semibold text-neutral-400 uppercase tracking-wider">Administration</p>
|
{[...MANAGER_ADMIN_ITEMS, ...ADMIN_ITEMS].map((item) => (
|
||||||
</div>
|
|
||||||
{ADMIN_ITEMS.map((item) => (
|
|
||||||
<NavLink key={item.href} item={item} pathname={pathname} />
|
<NavLink key={item.href} item={item} pathname={pathname} />
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
|
|
@ -122,10 +134,17 @@ export function Sidebar({ userRole }: { userRole: Role }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 }) {
|
function NavLink({ item, pathname }: { item: NavItem; pathname: string }) {
|
||||||
const isActive = pathname === item.href || pathname.startsWith(item.href + "/");
|
const isActive = pathname === item.href || pathname.startsWith(item.href + "/");
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={item.href}
|
href={item.href}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue