diff --git a/App/CLAUDE.md b/App/CLAUDE.md index 016fc89..c9b64ad 100644 --- a/App/CLAUDE.md +++ b/App/CLAUDE.md @@ -72,6 +72,18 @@ Every status change is validated against the state machine and recorded as a `PO - `components/po/` — PO-specific components (line items editor, status badge, etc.) - `tests/integration/helpers.ts` — `makeSession()`, `makePoForm()`, `fd()` for integration test setup +### Cost Centre Model + +A PO's "cost centre" is either a **Vessel** or a **Site**. `PurchaseOrder` has both `vesselId String?` (nullable) and `siteId String?` — exactly one is set. + +**Form encoding:** All PO creation/edit forms use a `costCentreRef` field with values `v:` (vessel) or `s:` (site). Server actions parse this to set the correct FK. + +**Display pattern:** `po.vessel?.name ?? po.site?.name ?? "—"` everywhere a cost centre name is shown. + +**URL pre-select:** `/po/new?costCentreRef=v:` or `?costCentreRef=s:`. + +**Terminology:** Admin pages use the real entity names (Vessel Management, Sites). PO-facing pages use "Cost Centre" for the combined concept. Budget heads are labelled "Accounting Code" (not "Account"). + ### GST Calculation `totalAmount = sum(quantity × unitPrice × (1 + gstRate))` for each line item. The `gstRate` is stored as a decimal on `POLineItem` (e.g., `0.18` = 18%). This applies in Server Actions when computing `totalPrice` per line and the PO `totalAmount`. diff --git a/App/app/(portal)/admin/accounts/account-form.tsx b/App/app/(portal)/admin/accounts/account-form.tsx index a6ce069..243bc03 100644 --- a/App/app/(portal)/admin/accounts/account-form.tsx +++ b/App/app/(portal)/admin/accounts/account-form.tsx @@ -18,13 +18,13 @@ function AccountFormFields({ account, suggestedCode }: { account?: AccountRow; s
- +
- +
@@ -57,9 +57,9 @@ export function AddAccountButton({ suggestedCode }: { suggestedCode?: string }) <> - setOpen(false)}> + setOpen(false)}>
{error &&

{error}

} @@ -70,7 +70,7 @@ export function AddAccountButton({ suggestedCode }: { suggestedCode?: string })
@@ -116,7 +116,7 @@ export function EditAccountButton({ Edit )} - setOpen(false)}> + setOpen(false)}>
{error &&

{error}

} diff --git a/App/app/(portal)/admin/accounts/accounts-table.tsx b/App/app/(portal)/admin/accounts/accounts-table.tsx index 0d523a8..dcdb7f5 100644 --- a/App/app/(portal)/admin/accounts/accounts-table.tsx +++ b/App/app/(portal)/admin/accounts/accounts-table.tsx @@ -93,14 +93,14 @@ export function AccountsTable({ return (
-

Account Management

+

Accounting Code Management

- + + Create PO {canEdit && } @@ -110,7 +110,7 @@ export default async function SiteDetailPage({ params }: Props) { {/* Summary cards */}
-

Assigned Cost Centres

+

Assigned Vessels

{site.vessels.length}

@@ -170,7 +170,7 @@ export default async function SiteDetailPage({ params }: Props) { {/* Assigned vessels */} {site.vessels.length > 0 && (
-

Assigned Vessels

+

Assigned Vessels (Cost Centres)

{site.vessels.map((v) => ( toggleSiteActive(site.id)} /> @@ -123,7 +123,7 @@ export function SitesTable({ toggleSort(k as keyof SiteRow)}>Name toggleSort(k as keyof SiteRow)}>Code toggleSort(k as keyof SiteRow)}>Address - Cost Centres + Vessels Items tracked Location toggleSort(k as keyof SiteRow)}>Status diff --git a/App/app/(portal)/admin/vessels/[id]/page.tsx b/App/app/(portal)/admin/vessels/[id]/page.tsx index 13c3064..8b87674 100644 --- a/App/app/(portal)/admin/vessels/[id]/page.tsx +++ b/App/app/(portal)/admin/vessels/[id]/page.tsx @@ -11,7 +11,7 @@ interface Props { params: Promise<{ id: string }> } export async function generateMetadata({ params }: Props): Promise { const { id } = await params; const v = await db.vessel.findUnique({ where: { id }, select: { name: true } }); - return { title: v?.name ?? "Cost Centre Detail" }; + return { title: v?.name ?? "Vessel Detail" }; } export default async function VesselDetailPage({ params }: Props) { @@ -47,7 +47,7 @@ export default async function VesselDetailPage({ params }: Props) { return (
- Cost Centres + Vessels / {vessel.name}
@@ -66,7 +66,7 @@ export default async function VesselDetailPage({ params }: Props) {

)}
- + + Create PO
diff --git a/App/app/(portal)/admin/vessels/actions.ts b/App/app/(portal)/admin/vessels/actions.ts index 830eb46..8b66426 100644 --- a/App/app/(portal)/admin/vessels/actions.ts +++ b/App/app/(portal)/admin/vessels/actions.ts @@ -11,6 +11,7 @@ type ActionResult = { ok: true } | { error: string }; const vesselSchema = z.object({ name: z.string().min(1, "Vessel name is required"), + siteId: z.string().optional(), }); export async function createVessel(formData: FormData): Promise { @@ -21,13 +22,14 @@ export async function createVessel(formData: FormData): Promise { const parsed = vesselSchema.safeParse({ name: formData.get("name"), + siteId: (formData.get("siteId") as string) || undefined, }); if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" }; const existingCodes = await db.vessel.findMany({ select: { code: true } }); const code = nextId("SITE", existingCodes.map((v) => v.code)); - await db.vessel.create({ data: { name: parsed.data.name, code } }); + await db.vessel.create({ data: { name: parsed.data.name, code, siteId: parsed.data.siteId ?? null } }); revalidatePath("/admin/vessels"); return { ok: true }; } @@ -43,10 +45,11 @@ export async function updateVessel(formData: FormData): Promise { const parsed = vesselSchema.safeParse({ name: formData.get("name"), + siteId: (formData.get("siteId") as string) || undefined, }); if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" }; - await db.vessel.update({ where: { id }, data: { name: parsed.data.name } }); + await db.vessel.update({ where: { id }, data: { name: parsed.data.name, siteId: parsed.data.siteId ?? null } }); revalidatePath("/admin/vessels"); return { ok: true }; } diff --git a/App/app/(portal)/admin/vessels/page.tsx b/App/app/(portal)/admin/vessels/page.tsx index b7f661c..f2da75f 100644 --- a/App/app/(portal)/admin/vessels/page.tsx +++ b/App/app/(portal)/admin/vessels/page.tsx @@ -5,7 +5,7 @@ import { redirect } from "next/navigation"; import { VesselsTable } from "./vessels-table"; import type { Metadata } from "next"; -export const metadata: Metadata = { title: "Cost Centre Management" }; +export const metadata: Metadata = { title: "Vessel Management" }; export default async function AdminVesselsPage() { const session = await auth(); @@ -13,10 +13,13 @@ export default async function AdminVesselsPage() { if (!hasPermission(session.user.role, "manage_vessels_accounts")) redirect("/dashboard"); - const vessels = await db.vessel.findMany({ - orderBy: { name: "asc" }, - include: { site: { select: { name: true } } }, - }); + const [vessels, sites] = await Promise.all([ + db.vessel.findMany({ + orderBy: { name: "asc" }, + include: { site: { select: { name: true } } }, + }), + db.site.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }), + ]); return ( ); } diff --git a/App/app/(portal)/admin/vessels/vessel-form.tsx b/App/app/(portal)/admin/vessels/vessel-form.tsx index cc2c807..9114182 100644 --- a/App/app/(portal)/admin/vessels/vessel-form.tsx +++ b/App/app/(portal)/admin/vessels/vessel-form.tsx @@ -5,14 +5,19 @@ import { useRouter } from "next/navigation"; import { AdminDialog } from "@/components/ui/admin-dialog"; import { createVessel, updateVessel, toggleVesselActive } from "./actions"; +type SiteOption = { id: string; name: string }; + type VesselRow = { id: string; name: string; code: string; + siteId: string | null; isActive: boolean; }; -function VesselFormFields({ vessel }: { vessel?: VesselRow }) { +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"; + +function VesselFormFields({ vessel, sites }: { vessel?: VesselRow; sites: SiteOption[] }) { return (
{vessel && ( @@ -22,15 +27,25 @@ function VesselFormFields({ vessel }: { vessel?: VesselRow }) {
)}
- - + +
+ {sites.length > 0 && ( +
+ + +
+ )}
); } -export function AddVesselButton() { +export function AddVesselButton({ sites }: { sites: SiteOption[] }) { const router = useRouter(); const [open, setOpen] = useState(false); const [pending, setPending] = useState(false); @@ -49,11 +64,11 @@ export function AddVesselButton() { <> - setOpen(false)}> + setOpen(false)}> - + {error &&

{error}

}
@@ -73,10 +88,12 @@ export function AddVesselButton() { export function EditVesselButton({ vessel, + sites, open: controlledOpen, onOpenChange, }: { vessel: VesselRow; + sites: SiteOption[]; open?: boolean; onOpenChange?: (v: boolean) => void; }) { @@ -108,9 +125,9 @@ export function EditVesselButton({ Edit )} - setOpen(false)}> + setOpen(false)}>
- + {error &&

{error}

}