feat(site-detail): split P&G card into monthly + one-off sections
This commit is contained in:
parent
014954b670
commit
b59380455c
3 changed files with 383 additions and 67 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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&G overheads</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Monthly preliminaries & general running costs.
|
||||
Preliminaries & 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&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&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&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>
|
||||
|
|
|
|||
229
src/components/fixed-cost-dialog.tsx
Normal file
229
src/components/fixed-cost-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue