From b59380455c68380127eb9f162dc7b529fabbed9b Mon Sep 17 00:00:00 2001 From: DEls Date: Fri, 28 Aug 2026 13:14:15 +0200 Subject: [PATCH] feat(site-detail): split P&G card into monthly + one-off sections --- src/app/dashboard/sites/[id]/page.tsx | 2 - .../dashboard/sites/[id]/site-detail-view.tsx | 219 ++++++++++++----- src/components/fixed-cost-dialog.tsx | 229 ++++++++++++++++++ 3 files changed, 383 insertions(+), 67 deletions(-) create mode 100644 src/components/fixed-cost-dialog.tsx diff --git a/src/app/dashboard/sites/[id]/page.tsx b/src/app/dashboard/sites/[id]/page.tsx index ee7e369..e0d9865 100644 --- a/src/app/dashboard/sites/[id]/page.tsx +++ b/src/app/dashboard/sites/[id]/page.tsx @@ -92,7 +92,6 @@ export default async function SiteDetailPage({ const monthlyItems: PnGItem[] = partitioned.monthlyItems.map(toPnGItem); const oneOffItems: PnGItem[] = partitioned.oneOffItems.map(toPnGItem); - const pngItems: PnGItem[] = [...monthlyItems, ...oneOffItems]; // legacy aggregate, removed in Task 9 // POs const poList = purchaseOrders @@ -232,7 +231,6 @@ export default async function SiteDetailPage({ monthlyItems, oneOffItems, oneOffSum, - pngItems, poList, fuelLitres: totalLitres, fuelLogs: fuelLogs.length, diff --git a/src/app/dashboard/sites/[id]/site-detail-view.tsx b/src/app/dashboard/sites/[id]/site-detail-view.tsx index 88738a2..e7d764b 100644 --- a/src/app/dashboard/sites/[id]/site-detail-view.tsx +++ b/src/app/dashboard/sites/[id]/site-detail-view.tsx @@ -14,6 +14,7 @@ import { Fuel, Package, PiggyBank, + Plus, Receipt, Truck, TrendingDown, @@ -24,6 +25,8 @@ import { } from "lucide-react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { FixedCostDialog } from "@/components/fixed-cost-dialog"; import { cn, formatCurrency, formatNumber } from "@/lib/utils"; export type SiteStatus = "just-started" | "under" | "on-track" | "over"; @@ -122,7 +125,6 @@ export interface SiteDetailData { status: SiteStatus; monthlyItems: PnGItem[]; oneOffItems: PnGItem[]; - pngItems: PnGItem[]; poList: PoListItem[]; fuelLitres: number; fuelLogs: number; @@ -219,6 +221,7 @@ function amberTile( export function SiteDetailView({ data }: { data: SiteDetailData }) { const status = STATUS_COPY[data.status]; + const [addOpen, setAddOpen] = React.useState(false); return (
@@ -394,84 +397,170 @@ export function SiteDetailView({ data }: { data: SiteDetailData }) {
- {/* P&G breakdown */} + {/* P&G breakdown — monthly + one-off */}
Site P&G overheads

- Monthly preliminaries & general running costs. + Preliminaries & general running costs — monthly and one-off.

-
-

- Total -

-

- {formatCurrency(data.fixedSum)} -

+
+ +
+

+ Total +

+

+ {formatCurrency(data.fixedSum + data.oneOffSum)} +

+
- {data.pngItems.length === 0 ? ( -
- No fixed costs logged for this site yet. -
- ) : ( -
- - - - - - - - - - - - - {data.pngItems.map((c) => { - const pct = - data.fixedSum > 0 ? (c.cost / data.fixedSum) * 100 : 0; - return ( - - - - - - - + + ); + })} + +
DescriptionNotesDateMonthlyDaily (÷ 21)% of P&G
{c.description} - {c.notes || "—"} - - {format(parseISO(c.date), "yyyy-MM-dd")} - - {formatCurrency(c.cost)} - - {formatCurrency(c.dailyCost)} - -
-
-
+ {/* Monthly overheads */} +
+ + Monthly overheads + + {data.monthlyItems.length} item{data.monthlyItems.length === 1 ? "" : "s"} + + + {data.monthlyItems.length === 0 ? ( +
+ No monthly overheads yet. +
+ ) : ( +
+ + + + + + + + + + + + + {data.monthlyItems.map((c) => { + const pct = (c.cost / (data.fixedSum + data.oneOffSum)) * 100; + return ( + + + + + + + - - ); - })} - -
DescriptionNotesDateMonthlyDaily (÷ 21)% of P&G
{c.description} + {c.notes || "—"} + + {c.date ? format(parseISO(c.date), "yyyy-MM-dd") : "—"} + + {formatCurrency(c.cost)} + + {formatCurrency(c.dailyCost)} + +
+
+
+
+ + {pct.toFixed(1)}% +
- - {pct.toFixed(1)}% - -
-
-
- )} +
+
+ )} + + + {/* One-off costs */} +
+ + One-off costs + + {data.oneOffItems.length} item{data.oneOffItems.length === 1 ? "" : "s"} + + + {data.oneOffItems.length === 0 ? ( +
+ No one-off costs logged yet. +
+ ) : ( +
+ + + + + + + + + + + + + {data.oneOffItems.map((c) => { + const pct = (c.cost / (data.fixedSum + data.oneOffSum)) * 100; + return ( + + + + + + + + + ); + })} + +
DescriptionNotesDateCostDaily (÷ 21)% of P&G
{c.description} + {c.notes || "—"} + + {c.date ? format(parseISO(c.date), "yyyy-MM-dd") : "—"} + + {formatCurrency(c.cost)} + + {formatCurrency(c.dailyCost)} + +
+
+
+
+ + {pct.toFixed(1)}% + +
+
+
+ )} +
+ + {/* Income vs Expenses */}
diff --git a/src/components/fixed-cost-dialog.tsx b/src/components/fixed-cost-dialog.tsx new file mode 100644 index 0000000..b5587f4 --- /dev/null +++ b/src/components/fixed-cost-dialog.tsx @@ -0,0 +1,229 @@ +"use client"; + +import * as React from "react"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +type RecurringType = "monthly" | "one_off"; + +interface FixedCostDialogProps { + siteId: string; + open: boolean; + onOpenChange: (open: boolean) => void; + defaultType?: RecurringType; +} + +export function FixedCostDialog({ + siteId, + open, + onOpenChange, + defaultType = "one_off", +}: FixedCostDialogProps) { + const router = useRouter(); + const [type, setType] = React.useState(defaultType); + const [description, setDescription] = React.useState(""); + const [cost, setCost] = React.useState(""); + const [dateLogged, setDateLogged] = React.useState( + new Date().toISOString().slice(0, 10), + ); + const [notes, setNotes] = React.useState(""); + const [fieldError, setFieldError] = React.useState(null); + const [generalError, setGeneralError] = React.useState(null); + const [submitting, setSubmitting] = React.useState(false); + + React.useEffect(() => { + if (open) { + setType(defaultType); + setDescription(""); + setCost(""); + setDateLogged(new Date().toISOString().slice(0, 10)); + setNotes(""); + setFieldError(null); + setGeneralError(null); + } + }, [open, defaultType]); + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + setFieldError(null); + setGeneralError(null); + + const costNum = Number(cost); + if (!Number.isFinite(costNum) || costNum <= 0) { + setFieldError("cost"); + return; + } + + setSubmitting(true); + try { + const res = await fetch(`/api/sites/${siteId}/fixed-costs`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + description: description.trim(), + cost: costNum, + date_logged: dateLogged, + notes: notes.trim() || undefined, + is_recurring: type === "monthly", + }), + }); + if (res.status === 400) { + const body = await res.json().catch(() => ({})); + setFieldError(body.field ?? "general"); + return; + } + if (!res.ok) { + setGeneralError("Couldn't save overhead. Please try again."); + return; + } + onOpenChange(false); + router.refresh(); + } catch { + setGeneralError("Couldn't save overhead. Please try again."); + } finally { + setSubmitting(false); + } + }; + + const fieldMsg = (name: string) => + fieldError === name + ? name === "cost" + ? "Cost must be greater than zero." + : name === "description" + ? "Description is required." + : name === "date_logged" + ? "Date must be in YYYY-MM-DD format." + : "Please fix this field." + : null; + + return ( + + + + Add overhead cost + + Logged against the same site total. Choose monthly for recurring + costs, or one-off for a single occurrence. + + + +
+
+ + Type + +
+ + +
+
+ +
+ + setDescription(e.target.value)} + maxLength={200} + required + /> + {fieldMsg("description") && ( +

{fieldMsg("description")}

+ )} +
+ +
+
+ + setDateLogged(e.target.value)} + required + /> + {fieldMsg("date_logged") && ( +

{fieldMsg("date_logged")}

+ )} +
+
+ +
+ R + setCost(e.target.value)} + required + /> +
+ {fieldMsg("cost") && ( +

{fieldMsg("cost")}

+ )} +
+
+ +
+ + setNotes(e.target.value)} + maxLength={500} + /> +
+ + {generalError && ( +

{generalError}

+ )} + + + + + +
+
+
+ ); +}