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:
parent
1b9779f47e
commit
472be99fd8
9 changed files with 813 additions and 150 deletions
|
|
@ -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`)",
|
||||
|
|
|
|||
65
src/app/api/sites/[id]/route.ts
Normal file
65
src/app/api/sites/[id]/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
72
src/app/api/sites/route.ts
Normal file
72
src/app/api/sites/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
443
src/components/site-form-dialog.tsx
Normal file
443
src/components/site-form-dialog.tsx
Normal 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;
|
||||
}
|
||||
22
src/components/ui/label.tsx
Normal file
22
src/components/ui/label.tsx
Normal 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 };
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue