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.
-
- ) : (
-
-
-
-
- | Description |
- Notes |
- Date |
- Monthly |
- Daily (÷ 21) |
- % of P&G |
-
-
-
- {data.pngItems.map((c) => {
- const pct =
- data.fixedSum > 0 ? (c.cost / data.fixedSum) * 100 : 0;
- return (
-
- | {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.
+
+ ) : (
+
+
+
+
+ | Description |
+ Notes |
+ Date |
+ Monthly |
+ Daily (÷ 21) |
+ % of P&G |
+
+
+
+ {data.monthlyItems.map((c) => {
+ const pct = (c.cost / (data.fixedSum + data.oneOffSum)) * 100;
+ return (
+
+ | {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.
+
+ ) : (
+
+
+
+
+ | Description |
+ Notes |
+ Date |
+ Cost |
+ Daily (÷ 21) |
+ % of P&G |
+
+
+
+ {data.oneOffItems.map((c) => {
+ const pct = (c.cost / (data.fixedSum + data.oneOffSum)) * 100;
+ return (
+
+ | {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 (
+
+ );
+}