439 lines
16 KiB
TypeScript
439 lines
16 KiB
TypeScript
"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>
|
|
);
|
|
}
|