981 lines
49 KiB
TypeScript
981 lines
49 KiB
TypeScript
import type {
|
|
DemandPlanningRollupDto,
|
|
GanttTaskDto,
|
|
ManufacturingStationDto,
|
|
PlanningExceptionDto,
|
|
PlanningStationLoadDto,
|
|
PlanningTaskActionDto,
|
|
PlanningTimelineDto,
|
|
} from "@mrp/shared";
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import { Link, useNavigate } from "react-router-dom";
|
|
|
|
import { useAuth } from "../../auth/AuthProvider";
|
|
import { ApiError, api } from "../../lib/api";
|
|
type WorkbenchMode = "overview" | "heatmap" | "agenda";
|
|
type FocusRecord = {
|
|
id: string;
|
|
entityId: string | null;
|
|
title: string;
|
|
kind: "PROJECT" | "WORK_ORDER" | "OPERATION" | "MILESTONE";
|
|
status: string;
|
|
ownerLabel: string | null;
|
|
start: string;
|
|
end: string;
|
|
progress: number;
|
|
detailHref: string | null;
|
|
parentId: string | null;
|
|
projectId: string | null;
|
|
workOrderId: string | null;
|
|
salesOrderId: string | null;
|
|
itemId: string | null;
|
|
itemSku: string | null;
|
|
stationId: string | null;
|
|
stationCode: string | null;
|
|
readinessState: string;
|
|
readinessScore: number;
|
|
shortageItemCount: number;
|
|
totalShortageQuantity: number;
|
|
openSupplyQuantity: number;
|
|
releaseReady: boolean;
|
|
overdue: boolean;
|
|
blockedReason: string | null;
|
|
utilizationPercent: number | null;
|
|
loadMinutes: number;
|
|
actions: PlanningTaskActionDto[];
|
|
};
|
|
|
|
type HeatmapCell = {
|
|
dateKey: string;
|
|
count: number;
|
|
lateCount: number;
|
|
blockedCount: number;
|
|
tasks: FocusRecord[];
|
|
};
|
|
|
|
type WorkbenchGroup = "projects" | "stations" | "exceptions";
|
|
type WorkbenchFilter = "all" | "release-ready" | "blocked" | "shortage" | "overdue";
|
|
type DraggingOperation = {
|
|
id: string;
|
|
title: string;
|
|
stationId: string | null;
|
|
start: string;
|
|
loadMinutes: number;
|
|
};
|
|
|
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
|
|
function formatDate(value: string | null, options?: Intl.DateTimeFormatOptions) {
|
|
if (!value) {
|
|
return "Unscheduled";
|
|
}
|
|
|
|
return new Intl.DateTimeFormat("en-US", options ?? {
|
|
month: "short",
|
|
day: "numeric",
|
|
}).format(new Date(value));
|
|
}
|
|
|
|
function startOfDay(value: Date) {
|
|
return new Date(value.getFullYear(), value.getMonth(), value.getDate());
|
|
}
|
|
|
|
function dateKey(value: Date) {
|
|
return value.toISOString().slice(0, 10);
|
|
}
|
|
|
|
function parseFocusKind(task: GanttTaskDto): FocusRecord["kind"] {
|
|
if (task.type === "project") {
|
|
return "PROJECT";
|
|
}
|
|
if (task.type === "milestone") {
|
|
return "MILESTONE";
|
|
}
|
|
if (task.id.startsWith("work-order-operation-")) {
|
|
return "OPERATION";
|
|
}
|
|
return "WORK_ORDER";
|
|
}
|
|
|
|
function densityTone(cell: HeatmapCell) {
|
|
if (cell.lateCount > 0) {
|
|
return "border-rose-400/60 bg-rose-500/25";
|
|
}
|
|
if (cell.blockedCount > 0) {
|
|
return "border-amber-300/60 bg-amber-400/25";
|
|
}
|
|
if (cell.count >= 4) {
|
|
return "border-brand/80 bg-brand/35";
|
|
}
|
|
if (cell.count >= 2) {
|
|
return "border-brand/50 bg-brand/20";
|
|
}
|
|
if (cell.count === 1) {
|
|
return "border-line/80 bg-page/80";
|
|
}
|
|
return "border-line/60 bg-surface/70";
|
|
}
|
|
|
|
function buildFocusRecords(tasks: GanttTaskDto[]) {
|
|
return tasks.map((task) => ({
|
|
id: task.id,
|
|
entityId: task.entityId ?? null,
|
|
title: task.text,
|
|
kind: parseFocusKind(task),
|
|
status: task.status ?? "PLANNED",
|
|
ownerLabel: task.ownerLabel ?? null,
|
|
start: task.start,
|
|
end: task.end,
|
|
progress: task.progress,
|
|
detailHref: task.detailHref ?? null,
|
|
parentId: task.parentId ?? null,
|
|
projectId: task.projectId ?? null,
|
|
workOrderId: task.workOrderId ?? null,
|
|
salesOrderId: task.salesOrderId ?? null,
|
|
itemId: task.itemId ?? null,
|
|
itemSku: task.itemSku ?? null,
|
|
stationId: task.stationId ?? null,
|
|
stationCode: task.stationCode ?? null,
|
|
readinessState: task.readinessState ?? "READY",
|
|
readinessScore: task.readinessScore ?? 0,
|
|
shortageItemCount: task.shortageItemCount ?? 0,
|
|
totalShortageQuantity: task.totalShortageQuantity ?? 0,
|
|
openSupplyQuantity: task.openSupplyQuantity ?? 0,
|
|
releaseReady: task.releaseReady ?? false,
|
|
overdue: task.overdue ?? false,
|
|
blockedReason: task.blockedReason ?? null,
|
|
utilizationPercent: task.utilizationPercent ?? null,
|
|
loadMinutes: task.loadMinutes ?? 0,
|
|
actions: task.actions ?? [],
|
|
}));
|
|
}
|
|
|
|
function matchesWorkbenchFilter(record: FocusRecord, filter: WorkbenchFilter) {
|
|
switch (filter) {
|
|
case "release-ready":
|
|
return record.releaseReady;
|
|
case "blocked":
|
|
return record.readinessState === "BLOCKED" || record.readinessState === "PENDING_SUPPLY";
|
|
case "shortage":
|
|
return record.totalShortageQuantity > 0 || record.readinessState === "SHORTAGE";
|
|
case "overdue":
|
|
return record.overdue;
|
|
default:
|
|
return true;
|
|
}
|
|
}
|
|
|
|
export function WorkbenchPage() {
|
|
const navigate = useNavigate();
|
|
const { token } = useAuth();
|
|
const [timeline, setTimeline] = useState<PlanningTimelineDto | null>(null);
|
|
const [planningRollup, setPlanningRollup] = useState<DemandPlanningRollupDto | null>(null);
|
|
const [status, setStatus] = useState("Loading live planning timeline...");
|
|
const [workbenchMode, setWorkbenchMode] = useState<WorkbenchMode>("overview");
|
|
const [workbenchGroup, setWorkbenchGroup] = useState<WorkbenchGroup>("projects");
|
|
const [workbenchFilter, setWorkbenchFilter] = useState<WorkbenchFilter>("all");
|
|
const [selectedFocusId, setSelectedFocusId] = useState<string | null>(null);
|
|
const [selectedHeatmapDate, setSelectedHeatmapDate] = useState<string | null>(null);
|
|
const [rescheduleStart, setRescheduleStart] = useState("");
|
|
const [rescheduleStationId, setRescheduleStationId] = useState("");
|
|
const [isRescheduling, setIsRescheduling] = useState(false);
|
|
const [stations, setStations] = useState<ManufacturingStationDto[]>([]);
|
|
const [draggingOperation, setDraggingOperation] = useState<DraggingOperation | null>(null);
|
|
const [dropStationId, setDropStationId] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!token) {
|
|
return;
|
|
}
|
|
|
|
Promise.all([api.getPlanningTimeline(token), api.getDemandPlanningRollup(token), api.getManufacturingStations(token)])
|
|
.then(([data, rollup, stationOptions]) => {
|
|
setTimeline(data);
|
|
setPlanningRollup(rollup);
|
|
setStations(stationOptions);
|
|
setStatus("Planning workbench loaded.");
|
|
})
|
|
.catch((error: unknown) => {
|
|
const message = error instanceof ApiError ? error.message : "Unable to load planning timeline.";
|
|
setStatus(message);
|
|
});
|
|
}, [token]);
|
|
|
|
const tasks = timeline?.tasks ?? [];
|
|
const summary = timeline?.summary;
|
|
const exceptions = timeline?.exceptions ?? [];
|
|
const stationLoads = timeline?.stationLoads ?? [];
|
|
const focusRecords = useMemo(() => buildFocusRecords(tasks), [tasks]);
|
|
const filteredFocusRecords = useMemo(() => focusRecords.filter((record) => matchesWorkbenchFilter(record, workbenchFilter)), [focusRecords, workbenchFilter]);
|
|
const focusById = useMemo(() => new Map(focusRecords.map((record) => [record.id, record])), [focusRecords]);
|
|
const selectedFocus = selectedFocusId ? focusById.get(selectedFocusId) ?? null : filteredFocusRecords[0] ?? focusRecords[0] ?? null;
|
|
|
|
useEffect(() => {
|
|
if (selectedFocus?.kind === "OPERATION") {
|
|
setRescheduleStart(selectedFocus.start.slice(0, 16));
|
|
setRescheduleStationId(selectedFocus.stationId ?? "");
|
|
} else {
|
|
setRescheduleStart("");
|
|
setRescheduleStationId("");
|
|
}
|
|
}, [selectedFocus?.id, selectedFocus?.kind, selectedFocus?.start, selectedFocus?.stationId]);
|
|
|
|
const heatmap = useMemo(() => {
|
|
const start = summary ? startOfDay(new Date(summary.horizonStart)) : startOfDay(new Date());
|
|
const cells = new Map<string, HeatmapCell>();
|
|
for (let index = 0; index < 84; index += 1) {
|
|
const nextDate = new Date(start.getTime() + index * DAY_MS);
|
|
cells.set(dateKey(nextDate), { dateKey: dateKey(nextDate), count: 0, lateCount: 0, blockedCount: 0, tasks: [] });
|
|
}
|
|
|
|
for (const record of filteredFocusRecords) {
|
|
if (record.kind === "PROJECT") {
|
|
continue;
|
|
}
|
|
const startDate = startOfDay(new Date(record.start));
|
|
const endDate = startOfDay(new Date(record.end));
|
|
for (let cursor = startDate.getTime(); cursor <= endDate.getTime(); cursor += DAY_MS) {
|
|
const key = dateKey(new Date(cursor));
|
|
const current = cells.get(key);
|
|
if (!current) {
|
|
continue;
|
|
}
|
|
current.count += 1;
|
|
if (record.status === "AT_RISK" || record.status === "ON_HOLD") {
|
|
current.blockedCount += 1;
|
|
}
|
|
if (new Date(record.end).getTime() < Date.now() && record.status !== "COMPLETE" && record.status !== "CANCELLED") {
|
|
current.lateCount += 1;
|
|
}
|
|
current.tasks.push(record);
|
|
}
|
|
}
|
|
|
|
return [...cells.values()];
|
|
}, [filteredFocusRecords, summary]);
|
|
|
|
const selectedHeatmapCell = selectedHeatmapDate ? heatmap.find((cell) => cell.dateKey === selectedHeatmapDate) ?? null : null;
|
|
const stationLoadById = useMemo(() => new Map(stationLoads.map((station) => [station.stationId, station])), [stationLoads]);
|
|
const selectedRescheduleStation = rescheduleStationId ? stations.find((station) => station.id === rescheduleStationId) ?? null : null;
|
|
const selectedRescheduleLoad = selectedRescheduleStation ? stationLoadById.get(selectedRescheduleStation.id) ?? null : null;
|
|
const agendaItems = useMemo(
|
|
() => [...focusRecords]
|
|
.filter((record) => record.kind !== "OPERATION")
|
|
.filter((record) => matchesWorkbenchFilter(record, workbenchFilter))
|
|
.sort((left, right) => new Date(left.end).getTime() - new Date(right.end).getTime())
|
|
.slice(0, 18),
|
|
[focusRecords, workbenchFilter]
|
|
);
|
|
|
|
const modeOptions: Array<{ value: WorkbenchMode; label: string; detail: string }> = [
|
|
{ value: "overview", label: "Overview", detail: "Dense planner board" },
|
|
{ value: "heatmap", label: "Heatmap", detail: "Load by day" },
|
|
{ value: "agenda", label: "Agenda", detail: "Upcoming due flow" },
|
|
];
|
|
const groupOptions: Array<{ value: WorkbenchGroup; label: string }> = [
|
|
{ value: "projects", label: "Projects" },
|
|
{ value: "stations", label: "Stations" },
|
|
{ value: "exceptions", label: "Exceptions" },
|
|
];
|
|
const filterOptions: Array<{ value: WorkbenchFilter; label: string }> = [
|
|
{ value: "all", label: "All" },
|
|
{ value: "release-ready", label: "Release Ready" },
|
|
{ value: "blocked", label: "Blocked" },
|
|
{ value: "shortage", label: "Shortage" },
|
|
{ value: "overdue", label: "Overdue" },
|
|
];
|
|
|
|
async function refreshWorkbench(message: string) {
|
|
if (!token) {
|
|
return;
|
|
}
|
|
const [refreshed, stationOptions] = await Promise.all([api.getPlanningTimeline(token), api.getManufacturingStations(token)]);
|
|
setTimeline(refreshed);
|
|
setStations(stationOptions);
|
|
setStatus(message);
|
|
}
|
|
|
|
async function handleTaskAction(action: PlanningTaskActionDto) {
|
|
if (!token) {
|
|
return;
|
|
}
|
|
if (action.kind === "RELEASE_WORK_ORDER" && action.workOrderId) {
|
|
await api.updateWorkOrderStatus(token, action.workOrderId, "RELEASED");
|
|
await refreshWorkbench("Workbench refreshed after release.");
|
|
return;
|
|
}
|
|
if (action.href) {
|
|
navigate(action.href);
|
|
}
|
|
}
|
|
|
|
async function rebalanceOperation(record: FocusRecord, nextStartIso?: string, nextStationId?: string | null) {
|
|
if (!token || record.kind !== "OPERATION" || !record.workOrderId || !record.entityId) {
|
|
return;
|
|
}
|
|
|
|
const plannedStart = nextStartIso ?? new Date(record.start).toISOString();
|
|
if (!plannedStart) {
|
|
return;
|
|
}
|
|
|
|
setIsRescheduling(true);
|
|
try {
|
|
await api.updateWorkOrderOperationSchedule(token, record.workOrderId, record.entityId, {
|
|
plannedStart,
|
|
stationId: nextStationId ?? record.stationId ?? null,
|
|
});
|
|
await refreshWorkbench("Workbench refreshed after operation rebalance.");
|
|
setSelectedFocusId(record.id);
|
|
setRescheduleStart(plannedStart.slice(0, 16));
|
|
if (nextStationId) {
|
|
setRescheduleStationId(nextStationId);
|
|
}
|
|
} catch (error: unknown) {
|
|
const message = error instanceof ApiError ? error.message : "Unable to rebalance operation from Workbench.";
|
|
setStatus(message);
|
|
} finally {
|
|
setIsRescheduling(false);
|
|
setDraggingOperation(null);
|
|
setDropStationId(null);
|
|
}
|
|
}
|
|
|
|
async function handleRescheduleOperation(nextStartIso?: string, nextStationId?: string | null) {
|
|
if (!selectedFocus || selectedFocus.kind !== "OPERATION") {
|
|
return;
|
|
}
|
|
const plannedStart = nextStartIso ?? (rescheduleStart ? new Date(rescheduleStart).toISOString() : "");
|
|
await rebalanceOperation(selectedFocus, plannedStart, (nextStationId ?? rescheduleStationId) || null);
|
|
}
|
|
|
|
function shiftRescheduleDraft(hours: number) {
|
|
if (!rescheduleStart) {
|
|
return;
|
|
}
|
|
const next = new Date(rescheduleStart);
|
|
next.setHours(next.getHours() + hours);
|
|
setRescheduleStart(next.toISOString().slice(0, 16));
|
|
}
|
|
|
|
function moveDraftToSelectedHeatmapDay() {
|
|
if (!selectedHeatmapDate) {
|
|
return;
|
|
}
|
|
const current = rescheduleStart ? new Date(rescheduleStart) : new Date(`${selectedHeatmapDate}T08:00:00`);
|
|
const target = new Date(`${selectedHeatmapDate}T${String(current.getHours()).padStart(2, "0")}:${String(current.getMinutes()).padStart(2, "0")}:00`);
|
|
setRescheduleStart(target.toISOString().slice(0, 16));
|
|
}
|
|
|
|
async function handleStationDrop(targetStationId: string) {
|
|
if (!draggingOperation) {
|
|
return;
|
|
}
|
|
const record = focusById.get(draggingOperation.id);
|
|
if (!record || record.kind !== "OPERATION") {
|
|
setDraggingOperation(null);
|
|
setDropStationId(null);
|
|
return;
|
|
}
|
|
|
|
const plannedStart = selectedHeatmapDate
|
|
? new Date(`${selectedHeatmapDate}T${new Date(record.start).toTimeString().slice(0, 5)}:00`).toISOString()
|
|
: new Date(record.start).toISOString();
|
|
await rebalanceOperation(record, plannedStart, targetStationId);
|
|
}
|
|
|
|
return (
|
|
<section className="space-y-4">
|
|
<div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
|
<div className="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
|
|
<div>
|
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Planning</p>
|
|
<h3 className="mt-2 text-2xl font-bold text-text">Planning Workbench</h3>
|
|
<p className="mt-2 max-w-4xl text-sm text-muted">A reactive planning surface for projects, work orders, operations, shortages, and schedule risk. Use it as the daily planner cockpit, not just a chart.</p>
|
|
</div>
|
|
<div className="rounded-[18px] border border-line/70 bg-page/60 px-3 py-3 text-sm">
|
|
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Workbench Status</div>
|
|
<div className="mt-2 font-semibold text-text">{status}</div>
|
|
</div>
|
|
</div>
|
|
<div className="mt-5 grid gap-3 xl:grid-cols-4">
|
|
{modeOptions.map((option) => (
|
|
<button key={option.value} type="button" onClick={() => setWorkbenchMode(option.value)} className={`rounded-[18px] border px-3 py-3 text-left transition ${workbenchMode === option.value ? "border-brand bg-brand/10" : "border-line/70 bg-page/60 hover:border-brand/40"}`}>
|
|
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">{option.label}</div>
|
|
<div className="mt-2 text-sm font-semibold text-text">{option.detail}</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="mt-4 flex flex-wrap gap-2">
|
|
{groupOptions.map((option) => (
|
|
<button key={option.value} type="button" onClick={() => setWorkbenchGroup(option.value)} className={`rounded-2xl border px-3 py-2 text-sm font-semibold ${workbenchGroup === option.value ? "border-brand bg-brand/10 text-text" : "border-line/70 bg-page/60 text-muted"}`}>
|
|
{option.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="mt-3 flex flex-wrap gap-2">
|
|
{filterOptions.map((option) => (
|
|
<button key={option.value} type="button" onClick={() => setWorkbenchFilter(option.value)} className={`rounded-2xl border px-3 py-2 text-sm font-semibold ${workbenchFilter === option.value ? "border-brand bg-brand/10 text-text" : "border-line/70 bg-page/60 text-muted"}`}>
|
|
{option.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<section className="grid gap-3 xl:grid-cols-10">
|
|
<MetricCard label="Active Projects" value={summary?.activeProjects ?? 0} />
|
|
<MetricCard label="At Risk" value={summary?.atRiskProjects ?? 0} />
|
|
<MetricCard label="Overdue Projects" value={summary?.overdueProjects ?? 0} />
|
|
<MetricCard label="Active Work" value={summary?.activeWorkOrders ?? 0} />
|
|
<MetricCard label="Overdue Work" value={summary?.overdueWorkOrders ?? 0} />
|
|
<MetricCard label="Unscheduled" value={summary?.unscheduledWorkOrders ?? 0} />
|
|
<MetricCard label="Release Ready" value={summary?.releaseReadyWorkOrders ?? 0} />
|
|
<MetricCard label="Blocked Work" value={summary?.blockedWorkOrders ?? 0} />
|
|
<MetricCard label="Stations" value={`${summary?.stationCount ?? 0} / ${summary?.overloadedStations ?? 0} hot`} />
|
|
<MetricCard label="Shortage Items" value={planningRollup?.summary.uncoveredItemCount ?? 0} />
|
|
<MetricCard label="Build / Buy" value={planningRollup ? `${planningRollup.summary.totalBuildQuantity} / ${planningRollup.summary.totalPurchaseQuantity}` : "0 / 0"} />
|
|
</section>
|
|
|
|
<div className="grid gap-3 xl:grid-cols-[320px_minmax(0,1fr)_360px]">
|
|
<aside className="space-y-3">
|
|
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div>
|
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Exception Rail</p>
|
|
<p className="mt-2 text-sm text-muted">Late, at-risk, and unscheduled items that require planner attention.</p>
|
|
</div>
|
|
<span className="rounded-full border border-line/70 px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-muted">{exceptions.length}</span>
|
|
</div>
|
|
{exceptions.length === 0 ? (
|
|
<div className="mt-5 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No planning exceptions are active.</div>
|
|
) : (
|
|
<div className="mt-5 space-y-3">
|
|
{exceptions.map((exception: PlanningExceptionDto) => (
|
|
<button key={exception.id} type="button" onClick={() => setSelectedFocusId(exception.id.startsWith("project-") ? exception.id : exception.id.replace("work-order-unscheduled-", "work-order-"))} className="block w-full rounded-[18px] border border-line/70 bg-page/60 p-3 text-left transition hover:bg-page/80">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div>
|
|
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">{exception.kind === "PROJECT" ? "Project" : "Work Order"}</div>
|
|
<div className="mt-1 font-semibold text-text">{exception.title}</div>
|
|
<div className="mt-2 text-xs text-muted">{exception.ownerLabel ?? "No owner context"}</div>
|
|
</div>
|
|
<span className="rounded-full border border-line/70 px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-muted">{exception.status.replaceAll("_", " ")}</span>
|
|
</div>
|
|
<div className="mt-3 text-xs text-muted">Due: {formatDate(exception.dueDate)}</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel">
|
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Planner Actions</p>
|
|
<div className="mt-4 space-y-2 rounded-[18px] border border-line/70 bg-page/60 p-3 text-sm">
|
|
<div className="flex items-center justify-between gap-3"><span className="text-muted">Uncovered quantity</span><span className="font-semibold text-text">{planningRollup?.summary.totalUncoveredQuantity ?? 0}</span></div>
|
|
<div className="flex items-center justify-between gap-3"><span className="text-muted">Projects with linked demand</span><span className="font-semibold text-text">{planningRollup?.summary.projectCount ?? 0}</span></div>
|
|
<div className="flex items-center justify-between gap-3"><span className="text-muted">Overloaded stations</span><span className="font-semibold text-text">{summary?.overloadedStations ?? 0}</span></div>
|
|
</div>
|
|
<div className="mt-4 flex flex-wrap gap-2">
|
|
<Link to="/projects" className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Open projects</Link>
|
|
<Link to="/manufacturing/work-orders" className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Open work orders</Link>
|
|
<Link to="/purchasing/orders" className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Open purchasing</Link>
|
|
</div>
|
|
</section>
|
|
</aside>
|
|
|
|
<div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel">
|
|
{workbenchMode === "overview" ? (
|
|
<OverviewBoard
|
|
focusRecords={filteredFocusRecords}
|
|
stationLoads={stationLoads}
|
|
groupMode={workbenchGroup}
|
|
onSelect={setSelectedFocusId}
|
|
draggingOperation={draggingOperation}
|
|
dropStationId={dropStationId}
|
|
selectedHeatmapDate={selectedHeatmapDate}
|
|
onDragOperation={setDraggingOperation}
|
|
onDropStation={handleStationDrop}
|
|
onDropStationChange={setDropStationId}
|
|
/>
|
|
) : null}
|
|
{workbenchMode === "heatmap" ? <HeatmapBoard heatmap={heatmap} selectedDate={selectedHeatmapDate} onSelectDate={setSelectedHeatmapDate} /> : null}
|
|
{workbenchMode === "agenda" ? <AgendaBoard records={agendaItems} onSelect={setSelectedFocusId} /> : null}
|
|
</div>
|
|
|
|
<aside className="space-y-3">
|
|
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel">
|
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Focus Drawer</p>
|
|
{selectedFocus ? (
|
|
<div className="mt-4 space-y-3">
|
|
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
|
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">{selectedFocus.kind}</div>
|
|
<div className="mt-2 text-base font-bold text-text">{selectedFocus.title}</div>
|
|
<div className="mt-2 text-xs text-muted">{selectedFocus.ownerLabel ?? "No context label"}</div>
|
|
</div>
|
|
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3 text-sm">
|
|
<div className="flex items-center justify-between gap-3"><span className="text-muted">Status</span><span className="font-semibold text-text">{selectedFocus.status.replaceAll("_", " ")}</span></div>
|
|
<div className="mt-2 flex items-center justify-between gap-3"><span className="text-muted">Readiness</span><span className="font-semibold text-text">{selectedFocus.readinessState.replaceAll("_", " ")} ({selectedFocus.readinessScore}%)</span></div>
|
|
<div className="mt-2 flex items-center justify-between gap-3"><span className="text-muted">Window</span><span className="font-semibold text-text">{formatDate(selectedFocus.start)} - {formatDate(selectedFocus.end)}</span></div>
|
|
<div className="mt-2 flex items-center justify-between gap-3"><span className="text-muted">Progress</span><span className="font-semibold text-text">{selectedFocus.progress}%</span></div>
|
|
<div className="mt-2 flex items-center justify-between gap-3"><span className="text-muted">Shortage</span><span className="font-semibold text-text">{selectedFocus.shortageItemCount} / {selectedFocus.totalShortageQuantity}</span></div>
|
|
<div className="mt-2 flex items-center justify-between gap-3"><span className="text-muted">Open supply</span><span className="font-semibold text-text">{selectedFocus.openSupplyQuantity}</span></div>
|
|
{selectedFocus.blockedReason ? <div className="mt-3 text-xs text-muted">{selectedFocus.blockedReason}</div> : null}
|
|
</div>
|
|
{selectedFocus.kind === "OPERATION" ? (
|
|
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3 text-sm">
|
|
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Workbench Rebalance</div>
|
|
<div className="mt-3">
|
|
<label className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Target Station</label>
|
|
<select
|
|
value={rescheduleStationId}
|
|
onChange={(event) => setRescheduleStationId(event.target.value)}
|
|
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
|
|
>
|
|
{stations
|
|
.filter((station) => station.isActive || station.id === selectedFocus.stationId)
|
|
.map((station) => (
|
|
<option key={station.id} value={station.id}>
|
|
{station.code} - {station.name}{station.isActive ? "" : " (Inactive)"}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="mt-3">
|
|
<label className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Planned Start</label>
|
|
<input
|
|
type="datetime-local"
|
|
value={rescheduleStart}
|
|
onChange={(event) => setRescheduleStart(event.target.value)}
|
|
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
|
|
/>
|
|
</div>
|
|
{selectedRescheduleStation ? (
|
|
<div className="mt-3 rounded-[16px] border border-line/70 bg-surface/80 p-3 text-xs text-muted">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<span>Capacity</span>
|
|
<span className="font-semibold text-text">
|
|
{selectedRescheduleStation.dailyCapacityMinutes} min/day x {selectedRescheduleStation.parallelCapacity}
|
|
</span>
|
|
</div>
|
|
<div className="mt-2 flex items-center justify-between gap-3">
|
|
<span>Working days</span>
|
|
<span className="font-semibold text-text">
|
|
{selectedRescheduleStation.workingDays.map((day) => ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][day]).join(", ")}
|
|
</span>
|
|
</div>
|
|
{selectedRescheduleLoad ? (
|
|
<div className="mt-2 flex items-center justify-between gap-3">
|
|
<span>Current load</span>
|
|
<span className="font-semibold text-text">
|
|
{selectedRescheduleLoad.utilizationPercent}% util / {selectedRescheduleLoad.overloaded ? "Overloaded" : "Within load"}
|
|
</span>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
<div className="mt-3 flex flex-wrap gap-2">
|
|
<button type="button" onClick={() => shiftRescheduleDraft(1)} className="rounded-2xl border border-line/70 px-2 py-2 text-xs font-semibold text-text">+1h</button>
|
|
<button type="button" onClick={() => shiftRescheduleDraft(8)} className="rounded-2xl border border-line/70 px-2 py-2 text-xs font-semibold text-text">+1 shift</button>
|
|
<button type="button" onClick={() => shiftRescheduleDraft(24)} className="rounded-2xl border border-line/70 px-2 py-2 text-xs font-semibold text-text">+1 day</button>
|
|
{selectedHeatmapDate ? (
|
|
<button type="button" onClick={moveDraftToSelectedHeatmapDay} className="rounded-2xl border border-line/70 px-2 py-2 text-xs font-semibold text-text">
|
|
Move to {formatDate(selectedHeatmapDate)}
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
<div className="mt-3 flex flex-wrap gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => void handleRescheduleOperation()}
|
|
disabled={isRescheduling || !rescheduleStart}
|
|
className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60"
|
|
>
|
|
{isRescheduling ? "Rebalancing..." : "Apply rebalance"}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
const originalStationId = selectedFocus.stationId ?? "";
|
|
setRescheduleStationId(originalStationId);
|
|
void handleRescheduleOperation(new Date(selectedFocus.start).toISOString(), originalStationId);
|
|
}}
|
|
disabled={isRescheduling}
|
|
className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60"
|
|
>
|
|
Reset
|
|
</button>
|
|
</div>
|
|
<div className="mt-3 text-xs text-muted">
|
|
Rebalance starts from this operation, can move it onto another active station, and rebuilds downstream operations using station calendars and capacity.
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
<div className="flex flex-wrap gap-2">
|
|
{selectedFocus.actions.map((action, index) => (
|
|
<button
|
|
key={`${action.kind}-${index}`}
|
|
type="button"
|
|
onClick={() => void handleTaskAction(action)}
|
|
className={`${index === 0 ? "bg-brand text-white" : "border border-line/70 text-text"} rounded-2xl px-2 py-2 text-sm font-semibold`}
|
|
>
|
|
{action.label}
|
|
</button>
|
|
))}
|
|
<button type="button" onClick={() => setWorkbenchMode("heatmap")} className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">View load</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="mt-5 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">Select a project or work order to inspect it.</div>
|
|
)}
|
|
</section>
|
|
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel">
|
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">{workbenchMode === "heatmap" ? "Selected Day" : "Upcoming Agenda"}</p>
|
|
{workbenchMode === "heatmap"
|
|
? (selectedHeatmapCell ? <SelectedDayPanel cell={selectedHeatmapCell} onSelect={setSelectedFocusId} /> : <div className="mt-5 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">Select a day in the heatmap to inspect its load.</div>)
|
|
: <AgendaBoard records={agendaItems.slice(0, 8)} onSelect={setSelectedFocusId} compact />}
|
|
</section>
|
|
</aside>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function MetricCard({ label, value }: { label: string; value: string | number }) {
|
|
return (
|
|
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">{label}</p>
|
|
<div className="mt-2 text-xl font-extrabold text-text">{value}</div>
|
|
</article>
|
|
);
|
|
}
|
|
|
|
function OverviewBoard({
|
|
focusRecords,
|
|
stationLoads,
|
|
groupMode,
|
|
onSelect,
|
|
draggingOperation,
|
|
dropStationId,
|
|
selectedHeatmapDate,
|
|
onDragOperation,
|
|
onDropStation,
|
|
onDropStationChange,
|
|
}: {
|
|
focusRecords: FocusRecord[];
|
|
stationLoads: PlanningStationLoadDto[];
|
|
groupMode: WorkbenchGroup;
|
|
onSelect: (id: string) => void;
|
|
draggingOperation: DraggingOperation | null;
|
|
dropStationId: string | null;
|
|
selectedHeatmapDate: string | null;
|
|
onDragOperation: (operation: DraggingOperation | null) => void;
|
|
onDropStation: (stationId: string) => void | Promise<void>;
|
|
onDropStationChange: (stationId: string | null) => void;
|
|
}) {
|
|
const projects = focusRecords.filter((record) => record.kind === "PROJECT").slice(0, 6);
|
|
const operations = focusRecords.filter((record) => record.kind === "OPERATION");
|
|
const workOrders = focusRecords.filter((record) => record.kind === "WORK_ORDER").slice(0, 10);
|
|
const exceptionRows = focusRecords
|
|
.filter((record) => record.kind !== "PROJECT" && (record.overdue || record.totalShortageQuantity > 0 || record.readinessState === "BLOCKED" || record.readinessState === "PENDING_SUPPLY"))
|
|
.slice(0, 10);
|
|
const stationOperations = new Map<string, FocusRecord[]>();
|
|
for (const operation of operations) {
|
|
if (!operation.stationId) {
|
|
continue;
|
|
}
|
|
const bucket = stationOperations.get(operation.stationId) ?? [];
|
|
bucket.push(operation);
|
|
stationOperations.set(operation.stationId, bucket);
|
|
}
|
|
for (const bucket of stationOperations.values()) {
|
|
bucket.sort((left, right) => new Date(left.start).getTime() - new Date(right.start).getTime());
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div>
|
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Overview</p>
|
|
<p className="mt-2 text-sm text-muted">Scan project rollups, active work, station load, and release blockers without leaving the planner.</p>
|
|
</div>
|
|
</div>
|
|
{groupMode === "projects" ? (
|
|
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.15fr)_minmax(0,0.85fr)]">
|
|
<section className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Program Queue</p>
|
|
<div className="mt-3 space-y-3">
|
|
{projects.map((record) => (
|
|
<button key={record.id} type="button" onClick={() => onSelect(record.id)} className="block w-full rounded-[16px] border border-line/70 bg-surface/80 p-3 text-left transition hover:bg-surface">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div>
|
|
<div className="font-semibold text-text">{record.title}</div>
|
|
<div className="mt-1 text-xs text-muted">{record.ownerLabel ?? "No owner context"}</div>
|
|
</div>
|
|
<div className="text-right text-xs text-muted">
|
|
<div>{record.readinessState}</div>
|
|
<div>{record.progress}% progress</div>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</section>
|
|
<section className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Operation Load</p>
|
|
<div className="mt-3 space-y-2">
|
|
{operations.slice(0, 10).map((record) => (
|
|
<button key={record.id} type="button" onClick={() => onSelect(record.id)} className="flex w-full items-center justify-between gap-3 rounded-[16px] border border-line/70 bg-surface/80 px-3 py-2 text-left transition hover:bg-surface">
|
|
<div>
|
|
<div className="font-semibold text-text">{record.title}</div>
|
|
<div className="mt-1 text-xs text-muted">{record.ownerLabel ?? "No parent work order"}</div>
|
|
</div>
|
|
<div className="text-right text-xs text-muted">
|
|
<div>{record.stationCode ?? "No station"}</div>
|
|
<div>{record.utilizationPercent ?? 0}% util</div>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
) : null}
|
|
{groupMode === "stations" ? (
|
|
<section className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div>
|
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Work Center Load</p>
|
|
<p className="mt-2 text-sm text-muted">Drag operations between stations to rebalance capacity. If a heatmap day is selected, drops target that date on the new station.</p>
|
|
</div>
|
|
{draggingOperation ? (
|
|
<div className="rounded-2xl border border-brand/40 bg-brand/10 px-3 py-2 text-xs font-semibold text-text">
|
|
Dragging {draggingOperation.title}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
<div className="mt-3 grid gap-3 xl:grid-cols-2">
|
|
{stationLoads.slice(0, 10).map((station) => (
|
|
<div
|
|
key={station.stationId}
|
|
onDragOver={(event) => {
|
|
if (!draggingOperation) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
onDropStationChange(station.stationId);
|
|
}}
|
|
onDragLeave={() => {
|
|
if (dropStationId === station.stationId) {
|
|
onDropStationChange(null);
|
|
}
|
|
}}
|
|
onDrop={(event) => {
|
|
event.preventDefault();
|
|
void onDropStation(station.stationId);
|
|
}}
|
|
className={`rounded-[16px] border bg-surface/80 p-3 transition ${
|
|
dropStationId === station.stationId
|
|
? "border-brand bg-brand/10"
|
|
: station.overloaded
|
|
? "border-amber-300/60"
|
|
: "border-line/70"
|
|
}`}
|
|
>
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div>
|
|
<div className="font-semibold text-text">{station.stationCode} - {station.stationName}</div>
|
|
<div className="mt-1 text-xs text-muted">{station.operationCount} ops across {station.workOrderCount} work orders</div>
|
|
</div>
|
|
<div className="text-right text-xs text-muted">
|
|
<div>{station.utilizationPercent}% util</div>
|
|
<div>{station.overloaded ? "Overloaded" : "Within load"}</div>
|
|
</div>
|
|
</div>
|
|
<div className="mt-3 grid grid-cols-3 gap-2 text-xs text-muted">
|
|
<div>Ready {station.readyCount}</div>
|
|
<div>Blocked {station.blockedCount}</div>
|
|
<div>Late {station.lateCount}</div>
|
|
</div>
|
|
{draggingOperation ? (
|
|
<div className="mt-3 rounded-[14px] border border-line/70 bg-page/60 p-2 text-xs text-muted">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<span>Projected util after drop</span>
|
|
<span className="font-semibold text-text">
|
|
{Math.round(((station.totalPlannedMinutes + draggingOperation.loadMinutes) / Math.max(station.capacityMinutes, 1)) * 100)}%
|
|
</span>
|
|
</div>
|
|
<div className="mt-1">
|
|
{station.stationId === draggingOperation.stationId
|
|
? "Same station move."
|
|
: Math.round(((station.totalPlannedMinutes + draggingOperation.loadMinutes) / Math.max(station.capacityMinutes, 1)) * 100) > 100
|
|
? "Drop will overload this station."
|
|
: "Drop stays within current summarized load."}
|
|
</div>
|
|
{selectedHeatmapDate ? <div className="mt-1">Target day: {formatDate(selectedHeatmapDate)}</div> : null}
|
|
</div>
|
|
) : null}
|
|
<div className="mt-3 space-y-2">
|
|
{(stationOperations.get(station.stationId) ?? []).slice(0, 5).map((record) => (
|
|
<div
|
|
key={record.id}
|
|
draggable
|
|
onDragStart={() =>
|
|
onDragOperation({
|
|
id: record.id,
|
|
title: record.title,
|
|
stationId: record.stationId,
|
|
start: record.start,
|
|
loadMinutes: Math.max(record.loadMinutes, 1),
|
|
})
|
|
}
|
|
onDragEnd={() => {
|
|
onDragOperation(null);
|
|
onDropStationChange(null);
|
|
}}
|
|
className="cursor-grab rounded-[14px] border border-line/70 bg-page/60 p-2 active:cursor-grabbing"
|
|
>
|
|
<button type="button" onClick={() => onSelect(record.id)} className="block w-full text-left">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div>
|
|
<div className="font-semibold text-text">{record.ownerLabel ?? record.title}</div>
|
|
<div className="mt-1 text-xs text-muted">{formatDate(record.start, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })}</div>
|
|
</div>
|
|
<div className="text-right text-xs text-muted">
|
|
<div>{record.readinessState}</div>
|
|
<div>{record.utilizationPercent ?? station.utilizationPercent}% util</div>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
))}
|
|
{(stationOperations.get(station.stationId)?.length ?? 0) > 5 ? (
|
|
<div className="text-xs text-muted">+{(stationOperations.get(station.stationId)?.length ?? 0) - 5} more operations</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
) : null}
|
|
{groupMode === "exceptions" ? (
|
|
<section className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Dispatch Exceptions</p>
|
|
<div className="mt-3 space-y-2">
|
|
{exceptionRows.map((record) => (
|
|
<button key={record.id} type="button" onClick={() => onSelect(record.id)} className="flex w-full items-center justify-between gap-3 rounded-[16px] border border-line/70 bg-surface/80 px-3 py-2 text-left transition hover:bg-surface">
|
|
<div>
|
|
<div className="font-semibold text-text">{record.title}</div>
|
|
<div className="mt-1 text-xs text-muted">{record.readinessState} - shortage {record.totalShortageQuantity}</div>
|
|
</div>
|
|
<div className="text-right text-xs text-muted">
|
|
<div>{record.ownerLabel ?? "No context"}</div>
|
|
<div>{record.overdue ? "Overdue" : "Open"}</div>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</section>
|
|
) : null}
|
|
<section className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Active Work Orders</p>
|
|
<div className="mt-3 grid gap-3 xl:grid-cols-2">
|
|
{workOrders.map((record) => (
|
|
<button key={record.id} type="button" onClick={() => onSelect(record.id)} className="rounded-[16px] border border-line/70 bg-surface/80 p-3 text-left transition hover:bg-surface">
|
|
<div className="font-semibold text-text">{record.title}</div>
|
|
<div className="mt-2 flex items-center justify-between gap-3 text-xs text-muted">
|
|
<span>{record.readinessState}</span>
|
|
<span>{record.progress}%</span>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function HeatmapBoard({ heatmap, selectedDate, onSelectDate }: { heatmap: HeatmapCell[]; selectedDate: string | null; onSelectDate: (date: string) => void }) {
|
|
const weeks = [];
|
|
for (let index = 0; index < heatmap.length; index += 7) {
|
|
weeks.push(heatmap.slice(index, index + 7));
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Load Heatmap</p>
|
|
<p className="mt-2 text-sm text-muted">Dense daily load scan for operations and work orders, with late and blocked pressure highlighted.</p>
|
|
</div>
|
|
<div className="overflow-x-auto rounded-[18px] border border-line/70 bg-page/60 p-4">
|
|
<div className="flex gap-2">
|
|
<div className="flex flex-col gap-2 pt-7">
|
|
{["M", "T", "W", "T", "F", "S", "S"].map((label) => <div key={label} className="h-9 text-xs font-semibold text-muted">{label}</div>)}
|
|
</div>
|
|
{weeks.map((week, weekIndex) => (
|
|
<div key={weekIndex} className="flex flex-col gap-2">
|
|
<div className="h-5 text-[11px] font-semibold uppercase tracking-[0.16em] text-muted">{formatDate(week[0]?.dateKey ?? null, { month: "short" })}</div>
|
|
{week.map((cell) => (
|
|
<button
|
|
key={cell.dateKey}
|
|
type="button"
|
|
onClick={() => onSelectDate(cell.dateKey)}
|
|
className={`h-9 w-9 rounded-md border text-[10px] font-semibold transition hover:scale-110 ${densityTone(cell)} ${selectedDate === cell.dateKey ? "ring-2 ring-brand" : ""}`}
|
|
title={`${cell.dateKey}: ${cell.count} scheduled`}
|
|
>
|
|
{new Date(cell.dateKey).getDate()}
|
|
</button>
|
|
))}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AgendaBoard({ records, onSelect, compact = false }: { records: FocusRecord[]; onSelect: (id: string) => void; compact?: boolean }) {
|
|
return (
|
|
<div className={compact ? "mt-4 space-y-3" : "space-y-4"}>
|
|
{!compact ? (
|
|
<div>
|
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Agenda</p>
|
|
<p className="mt-2 text-sm text-muted">Upcoming projects, work orders, and milestones ordered by due date.</p>
|
|
</div>
|
|
) : null}
|
|
<div className="space-y-2">
|
|
{records.map((record) => (
|
|
<button key={record.id} type="button" onClick={() => onSelect(record.id)} className="flex w-full items-center justify-between gap-3 rounded-[16px] border border-line/70 bg-page/60 px-3 py-3 text-left transition hover:bg-page/80">
|
|
<div>
|
|
<div className="font-semibold text-text">{record.title}</div>
|
|
<div className="mt-1 text-xs text-muted">{record.kind} - {record.ownerLabel ?? "No context"}</div>
|
|
</div>
|
|
<div className="text-right text-xs text-muted">
|
|
<div>{formatDate(record.end)}</div>
|
|
<div>{record.status.replaceAll("_", " ")}</div>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SelectedDayPanel({ cell, onSelect }: { cell: HeatmapCell; onSelect: (id: string) => void }) {
|
|
return (
|
|
<div className="mt-4 space-y-3">
|
|
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
|
<div className="text-sm font-semibold text-text">{formatDate(cell.dateKey, { weekday: "short", month: "short", day: "numeric" })}</div>
|
|
<div className="mt-2 flex items-center justify-between gap-3 text-xs text-muted">
|
|
<span>{cell.count} scheduled</span>
|
|
<span>{cell.lateCount} late</span>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-2">
|
|
{cell.tasks.slice(0, 8).map((task) => (
|
|
<button key={task.id} type="button" onClick={() => onSelect(task.id)} className="block w-full rounded-[16px] border border-line/70 bg-page/60 p-3 text-left transition hover:bg-page/80">
|
|
<div className="font-semibold text-text">{task.title}</div>
|
|
<div className="mt-1 text-xs text-muted">{task.status.replaceAll("_", " ")} - {task.ownerLabel ?? "No context"}</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|