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.
This commit is contained in:
DEls 2026-08-25 19:01:56 +02:00
parent 1b9779f47e
commit 472be99fd8
9 changed files with 813 additions and 150 deletions

View file

@ -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`)",

View file

@ -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<string, unknown>;
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<string, unknown> = {};
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 },
);
}
}

View file

@ -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<string, unknown>;
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<string, unknown> = { 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 });
}
}

View file

@ -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={
<Link href="/dashboard/sites">
<Button variant="outline" className="gap-2">
<ArrowLeft className="h-4 w-4" />
All sites
</Button>
</Link>
<div className="flex items-center gap-2">
<EditSiteButton
site={{
id: site.id,
project_code: site.project_code,
name: site.name,
duration: site.duration,
contract_value: site.contract_value,
work_hours: site.work_hours ?? null,
hours_per_week: site.hours_per_week ?? null,
wages_hours_per_day: site.wages_hours_per_day ?? null,
job_start_date: site.job_start_date ?? null,
}}
/>
<Link href="/dashboard/sites">
<Button variant="outline" className="gap-2">
<ArrowLeft className="h-4 w-4" />
All sites
</Button>
</Link>
</div>
}
/>
<SiteDetailView data={data} />
@ -423,4 +439,4 @@ function buildDailyFuelCost(
litres: b.litres,
cost: b.cost,
}));
}
}

View file

@ -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 (
<>
<Topbar
title="Sites"
subtitle="One page per contract — profitability, burn, and P&G."
right={<AddSiteButton templates={templates} />}
/>
<SitesView sites={summaries} />
</>
@ -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;
}
}

View file

@ -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[] }) {
<Building2 className="h-10 w-10 text-muted-foreground/40" />
<p className="text-sm font-medium">No sites yet</p>
<p className="text-xs text-muted-foreground">
Sites appear here once they’re added.
Click <span className="font-medium">Add site</span> in the top
right to create the first one.
</p>
</CardContent>
</Card>
@ -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<HTMLDivElement>) {
const target = e.target as HTMLElement;
if (target.closest("button, a")) return;
router.push(`/dashboard/sites/${site.id}`);
}
return (
<Link
href={`/dashboard/sites/${site.id}`}
className="group block"
<Card
onClick={handleCardClick}
role="link"
tabIndex={0}
onKeyDown={(e) => {
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]"
>
<Card className="relative overflow-hidden border-border/60 transition-colors hover:border-[#F2B165]/50">
<span
aria-hidden
className={cn(
"absolute inset-y-0 left-0 w-1 bg-gradient-to-b",
site.status === "over"
? "from-red-500 to-amber-500"
: site.status === "under"
? "from-emerald-500 to-emerald-300"
<span
aria-hidden
className={cn(
"absolute inset-y-0 left-0 w-1 bg-gradient-to-b",
site.status === "over"
? "from-red-500 to-amber-500"
: site.status === "under"
? "from-emerald-500 to-emerald-300"
: site.status === "upcoming"
? "from-blue-500 to-blue-300"
: "from-[#F2B165] to-[#E08A3C]",
)}
/>
<CardHeader className="flex flex-row items-start justify-between gap-3 pb-3 pl-5">
<div className="min-w-0">
<CardTitle className="text-lg font-semibold tracking-tight">
)}
/>
<CardHeader className="flex flex-row items-start justify-between gap-3 pb-3 pl-5">
<div className="min-w-0 flex-1">
<CardTitle className="text-lg font-semibold tracking-tight">
<Link
href={`/dashboard/sites/${site.id}`}
className="hover:text-[#F2B165]"
>
{site.name}
</CardTitle>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<Badge variant="outline" className="font-mono text-xs">
{site.projectCode}
</Badge>
<span className="inline-flex items-center gap-1">
<Calendar className="h-3 w-3" /> {site.duration}
</span>
</div>
</div>
<div className="flex flex-col items-end gap-1">
<Badge variant="outline" className={cn("gap-1.5", status.tone)}>
{status.label}
</Link>
</CardTitle>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<Badge variant="outline" className="font-mono text-xs">
{site.projectCode}
</Badge>
<ChevronRight className="h-4 w-4 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
</div>
</CardHeader>
<CardContent className="space-y-4 pl-5">
{/* Contract + burn */}
<div className="grid grid-cols-3 gap-3">
<Metric label="Contract" value={formatCurrency(site.contractValue)} />
<Metric
label="Spent"
value={formatCurrency(site.spent)}
sub={`${site.burnPct.toFixed(1)}% of contract`}
/>
<Metric
label="Remaining"
value={formatCurrency(site.remaining)}
sub={`${(100 - site.burnPct).toFixed(1)}%`}
/>
</div>
{/* Burn bar */}
<div>
<div className="mb-1 flex items-center justify-between text-xs">
<span className="text-muted-foreground">Burn vs contract</span>
<span className="font-mono tabular-nums">{site.burnPct.toFixed(1)}%</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div
className={cn(
"h-full transition-all",
site.status === "over"
? "bg-red-500"
: site.status === "under"
? "bg-emerald-500"
: "bg-[#F2B165]",
)}
style={{ width: `${Math.min(100, site.burnPct)}%` }}
/>
</div>
</div>
{/* Timeline */}
<div>
<div className="mb-1 flex items-center justify-between text-xs">
<span className="text-muted-foreground">Timeline</span>
<span className="font-mono tabular-nums">
{formatNumber(site.elapsedMonths, { maximumFractionDigits: 1 })} / {site.months} mo
</span>
</div>
<div className="relative h-2 overflow-hidden rounded-full bg-muted">
<div
className="h-full bg-[#F2B165]/60 transition-all"
style={{ width: `${Math.min(100, site.elapsedPct)}%` }}
/>
</div>
</div>
{/* Spend breakdown */}
<div className="grid grid-cols-4 gap-2 border-t border-border pt-3 text-xs">
<MiniMetric icon={Receipt} label="P&G" value={formatCurrency(site.fixed)} />
<MiniMetric icon={Users} label="Payroll" value={formatCurrency(site.payroll)} />
<MiniMetric icon={Package} label="Material" value={formatCurrency(site.material)} />
<MiniMetric icon={Fuel} label="Fuel" value={formatCurrency(site.fuel)} />
</div>
{/* Profit forecast */}
<div className="flex items-center justify-between border-t border-border pt-3 text-xs">
<span className="text-muted-foreground">Projected profit @ completion</span>
<span className="inline-flex items-center gap-1">
<Calendar className="h-3 w-3" /> {site.duration || "—"}
</span>
<span
className={cn(
"font-semibold tabular-nums",
site.projectedProfit >= 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)}
<Clock className="h-3 w-3" />
Start: {format(parseISO(site.jobStartDate), "yyyy-MM-dd")}
{!site.hasExplicitStart && (
<span className="ml-0.5 text-[10px]">(inferred)</span>
)}
</span>
</div>
</CardContent>
</Card>
</Link>
</div>
<div className="flex shrink-0 flex-col items-end gap-1">
<Badge variant="outline" className={cn("gap-1.5", status.tone)}>
{status.label}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4 pl-5">
{/* Contract + burn */}
<div className="grid grid-cols-3 gap-3">
<Metric label="Contract" value={formatCurrency(site.contractValue)} />
<Metric
label="Spent"
value={formatCurrency(site.spent)}
sub={`${site.burnPct.toFixed(1)}% of contract`}
/>
<Metric
label="Remaining"
value={formatCurrency(site.remaining)}
sub={`${(100 - site.burnPct).toFixed(1)}%`}
/>
</div>
{/* Combined burn + timeline bar. Burn fills the background; a
vertical tick marks where elapsed time should be. */}
<div>
<div className="mb-1 flex items-center justify-between text-xs">
<span className="text-muted-foreground">Burn vs time</span>
<span className="font-mono tabular-nums">
{site.burnPct.toFixed(0)}% spent · {site.elapsedPct.toFixed(0)}% elapsed
</span>
</div>
<div className="relative h-2.5 overflow-hidden rounded-full bg-muted">
<div
className={cn(
"h-full transition-all",
site.status === "over"
? "bg-red-500"
: site.status === "under"
? "bg-emerald-500"
: site.status === "upcoming"
? "bg-blue-400/40"
: "bg-[#F2B165]",
)}
style={{ width: `${Math.min(100, site.burnPct)}%` }}
/>
{/* Elapsed-time tick */}
<div
aria-hidden
className="absolute top-0 h-full w-px bg-foreground/70"
style={{ left: `${Math.min(100, site.elapsedPct)}%` }}
/>
</div>
</div>
{/* Profit forecast */}
<div className="flex items-center justify-between border-t border-border pt-3 text-sm">
<span className="text-muted-foreground">Projected profit @ completion</span>
<span
className={cn(
"font-semibold tabular-nums",
site.projectedProfit >= 0
? "text-emerald-700 dark:text-emerald-400"
: "text-red-700 dark:text-red-400",
)}
>
{formatCurrency(site.projectedProfit)}
</span>
</div>
</CardContent>
</Card>
);
}
@ -236,22 +267,3 @@ function Metric({
</div>
);
}
function MiniMetric({
icon: Icon,
label,
value,
}: {
icon: React.ComponentType<{ className?: string }>;
label: string;
value: React.ReactNode;
}) {
return (
<div>
<p className="flex items-center gap-1 text-[10px] uppercase tracking-wider text-muted-foreground">
<Icon className="h-3 w-3" /> {label}
</p>
<p className="mt-0.5 text-xs font-semibold tabular-nums">{value}</p>
</div>
);
}

View file

@ -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 (
<SiteFormDialog
mode="create"
templates={templates}
trigger={
<Button className="gap-1.5">
<Plus className="h-4 w-4" />
Add site
</Button>
}
/>
);
}
export function EditSiteButton({ site }: { site: SiteFormInitial }) {
return (
<SiteFormDialog
mode="edit"
initial={site}
trigger={
<Button
type="button"
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground"
>
Edit
</Button>
}
/>
);
}
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<string | null>(null);
const [templateId, setTemplateId] = React.useState<string>("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<K extends keyof typeof form>(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 (
<Dialog
open={open}
onOpenChange={(o) => {
setOpen(o);
if (o) {
setError(null);
setForm(initialValues(initial));
setTemplateId("blank");
}
}}
>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle>{heading}</DialogTitle>
<DialogDescription>{sub}</DialogDescription>
</DialogHeader>
<form onSubmit={submit} className="space-y-4">
{mode === "create" && (templates?.length ?? 0) > 0 && (
<div className="rounded-md border border-border bg-muted/30 p-3">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">
Template
</Label>
<Select value={templateId} onValueChange={setTemplateId}>
<SelectTrigger className="mt-1 h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="blank">Start blank</SelectItem>
{(templates ?? []).map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.name} ({t.project_code})
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-1 text-xs text-muted-foreground">
Cloning copies duration, contract value, work hours, and the
start date. Project code and name stay blank so you can enter
fresh ones.
</p>
</div>
)}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Field
label="Project code"
hint={mode === "edit" ? "Locked once set" : "Required · max 64 chars"}
>
<Input
value={form.project_code}
onChange={(e) => setField("project_code", e.target.value)}
disabled={mode === "edit"}
placeholder="e.g. P-217"
className="font-mono"
maxLength={64}
/>
</Field>
<Field label="Site name" hint="Required">
<Input
value={form.name}
onChange={(e) => setField("name", e.target.value)}
placeholder="e.g. The Hills"
/>
</Field>
<Field label="Duration" hint="Free text">
<Input
value={form.duration}
onChange={(e) => setField("duration", e.target.value)}
placeholder="e.g. 12 months"
/>
</Field>
<Field
label="Job start date"
hint={
mode === "edit"
? "Clear the field to remove the start date"
: "Required for burn calculations"
}
>
<Input
type="date"
value={form.job_start_date}
onChange={(e) => setField("job_start_date", e.target.value)}
className={cn(mode === "edit" && "font-mono")}
/>
</Field>
<Field label="Contract value" hint="R (ZAR)">
<Input
type="number"
inputMode="decimal"
step="0.01"
min="0"
value={form.contract_value}
onChange={(e) => setField("contract_value", e.target.value)}
placeholder="0.00"
/>
</Field>
<Field label="Work hours" hint="Total per project">
<Input
type="number"
inputMode="numeric"
min="0"
value={form.work_hours}
onChange={(e) => setField("work_hours", e.target.value)}
placeholder="0"
/>
</Field>
<Field label="Hours per week">
<Input
type="number"
inputMode="numeric"
min="0"
value={form.hours_per_week}
onChange={(e) => setField("hours_per_week", e.target.value)}
placeholder="45"
/>
</Field>
<Field label="Wages hours / day">
<Input
type="number"
inputMode="numeric"
min="0"
step="0.5"
value={form.wages_hours_per_day}
onChange={(e) => setField("wages_hours_per_day", e.target.value)}
placeholder="9"
/>
</Field>
</div>
{error && (
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
<DialogFooter className="gap-2">
<Button
type="button"
variant="ghost"
onClick={() => setOpen(false)}
disabled={submitting}
>
Cancel
</Button>
<Button type="submit" disabled={submitting} className="gap-1.5">
{submitting ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Saving…
</>
) : mode === "create" ? (
"Create site"
) : (
"Save changes"
)}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
function Field({
label,
hint,
children,
}: {
label: string;
hint?: string;
children: React.ReactNode;
}) {
return (
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<Label className="text-xs font-medium text-muted-foreground">{label}</Label>
{hint && (
<span className="text-[10px] uppercase tracking-wider text-muted-foreground/70">
{hint}
</span>
)}
</div>
{children}
</div>
);
}
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;
}

View file

@ -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<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
className,
)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };

View file

@ -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<string, never>;
}