feat(site-detail): split P&G card into monthly + one-off sections

This commit is contained in:
DEls 2026-08-28 13:14:15 +02:00
parent 014954b670
commit b59380455c
3 changed files with 383 additions and 67 deletions

View file

@ -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,

View file

@ -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 (
<div className="space-y-6 p-4 sm:p-6">
@ -394,84 +397,170 @@ export function SiteDetailView({ data }: { data: SiteDetailData }) {
</Card>
</div>
{/* P&G breakdown */}
{/* P&G breakdown — monthly + one-off */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="text-base">Site P&amp;G overheads</CardTitle>
<p className="text-xs text-muted-foreground">
Monthly preliminaries &amp; general running costs.
Preliminaries &amp; general running costs — monthly and one-off.
</p>
</div>
<div className="text-right">
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">
Total
</p>
<p className="text-base font-bold tabular-nums">
{formatCurrency(data.fixedSum)}
</p>
<div className="flex items-center gap-3">
<Button size="sm" className="gap-2" onClick={() => setAddOpen(true)}>
<Plus className="h-4 w-4" />
Add
</Button>
<div className="text-right">
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">
Total
</p>
<p className="text-base font-bold tabular-nums">
{formatCurrency(data.fixedSum + data.oneOffSum)}
</p>
</div>
</div>
</CardHeader>
<CardContent className="p-0">
{data.pngItems.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
No fixed costs logged for this site yet.
</div>
) : (
<div className="overflow-auto">
<table className="w-full text-sm">
<thead className="border-b border-border bg-card/95 text-xs uppercase tracking-wider text-muted-foreground">
<tr>
<th className="h-10 px-3 text-left">Description</th>
<th className="h-10 px-3 text-left">Notes</th>
<th className="h-10 px-3 text-left">Date</th>
<th className="h-10 px-3 text-right">Monthly</th>
<th className="h-10 px-3 text-right">Daily (÷ 21)</th>
<th className="h-10 px-3 text-right">% of P&amp;G</th>
</tr>
</thead>
<tbody>
{data.pngItems.map((c) => {
const pct =
data.fixedSum > 0 ? (c.cost / data.fixedSum) * 100 : 0;
return (
<tr key={c.id} className="border-b border-border last:border-0">
<td className="px-3 py-2 font-medium">{c.description}</td>
<td className="px-3 py-2 text-xs text-muted-foreground">
{c.notes || "—"}
</td>
<td className="px-3 py-2 text-xs tabular-nums">
{format(parseISO(c.date), "yyyy-MM-dd")}
</td>
<td className="px-3 py-2 text-right tabular-nums font-semibold">
{formatCurrency(c.cost)}
</td>
<td className="px-3 py-2 text-right tabular-nums text-[#F2B165]">
{formatCurrency(c.dailyCost)}
</td>
<td className="px-3 py-2 text-right">
<div className="flex items-center justify-end gap-2">
<div className="h-1 w-16 overflow-hidden rounded-full bg-muted">
<div
className="h-full bg-[#F2B165]"
style={{ width: `${pct}%` }}
/>
{/* Monthly overheads */}
<details open className="border-b border-border">
<summary className="flex cursor-pointer items-center justify-between px-4 py-3 text-sm font-medium">
<span>Monthly overheads</span>
<span className="text-xs text-muted-foreground">
{data.monthlyItems.length} item{data.monthlyItems.length === 1 ? "" : "s"}
</span>
</summary>
{data.monthlyItems.length === 0 ? (
<div className="px-4 pb-4 text-sm text-muted-foreground">
No monthly overheads yet.
</div>
) : (
<div className="overflow-auto">
<table className="w-full text-sm">
<thead className="border-b border-border bg-card/95 text-xs uppercase tracking-wider text-muted-foreground">
<tr>
<th className="h-10 px-3 text-left">Description</th>
<th className="h-10 px-3 text-left">Notes</th>
<th className="h-10 px-3 text-left">Date</th>
<th className="h-10 px-3 text-right">Monthly</th>
<th className="h-10 px-3 text-right">Daily (÷ 21)</th>
<th className="h-10 px-3 text-right">% of P&amp;G</th>
</tr>
</thead>
<tbody>
{data.monthlyItems.map((c) => {
const pct = (c.cost / (data.fixedSum + data.oneOffSum)) * 100;
return (
<tr key={c.id} className="border-b border-border last:border-0">
<td className="px-3 py-2 font-medium">{c.description}</td>
<td className="px-3 py-2 text-xs text-muted-foreground">
{c.notes || "—"}
</td>
<td className="px-3 py-2 text-xs tabular-nums">
{c.date ? format(parseISO(c.date), "yyyy-MM-dd") : "—"}
</td>
<td className="px-3 py-2 text-right tabular-nums font-semibold">
{formatCurrency(c.cost)}
</td>
<td className="px-3 py-2 text-right tabular-nums text-[#F2B165]">
{formatCurrency(c.dailyCost)}
</td>
<td className="px-3 py-2 text-right">
<div className="flex items-center justify-end gap-2">
<div className="h-1 w-16 overflow-hidden rounded-full bg-muted">
<div
className="h-full bg-[#F2B165]"
style={{ width: `${pct}%` }}
/>
</div>
<span className="w-12 text-right text-xs tabular-nums text-muted-foreground">
{pct.toFixed(1)}%
</span>
</div>
<span className="w-12 text-right text-xs tabular-nums text-muted-foreground">
{pct.toFixed(1)}%
</span>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</details>
{/* One-off costs */}
<details open>
<summary className="flex cursor-pointer items-center justify-between px-4 py-3 text-sm font-medium">
<span>One-off costs</span>
<span className="text-xs text-muted-foreground">
{data.oneOffItems.length} item{data.oneOffItems.length === 1 ? "" : "s"}
</span>
</summary>
{data.oneOffItems.length === 0 ? (
<div className="px-4 pb-4 text-sm text-muted-foreground">
No one-off costs logged yet.
</div>
) : (
<div className="overflow-auto">
<table className="w-full text-sm">
<thead className="border-b border-border bg-card/95 text-xs uppercase tracking-wider text-muted-foreground">
<tr>
<th className="h-10 px-3 text-left">Description</th>
<th className="h-10 px-3 text-left">Notes</th>
<th className="h-10 px-3 text-left">Date</th>
<th className="h-10 px-3 text-right">Cost</th>
<th className="h-10 px-3 text-right">Daily (÷ 21)</th>
<th className="h-10 px-3 text-right">% of P&amp;G</th>
</tr>
</thead>
<tbody>
{data.oneOffItems.map((c) => {
const pct = (c.cost / (data.fixedSum + data.oneOffSum)) * 100;
return (
<tr key={c.id} className="border-b border-border last:border-0">
<td className="px-3 py-2 font-medium">{c.description}</td>
<td className="px-3 py-2 text-xs text-muted-foreground">
{c.notes || "—"}
</td>
<td className="px-3 py-2 text-xs tabular-nums">
{c.date ? format(parseISO(c.date), "yyyy-MM-dd") : "—"}
</td>
<td className="px-3 py-2 text-right tabular-nums font-semibold">
{formatCurrency(c.cost)}
</td>
<td className="px-3 py-2 text-right tabular-nums text-[#F2B165]">
{formatCurrency(c.dailyCost)}
</td>
<td className="px-3 py-2 text-right">
<div className="flex items-center justify-end gap-2">
<div className="h-1 w-16 overflow-hidden rounded-full bg-muted">
<div
className="h-full bg-[#F2B165]"
style={{ width: `${pct}%` }}
/>
</div>
<span className="w-12 text-right text-xs tabular-nums text-muted-foreground">
{pct.toFixed(1)}%
</span>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</details>
</CardContent>
</Card>
<FixedCostDialog
siteId={data.id}
open={addOpen}
onOpenChange={setAddOpen}
defaultType="one_off"
/>
{/* Income vs Expenses */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<Card>

View file

@ -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<RecurringType>(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<string | null>(null);
const [generalError, setGeneralError] = React.useState<string | null>(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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add overhead cost</DialogTitle>
<DialogDescription>
Logged against the same site total. Choose monthly for recurring
costs, or one-off for a single occurrence.
</DialogDescription>
</DialogHeader>
<form onSubmit={submit} className="grid gap-4">
<fieldset className="grid gap-2">
<legend className="text-xs uppercase tracking-wider text-muted-foreground">
Type
</legend>
<div className="flex gap-4">
<label className="flex items-center gap-2 text-sm">
<input
type="radio"
name="type"
value="monthly"
checked={type === "monthly"}
onChange={() => setType("monthly")}
/>
Monthly recurring
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="radio"
name="type"
value="one_off"
checked={type === "one_off"}
onChange={() => setType("one_off")}
/>
One-off
</label>
</div>
</fieldset>
<div className="grid gap-1">
<Label htmlFor="fc-description">Description</Label>
<Input
id="fc-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
maxLength={200}
required
/>
{fieldMsg("description") && (
<p className="text-xs text-red-500">{fieldMsg("description")}</p>
)}
</div>
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-1">
<Label htmlFor="fc-date">Date</Label>
<Input
id="fc-date"
type="date"
value={dateLogged}
onChange={(e) => setDateLogged(e.target.value)}
required
/>
{fieldMsg("date_logged") && (
<p className="text-xs text-red-500">{fieldMsg("date_logged")}</p>
)}
</div>
<div className="grid gap-1">
<Label htmlFor="fc-cost">Cost</Label>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">R</span>
<Input
id="fc-cost"
type="number"
inputMode="decimal"
step="0.01"
min="0"
value={cost}
onChange={(e) => setCost(e.target.value)}
required
/>
</div>
{fieldMsg("cost") && (
<p className="text-xs text-red-500">{fieldMsg("cost")}</p>
)}
</div>
</div>
<div className="grid gap-1">
<Label htmlFor="fc-notes">Notes</Label>
<Input
id="fc-notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
maxLength={500}
/>
</div>
{generalError && (
<p className="text-sm text-red-500">{generalError}</p>
)}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={submitting}
>
Cancel
</Button>
<Button type="submit" disabled={submitting}>
{submitting ? "Saving…" : "Save"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}