chore(repo): initialize planarchy workspace

This commit is contained in:
2026-03-14 14:31:09 +01:00
commit dd55d0e78b
769 changed files with 166461 additions and 0 deletions
@@ -0,0 +1,438 @@
"use client";
import { useMemo, useState } from "react";
import { clsx } from "clsx";
import { trpc } from "~/lib/trpc/client.js";
interface WeeklyPhasingViewProps {
estimateId: string;
canEdit: boolean;
}
type ViewMode = "by_line" | "by_chapter";
type PhasingPattern = "even" | "front_loaded" | "back_loaded";
function getDefaultDateRange(): { start: string; end: string } {
const now = new Date();
const start = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-01`;
const endDate = new Date(now.getFullYear(), now.getMonth() + 6, 0);
const end = `${endDate.getFullYear()}-${String(endDate.getMonth() + 1).padStart(2, "0")}-${String(endDate.getDate()).padStart(2, "0")}`;
return { start, end };
}
function heatColor(hours: number, maxHours: number): string {
if (hours === 0 || maxHours === 0) return "";
const ratio = Math.min(hours / maxHours, 1);
if (ratio < 0.25) return "bg-blue-50";
if (ratio < 0.5) return "bg-blue-100";
if (ratio < 0.75) return "bg-blue-200";
return "bg-blue-300";
}
export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProps) {
const defaults = getDefaultDateRange();
const [startDate, setStartDate] = useState(defaults.start);
const [endDate, setEndDate] = useState(defaults.end);
const [pattern, setPattern] = useState<PhasingPattern>("even");
const [viewMode, setViewMode] = useState<ViewMode>("by_line");
const utils = trpc.useUtils();
const phasingQuery = trpc.estimate.getWeeklyPhasing.useQuery(
{ estimateId },
{ staleTime: 30_000 },
);
const generateMutation = trpc.estimate.generateWeeklyPhasing.useMutation({
onSuccess: () => {
void utils.estimate.getWeeklyPhasing.invalidate({ estimateId });
void utils.estimate.getById.invalidate({ id: estimateId });
},
});
const data = phasingQuery.data;
// Compute max hours for heat-map coloring
const maxHours = useMemo(() => {
if (!data?.hasPhasing) return 0;
let max = 0;
for (const line of data.lines) {
for (const h of Object.values(line.weeklyHours)) {
if (h > max) max = h;
}
}
return max;
}, [data]);
// Compute column totals
const columnTotals = useMemo(() => {
if (!data?.hasPhasing) return {};
const totals: Record<string, number> = {};
for (const line of data.lines) {
for (const [weekKey, hours] of Object.entries(line.weeklyHours)) {
totals[weekKey] = Math.round(((totals[weekKey] ?? 0) + hours) * 100) / 100;
}
}
return totals;
}, [data]);
// Compute chapter column totals
const chapterColumnTotals = useMemo(() => {
if (!data?.hasPhasing) return {};
const totals: Record<string, number> = {};
for (const chapterHours of Object.values(data.chapterAggregation)) {
for (const [weekKey, hours] of Object.entries(chapterHours)) {
totals[weekKey] = Math.round(((totals[weekKey] ?? 0) + hours) * 100) / 100;
}
}
return totals;
}, [data]);
// Compute max hours for chapter view
const maxChapterHours = useMemo(() => {
if (!data?.hasPhasing) return 0;
let max = 0;
for (const chapterHours of Object.values(data.chapterAggregation)) {
for (const h of Object.values(chapterHours)) {
if (h > max) max = h;
}
}
return max;
}, [data]);
const handleGenerate = () => {
generateMutation.mutate({
estimateId,
startDate,
endDate,
pattern,
});
};
// Use config dates from existing phasing if available
const effectiveStart = data?.hasPhasing ? data.config.startDate : startDate;
const effectiveEnd = data?.hasPhasing ? data.config.endDate : endDate;
return (
<div className="space-y-6">
{/* Header / Controls */}
<div className="rounded-3xl border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold text-gray-900">
Weekly Phasing (4Dispo)
</h3>
{canEdit && (
<div className="flex flex-wrap items-end gap-4">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
Start Date
</label>
<input
type="date"
value={data?.hasPhasing ? effectiveStart : startDate}
onChange={(e) => setStartDate(e.target.value)}
className="rounded-lg border border-gray-300 px-3 py-2 text-sm"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
End Date
</label>
<input
type="date"
value={data?.hasPhasing ? effectiveEnd : endDate}
onChange={(e) => setEndDate(e.target.value)}
className="rounded-lg border border-gray-300 px-3 py-2 text-sm"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
Pattern
</label>
<select
value={pattern}
onChange={(e) => setPattern(e.target.value as PhasingPattern)}
className="rounded-lg border border-gray-300 px-3 py-2 text-sm"
>
<option value="even">Even Distribution</option>
<option value="front_loaded">Front Loaded (60/40)</option>
<option value="back_loaded">Back Loaded (40/60)</option>
</select>
</div>
<button
type="button"
onClick={handleGenerate}
disabled={generateMutation.isPending}
className={clsx(
"rounded-lg px-4 py-2 text-sm font-medium text-white",
generateMutation.isPending
? "cursor-not-allowed bg-gray-400"
: "bg-sky-600 hover:bg-sky-700",
)}
>
{generateMutation.isPending ? "Generating..." : "Generate Phasing"}
</button>
</div>
)}
{generateMutation.isError && (
<p className="mt-2 text-sm text-red-600">
{generateMutation.error.message}
</p>
)}
{generateMutation.isSuccess && (
<p className="mt-2 text-sm text-emerald-600">
Phasing generated for {generateMutation.data.linesUpdated} demand
lines.
</p>
)}
</div>
{/* View toggle */}
{data?.hasPhasing && (
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setViewMode("by_line")}
className={clsx(
"rounded-lg px-3 py-1.5 text-sm font-medium",
viewMode === "by_line"
? "bg-sky-100 text-sky-700"
: "bg-gray-100 text-gray-600 hover:bg-gray-200",
)}
>
By Line
</button>
<button
type="button"
onClick={() => setViewMode("by_chapter")}
className={clsx(
"rounded-lg px-3 py-1.5 text-sm font-medium",
viewMode === "by_chapter"
? "bg-sky-100 text-sky-700"
: "bg-gray-100 text-gray-600 hover:bg-gray-200",
)}
>
By Chapter
</button>
</div>
)}
{/* Phasing Grid */}
{phasingQuery.isLoading && (
<div className="rounded-3xl border border-gray-200 bg-white p-8 text-center text-gray-500">
Loading phasing data...
</div>
)}
{data && !data.hasPhasing && (
<div className="rounded-3xl border border-gray-200 bg-white p-8 text-center text-gray-500">
No weekly phasing generated yet. Use the controls above to generate a
phasing distribution.
</div>
)}
{data?.hasPhasing && viewMode === "by_line" && (
<div className="rounded-3xl border border-gray-200 bg-white overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 bg-gray-50">
<th className="sticky left-0 z-10 bg-gray-50 px-4 py-3 text-left font-medium text-gray-700 min-w-[200px]">
Demand Line
</th>
{data.weeks.map((week) => {
const key = `${week.year}-W${String(week.weekNumber).padStart(2, "0")}`;
return (
<th
key={key}
className="px-3 py-3 text-right font-medium text-gray-600 min-w-[80px] whitespace-nowrap"
>
{week.label}
</th>
);
})}
<th className="sticky right-0 z-10 bg-gray-50 px-4 py-3 text-right font-semibold text-gray-700 min-w-[90px]">
Total
</th>
</tr>
</thead>
<tbody>
{data.lines.map((line) => {
const lineTotal = Object.values(line.weeklyHours).reduce(
(sum, h) => sum + h,
0,
);
return (
<tr
key={line.id}
className="border-b border-gray-100 hover:bg-gray-50/50"
>
<td className="sticky left-0 z-10 bg-white px-4 py-2 font-medium text-gray-900">
<div className="truncate max-w-[200px]" title={line.name}>
{line.name}
</div>
{line.chapter && (
<div className="text-xs text-gray-500">
{line.chapter}
</div>
)}
</td>
{data.weeks.map((week) => {
const key = `${week.year}-W${String(week.weekNumber).padStart(2, "0")}`;
const hours = line.weeklyHours[key] ?? 0;
return (
<td
key={key}
className={clsx(
"px-3 py-2 text-right tabular-nums",
heatColor(hours, maxHours),
)}
>
{hours > 0 ? hours.toFixed(1) : "-"}
</td>
);
})}
<td className="sticky right-0 z-10 bg-white px-4 py-2 text-right font-semibold tabular-nums text-gray-900">
{lineTotal.toFixed(1)}
</td>
</tr>
);
})}
</tbody>
<tfoot>
<tr className="border-t-2 border-gray-300 bg-gray-50 font-semibold">
<td className="sticky left-0 z-10 bg-gray-50 px-4 py-3 text-gray-700">
Total
</td>
{data.weeks.map((week) => {
const key = `${week.year}-W${String(week.weekNumber).padStart(2, "0")}`;
const total = columnTotals[key] ?? 0;
return (
<td
key={key}
className="px-3 py-3 text-right tabular-nums text-gray-900"
>
{total > 0 ? total.toFixed(1) : "-"}
</td>
);
})}
<td className="sticky right-0 z-10 bg-gray-50 px-4 py-3 text-right tabular-nums text-gray-900">
{Object.values(columnTotals)
.reduce((sum, h) => sum + h, 0)
.toFixed(1)}
</td>
</tr>
</tfoot>
</table>
</div>
</div>
)}
{data?.hasPhasing && viewMode === "by_chapter" && (
<div className="rounded-3xl border border-gray-200 bg-white overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 bg-gray-50">
<th className="sticky left-0 z-10 bg-gray-50 px-4 py-3 text-left font-medium text-gray-700 min-w-[200px]">
Chapter
</th>
{data.weeks.map((week) => {
const key = `${week.year}-W${String(week.weekNumber).padStart(2, "0")}`;
return (
<th
key={key}
className="px-3 py-3 text-right font-medium text-gray-600 min-w-[80px] whitespace-nowrap"
>
{week.label}
</th>
);
})}
<th className="sticky right-0 z-10 bg-gray-50 px-4 py-3 text-right font-semibold text-gray-700 min-w-[90px]">
Total
</th>
</tr>
</thead>
<tbody>
{Object.entries(data.chapterAggregation)
.sort(([a], [b]) => a.localeCompare(b))
.map(([chapter, weeklyHours]) => {
const chapterTotal = Object.values(weeklyHours).reduce(
(sum, h) => sum + h,
0,
);
return (
<tr
key={chapter}
className="border-b border-gray-100 hover:bg-gray-50/50"
>
<td className="sticky left-0 z-10 bg-white px-4 py-2 font-medium text-gray-900">
{chapter}
</td>
{data.weeks.map((week) => {
const key = `${week.year}-W${String(week.weekNumber).padStart(2, "0")}`;
const hours = weeklyHours[key] ?? 0;
return (
<td
key={key}
className={clsx(
"px-3 py-2 text-right tabular-nums",
heatColor(hours, maxChapterHours),
)}
>
{hours > 0 ? hours.toFixed(1) : "-"}
</td>
);
})}
<td className="sticky right-0 z-10 bg-white px-4 py-2 text-right font-semibold tabular-nums text-gray-900">
{chapterTotal.toFixed(1)}
</td>
</tr>
);
})}
</tbody>
<tfoot>
<tr className="border-t-2 border-gray-300 bg-gray-50 font-semibold">
<td className="sticky left-0 z-10 bg-gray-50 px-4 py-3 text-gray-700">
Total
</td>
{data.weeks.map((week) => {
const key = `${week.year}-W${String(week.weekNumber).padStart(2, "0")}`;
const total = chapterColumnTotals[key] ?? 0;
return (
<td
key={key}
className="px-3 py-3 text-right tabular-nums text-gray-900"
>
{total > 0 ? total.toFixed(1) : "-"}
</td>
);
})}
<td className="sticky right-0 z-10 bg-gray-50 px-4 py-3 text-right tabular-nums text-gray-900">
{Object.values(chapterColumnTotals)
.reduce((sum, h) => sum + h, 0)
.toFixed(1)}
</td>
</tr>
</tfoot>
</table>
</div>
</div>
)}
{/* Info about current phasing config */}
{data?.hasPhasing && data.config && (
<div className="rounded-3xl border border-gray-200 bg-white p-4">
<p className="text-sm text-gray-600">
<span className="font-medium">Current phasing:</span>{" "}
{data.config.pattern.replace("_", " ")} distribution from{" "}
{data.config.startDate} to {data.config.endDate} across{" "}
{data.weeks.length} weeks, {data.lines.length} demand lines.
</p>
</div>
)}
</div>
);
}