From 472be99fd8183e54b012ac2cfb02eefaf3f45a70 Mon Sep 17 00:00:00 2001 From: DEls Date: Tue, 25 Aug 2026 19:01:56 +0200 Subject: [PATCH] feat(dashboard): add job start date to site records and enhance site management - Introduced a new optional field `job_start_date` in the site schema to track project start dates. - Updated the SitesPage component to utilize `job_start_date` for determining site status and fallback logic. - Enhanced the SitesView component to display the new status "upcoming" for sites with future start dates. - Modified the SiteDetailPage to include `job_start_date` in the site details and added an EditSiteButton for site management. - Updated the site templates to include `job_start_date` for better pre-filling in the site creation form. --- scripts/pb-bootstrap-schema.py | 1 + src/app/api/sites/[id]/route.ts | 65 ++++ src/app/api/sites/route.ts | 72 ++++ src/app/dashboard/sites/[id]/page.tsx | 30 +- src/app/dashboard/sites/page.tsx | 38 ++- src/app/dashboard/sites/sites-view.tsx | 290 ++++++++-------- src/components/site-form-dialog.tsx | 443 +++++++++++++++++++++++++ src/components/ui/label.tsx | 22 ++ src/lib/pb-types.ts | 2 + 9 files changed, 813 insertions(+), 150 deletions(-) create mode 100644 src/app/api/sites/[id]/route.ts create mode 100644 src/app/api/sites/route.ts create mode 100644 src/components/site-form-dialog.tsx create mode 100644 src/components/ui/label.tsx diff --git a/scripts/pb-bootstrap-schema.py b/scripts/pb-bootstrap-schema.py index b4ee123..43e2b00 100644 --- a/scripts/pb-bootstrap-schema.py +++ b/scripts/pb-bootstrap-schema.py @@ -78,6 +78,7 @@ DEFS["sites"] = { field("work_hours", "number", required=False), field("hours_per_week", "number", required=False), field("wages_hours_per_day", "number", required=False), + field("job_start_date", "date", required=False), ], "indexes": [ "CREATE UNIQUE INDEX `idx_sites_project_code` ON `sites` (`project_code`)", diff --git a/src/app/api/sites/[id]/route.ts b/src/app/api/sites/[id]/route.ts new file mode 100644 index 0000000..90762c0 --- /dev/null +++ b/src/app/api/sites/[id]/route.ts @@ -0,0 +1,65 @@ +import { NextResponse } from "next/server"; +import { pbAdmin } from "@/lib/pb"; + +/** + * PATCH /api/sites/[id] + * + * Partial update — only fields present in the body are written. + * project_code is locked (changing it would break joins); use null on + * job_start_date to clear it explicitly. + */ +export async function PATCH( + request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + let body: Record; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Body must be JSON" }, { status: 400 }); + } + + if ("project_code" in body) { + return NextResponse.json( + { error: "project_code cannot be changed once a site exists" }, + { status: 400 }, + ); + } + + const payload: Record = {}; + for (const k of ["name", "duration"] as const) { + if (typeof body[k] === "string") payload[k] = body[k]; + } + for (const k of [ + "contract_value", + "work_hours", + "hours_per_week", + "wages_hours_per_day", + ] as const) { + const v = body[k]; + if (v === null || v === "") continue; + const n = typeof v === "number" ? v : Number(v); + if (Number.isFinite(n)) payload[k] = n; + } + + if ("job_start_date" in body) { + const v = body.job_start_date; + if (v === null || v === "") { + payload.job_start_date = null; + } else if (typeof v === "string") { + payload.job_start_date = v; + } + } + + const pb = await pbAdmin(); + try { + const updated = await pb.collection("sites").update(id, payload); + return NextResponse.json({ site: updated }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Update failed" }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/sites/route.ts b/src/app/api/sites/route.ts new file mode 100644 index 0000000..721246d --- /dev/null +++ b/src/app/api/sites/route.ts @@ -0,0 +1,72 @@ +import { NextResponse } from "next/server"; +import { pbAdmin } from "@/lib/pb"; + +/** + * POST /api/sites + * + * Creates a new site row. Required: project_code (unique). The rest + * is optional and can be backfilled later via PATCH /api/sites/[id]. + */ +export async function POST(request: Request) { + let body: Record; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Body must be JSON" }, { status: 400 }); + } + + const projectCode = typeof body.project_code === "string" ? body.project_code.trim() : ""; + if (!projectCode) { + return NextResponse.json( + { error: "project_code is required" }, + { status: 400 }, + ); + } + if (projectCode.length > 64) { + return NextResponse.json( + { error: "project_code must be 64 characters or fewer" }, + { status: 400 }, + ); + } + + const payload: Record = { project_code: projectCode }; + for (const key of [ + "name", + "duration", + "contract_value", + "work_hours", + "hours_per_week", + "wages_hours_per_day", + "job_start_date", + ]) { + if (body[key] !== undefined && body[key] !== null && body[key] !== "") { + payload[key] = body[key]; + } + } + + for (const key of [ + "contract_value", + "work_hours", + "hours_per_week", + "wages_hours_per_day", + ]) { + if (typeof payload[key] === "string" && payload[key] === "") { + delete payload[key]; + } + } + + const pb = await pbAdmin(); + try { + const created = await pb.collection("sites").create(payload); + return NextResponse.json({ site: created }, { status: 201 }); + } catch (err) { + const msg = err instanceof Error ? err.message : "Insert failed"; + if (/UNIQUE|duplicate|idx_sites_project_code/i.test(msg)) { + return NextResponse.json( + { error: `project_code "${projectCode}" is already in use` }, + { status: 409 }, + ); + } + return NextResponse.json({ error: msg }, { status: 500 }); + } +} diff --git a/src/app/dashboard/sites/[id]/page.tsx b/src/app/dashboard/sites/[id]/page.tsx index 7c3d0a0..e84e60b 100644 --- a/src/app/dashboard/sites/[id]/page.tsx +++ b/src/app/dashboard/sites/[id]/page.tsx @@ -4,6 +4,7 @@ import { ArrowLeft } from "lucide-react"; import { pbAdmin } from "@/lib/pb"; import { Topbar } from "@/components/layout/sidebar"; import { Button } from "@/components/ui/button"; +import { EditSiteButton } from "@/components/site-form-dialog"; import { SiteDetailView, type SiteDetailData, @@ -226,12 +227,27 @@ export default async function SiteDetailPage({ title={site.name} subtitle={`${site.project_code} · ${site.duration || "—"}`} right={ - - - +
+ + + + +
} /> @@ -423,4 +439,4 @@ function buildDailyFuelCost( litres: b.litres, cost: b.cost, })); -} \ No newline at end of file +} diff --git a/src/app/dashboard/sites/page.tsx b/src/app/dashboard/sites/page.tsx index a876caf..3a8f755 100644 --- a/src/app/dashboard/sites/page.tsx +++ b/src/app/dashboard/sites/page.tsx @@ -2,6 +2,10 @@ import Link from "next/link"; import { pbAdmin } from "@/lib/pb"; import { Topbar } from "@/components/layout/sidebar"; import { SitesView, type SiteSummary } from "./sites-view"; +import { + AddSiteButton, + type SiteTemplateOption, +} from "@/components/site-form-dialog"; export const dynamic = "force-dynamic"; @@ -81,16 +85,25 @@ export default async function SitesPage() { const burnPct = contract > 0 ? (spent / contract) * 100 : 0; const months = parseDurationMonths(s.duration); - const startDate = lastFixedDateBySite.get(s.id) ?? new Date().toISOString(); + // Prefer the explicit job_start_date if set; otherwise fall back to + // the date of the latest fixed-cost entry (a noisy proxy, but better + // than nothing while sites are being migrated to use job_start_date). + const explicitStart = s.job_start_date?.trim(); + const fallbackStart = lastFixedDateBySite.get(s.id); + const startDate = explicitStart || fallbackStart || new Date().toISOString(); const startMs = new Date(startDate).getTime(); - const elapsedMs = Math.max(0, Date.now() - startMs); + const todayMs = Date.now(); + const elapsedMs = Math.max(0, todayMs - startMs); const elapsedMonths = Math.min(months, elapsedMs / (1000 * 60 * 60 * 24 * 30.4375)); const elapsedPct = months > 0 ? (elapsedMonths / months) * 100 : 0; const remainingMonths = Math.max(0, months - elapsedMonths); const projectedTotal = elapsedMonths > 0 ? spent * (months / elapsedMonths) : 0; const projectedProfit = contract - projectedTotal; + const isUpcoming = elapsedMs <= 0 && !!explicitStart; const status: SiteSummary["status"] = - elapsedPct === 0 + isUpcoming + ? "upcoming" + : elapsedPct === 0 ? "just-started" : burnPct < elapsedPct * 0.95 ? "under" @@ -118,14 +131,31 @@ export default async function SitesPage() { projectedTotal, projectedProfit, status, + jobStartDate: startDate, + hasExplicitStart: !!explicitStart, }; }); + // Templates for the "Add site" picker — every existing site is a clone + // candidate. We ship the richer fields too so the form can prefill them. + const templates: SiteTemplateOption[] = sites.map((s) => ({ + id: s.id, + project_code: s.project_code, + name: s.name, + duration: s.duration, + contract_value: s.contract_value ?? null, + work_hours: s.work_hours ?? null, + hours_per_week: s.hours_per_week ?? null, + wages_hours_per_day: s.wages_hours_per_day ?? null, + job_start_date: s.job_start_date ?? null, + })); + return ( <> } /> @@ -135,4 +165,4 @@ export default async function SitesPage() { function parseDurationMonths(s: string): number { const m = s?.match(/(\d+(?:\.\d+)?)/); return m ? Number(m[1]) : 0; -} \ No newline at end of file +} diff --git a/src/app/dashboard/sites/sites-view.tsx b/src/app/dashboard/sites/sites-view.tsx index 2f4d673..34833e9 100644 --- a/src/app/dashboard/sites/sites-view.tsx +++ b/src/app/dashboard/sites/sites-view.tsx @@ -2,23 +2,21 @@ import * as React from "react"; import Link from "next/link"; -import { format } from "date-fns"; +import { useRouter } from "next/navigation"; +import { format, parseISO } from "date-fns"; import { - Banknote, Building2, Calendar, - ChevronRight, - Fuel, - Package, - Receipt, - Truck, - Users, + Clock, } from "lucide-react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { cn, formatCurrency, formatNumber } from "@/lib/utils"; export type SiteStatus = "just-started" | "under" | "on-track" | "over"; +// "upcoming" is a newer status for sites whose job_start_date is in the +// future. Kept as a separate literal so older cards still typecheck. +export type SiteStatusWithUpcoming = SiteStatus | "upcoming"; export interface SiteSummary { id: string; @@ -27,10 +25,6 @@ export interface SiteSummary { duration: string; contractValue: number; spent: number; - fixed: number; - payroll: number; - material: number; - fuel: number; remaining: number; burnPct: number; months: number; @@ -39,13 +33,22 @@ export interface SiteSummary { remainingMonths: number; projectedTotal: number; projectedProfit: number; - status: SiteStatus; + status: SiteStatusWithUpcoming; + /** ISO date string of the start date actually used (explicit or proxy). */ + jobStartDate: string; + /** True when an explicit job_start_date is set on the record. */ + hasExplicitStart: boolean; } const STATUS_COPY: Record< - SiteStatus, + SiteStatusWithUpcoming, { label: string; tone: string; description: string } > = { + upcoming: { + label: "Upcoming", + tone: "bg-blue-500/15 text-blue-700 dark:text-blue-400 border-blue-500/30", + description: "Project has not started yet", + }, "just-started": { label: "Just started", tone: "bg-muted text-muted-foreground border-border", @@ -77,7 +80,8 @@ export function SitesView({ sites }: { sites: SiteSummary[] }) {

No sites yet

- Sites appear here once they’re added. + Click Add site in the top + right to create the first one.

@@ -94,122 +98,149 @@ export function SitesView({ sites }: { sites: SiteSummary[] }) { function SiteCard({ site }: { site: SiteSummary }) { const status = STATUS_COPY[site.status]; + const router = useRouter(); + + // Whole card is clickable to open the detail page, but clicks on + // interactive children (the site-name link, the Edit button, the + // chevron) handle themselves instead of triggering navigation. + function handleCardClick(e: React.MouseEvent) { + const target = e.target as HTMLElement; + if (target.closest("button, a")) return; + router.push(`/dashboard/sites/${site.id}`); + } + return ( - { + if (e.key === "Enter") { + e.preventDefault(); + router.push(`/dashboard/sites/${site.id}`); + } + }} + className="relative cursor-pointer overflow-hidden border-border/60 transition-colors hover:border-[#F2B165]/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#F2B165]" > - - - -
- + )} + /> + +
+ + {site.name} - -
- - {site.projectCode} - - - {site.duration} - -
-
-
- - {status.label} + + +
+ + {site.projectCode} - -
- - - {/* Contract + burn */} -
- - - -
- - {/* Burn bar */} -
-
- Burn vs contract - {site.burnPct.toFixed(1)}% -
-
-
-
-
- - {/* Timeline */} -
-
- Timeline - - {formatNumber(site.elapsedMonths, { maximumFractionDigits: 1 })} / {site.months} mo - -
-
-
-
-
- - {/* Spend breakdown */} -
- - - - -
- - {/* Profit forecast */} -
- Projected profit @ completion + + {site.duration || "—"} + = 0 - ? "text-emerald-700 dark:text-emerald-400" - : "text-red-700 dark:text-red-400", + "inline-flex items-center gap-1", + !site.hasExplicitStart && "italic text-amber-700 dark:text-amber-400", )} + title={ + site.hasExplicitStart + ? `Job start: ${site.jobStartDate}` + : `No job start set — using most recent fixed-cost date (${site.jobStartDate})` + } > - {formatCurrency(site.projectedProfit)} + + Start: {format(parseISO(site.jobStartDate), "yyyy-MM-dd")} + {!site.hasExplicitStart && ( + (inferred) + )}
- - - +
+
+ + {status.label} + +
+ + + {/* Contract + burn */} +
+ + + +
+ + {/* Combined burn + timeline bar. Burn fills the background; a + vertical tick marks where elapsed time should be. */} +
+
+ Burn vs time + + {site.burnPct.toFixed(0)}% spent · {site.elapsedPct.toFixed(0)}% elapsed + +
+
+
+ {/* Elapsed-time tick */} +
+
+
+ + {/* Profit forecast */} +
+ Projected profit @ completion + = 0 + ? "text-emerald-700 dark:text-emerald-400" + : "text-red-700 dark:text-red-400", + )} + > + {formatCurrency(site.projectedProfit)} + +
+ + ); } @@ -236,22 +267,3 @@ function Metric({
); } - -function MiniMetric({ - icon: Icon, - label, - value, -}: { - icon: React.ComponentType<{ className?: string }>; - label: string; - value: React.ReactNode; -}) { - return ( -
-

- {label} -

-

{value}

-
- ); -} \ No newline at end of file diff --git a/src/components/site-form-dialog.tsx b/src/components/site-form-dialog.tsx new file mode 100644 index 0000000..506ef25 --- /dev/null +++ b/src/components/site-form-dialog.tsx @@ -0,0 +1,443 @@ +"use client"; + +import * as React from "react"; +import { useRouter } from "next/navigation"; +import { Loader2, Plus } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { cn } from "@/lib/utils"; + +export interface SiteFormInitial { + id?: string; + project_code?: string; + name?: string; + duration?: string; + contract_value?: number | null; + work_hours?: number | null; + hours_per_week?: number | null; + wages_hours_per_day?: number | null; + job_start_date?: string | null; +} + +export interface SiteTemplateOption { + id: string; + project_code: string; + name: string; + duration?: string; + contract_value?: number | null; + work_hours?: number | null; + hours_per_week?: number | null; + wages_hours_per_day?: number | null; + job_start_date?: string | null; +} + +export function AddSiteButton({ + templates, +}: { + templates: SiteTemplateOption[]; +}) { + return ( + + + Add site + + } + /> + ); +} + +export function EditSiteButton({ site }: { site: SiteFormInitial }) { + return ( + + Edit + + } + /> + ); +} + +type Mode = "create" | "edit"; + +function SiteFormDialog({ + mode, + initial, + templates, + trigger, +}: { + mode: Mode; + initial?: SiteFormInitial; + templates?: SiteTemplateOption[]; + trigger: React.ReactNode; +}) { + const router = useRouter(); + const [open, setOpen] = React.useState(false); + const [submitting, setSubmitting] = React.useState(false); + const [error, setError] = React.useState(null); + const [templateId, setTemplateId] = React.useState("blank"); + + // Form state — strings everywhere so inputs stay editable; we coerce on submit. + const [form, setForm] = React.useState(() => initialValues(initial)); + + // When opening for a fresh "create", offer a template picker. Resetting + // the form when the template changes makes the cloning explicit. + React.useEffect(() => { + if (!open || mode !== "create") return; + if (templateId === "blank") { + setForm(initialValues(undefined)); + return; + } + const tpl = templates?.find((t) => t.id === templateId); + if (!tpl) return; + // Templates pull every field except project_code + name (those should + // be entered explicitly so we don't get "P-217 — The Hills 2"). + setForm((f) => ({ + ...f, + duration: tpl.duration ?? f.duration, + contract_value: + tpl.contract_value != null ? String(tpl.contract_value) : f.contract_value, + work_hours: tpl.work_hours != null ? String(tpl.work_hours) : f.work_hours, + hours_per_week: + tpl.hours_per_week != null ? String(tpl.hours_per_week) : f.hours_per_week, + wages_hours_per_day: + tpl.wages_hours_per_day != null + ? String(tpl.wages_hours_per_day) + : f.wages_hours_per_day, + job_start_date: tpl.job_start_date ?? f.job_start_date, + })); + }, [templateId, open, mode, templates]); + + function setField(key: K, value: (typeof form)[K]) { + setForm((f) => ({ ...f, [key]: value })); + } + + async function submit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + + const projectCode = form.project_code.trim(); + if (mode === "create" && !projectCode) { + setError("Project code is required"); + return; + } + if (mode === "create" && projectCode.length > 64) { + setError("Project code must be 64 characters or fewer"); + return; + } + if (!form.name.trim()) { + setError("Site name is required"); + return; + } + + setSubmitting(true); + try { + let res: Response; + if (mode === "create") { + res = await fetch("/api/sites", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + project_code: projectCode, + name: form.name.trim(), + duration: form.duration.trim() || undefined, + job_start_date: form.job_start_date || undefined, + contract_value: numOrUndef(form.contract_value), + work_hours: numOrUndef(form.work_hours), + hours_per_week: numOrUndef(form.hours_per_week), + wages_hours_per_day: numOrUndef(form.wages_hours_per_day), + }), + }); + } else { + res = await fetch(`/api/sites/${initial?.id}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: form.name.trim(), + duration: form.duration.trim() || null, + job_start_date: form.job_start_date || null, + contract_value: numOrUndef(form.contract_value), + work_hours: numOrUndef(form.work_hours), + hours_per_week: numOrUndef(form.hours_per_week), + wages_hours_per_day: numOrUndef(form.wages_hours_per_day), + }), + }); + } + + const data = await res.json(); + if (!res.ok) { + setError(data.error ?? "Save failed"); + return; + } + setOpen(false); + router.refresh(); + } catch (e) { + setError(e instanceof Error ? e.message : "Save failed"); + } finally { + setSubmitting(false); + } + } + + const heading = mode === "create" ? "Add a site" : `Edit ${initial?.name ?? "site"}`; + const sub = + mode === "create" + ? "Pick a template to clone the working-hours defaults, or start blank." + : "Update site details. Project code is permanent once a site exists."; + + return ( + { + setOpen(o); + if (o) { + setError(null); + setForm(initialValues(initial)); + setTemplateId("blank"); + } + }} + > + {trigger} + + + {heading} + {sub} + + +
+ {mode === "create" && (templates?.length ?? 0) > 0 && ( +
+ + +

+ Cloning copies duration, contract value, work hours, and the + start date. Project code and name stay blank so you can enter + fresh ones. +

+
+ )} + +
+ + setField("project_code", e.target.value)} + disabled={mode === "edit"} + placeholder="e.g. P-217" + className="font-mono" + maxLength={64} + /> + + + + setField("name", e.target.value)} + placeholder="e.g. The Hills" + /> + + + + setField("duration", e.target.value)} + placeholder="e.g. 12 months" + /> + + + + setField("job_start_date", e.target.value)} + className={cn(mode === "edit" && "font-mono")} + /> + + + + setField("contract_value", e.target.value)} + placeholder="0.00" + /> + + + + setField("work_hours", e.target.value)} + placeholder="0" + /> + + + + setField("hours_per_week", e.target.value)} + placeholder="45" + /> + + + + setField("wages_hours_per_day", e.target.value)} + placeholder="9" + /> + +
+ + {error && ( +
+ {error} +
+ )} + + + + + +
+
+
+ ); +} + +function Field({ + label, + hint, + children, +}: { + label: string; + hint?: string; + children: React.ReactNode; +}) { + return ( +
+
+ + {hint && ( + + {hint} + + )} +
+ {children} +
+ ); +} + +interface FormState { + project_code: string; + name: string; + duration: string; + job_start_date: string; + contract_value: string; + work_hours: string; + hours_per_week: string; + wages_hours_per_day: string; +} + +function initialValues(initial?: SiteFormInitial): FormState { + return { + project_code: initial?.project_code ?? "", + name: initial?.name ?? "", + duration: initial?.duration ?? "", + job_start_date: initial?.job_start_date ?? "", + contract_value: initial?.contract_value != null ? String(initial.contract_value) : "", + work_hours: initial?.work_hours != null ? String(initial.work_hours) : "", + hours_per_week: initial?.hours_per_week != null ? String(initial.hours_per_week) : "", + wages_hours_per_day: + initial?.wages_hours_per_day != null ? String(initial.wages_hours_per_day) : "", + }; +} + +function numOrUndef(s: string): number | undefined { + if (s === "" || s === null || s === undefined) return undefined; + const n = Number(s); + return Number.isFinite(n) ? n : undefined; +} diff --git a/src/components/ui/label.tsx b/src/components/ui/label.tsx new file mode 100644 index 0000000..7e93689 --- /dev/null +++ b/src/components/ui/label.tsx @@ -0,0 +1,22 @@ +"use client"; + +import * as React from "react"; +import * as LabelPrimitive from "@radix-ui/react-label"; +import { cn } from "@/lib/utils"; + +const Label = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Label.displayName = LabelPrimitive.Root.displayName; + +export { Label }; diff --git a/src/lib/pb-types.ts b/src/lib/pb-types.ts index f004e5a..9a5d322 100644 --- a/src/lib/pb-types.ts +++ b/src/lib/pb-types.ts @@ -14,6 +14,8 @@ export interface SiteRecord extends RecordModel { work_hours: number; hours_per_week: number; wages_hours_per_day: number; + /** ISO date (YYYY-MM-DD) the project started/starts. Optional. */ + job_start_date?: string; expand?: Record; }