diff --git a/CHANGELOG.md b/CHANGELOG.md index 194fdef..f3274d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ This file is the running release and change log for CODEXIUM. Keep it updated wh ### Added - Project cockpit section on project detail pages for commercial, supply, execution, delivery, purchasing, readiness-risk, and project cost snapshot rollups, plus direct launch paths into prefilled work-order and purchase-order follow-through and a chronological project activity timeline +- Planning workbench replacing the old one-note gantt screen with mode switching, dense exception rail, heatmap load view, agenda view, focus drawer, and gantt as one lens instead of the entire planner - Project milestones with status, due dates, notes, and edit-time sequencing inside the project workflow - Project-side milestone and work-order rollups surfaced on project list and detail pages - Inventory SKU master builder with family-level sequence codes, branch-aware taxonomy management, and generated SKU previews on the item form diff --git a/README.md b/README.md index df1b7a7..0a75853 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Current foundation scope includes: - shipping shipments linked to sales orders with packing slips, shipping labels, bills of lading, and logistics attachments - projects with customer/commercial/shipment linkage, owners, due dates, milestones, rollups, notes, and attachments - manufacturing work orders with project linkage, station-based operation templates, material issue posting, completion posting, and work-order attachments -- planning gantt timelines with live project and manufacturing schedule data +- planning workbench with live project/manufacturing schedule data, gantt lens, exception rail, heatmap load view, agenda view, and focus drawer - sales-order demand planning with multi-level BOM explosion, stock/open-supply netting, and build/buy recommendations - planner-assisted conversion of demand-planning recommendations into prefilled work-order and purchase-order drafts - pegged WO/PO supply tracking back to sales demand with preferred-vendor sourcing on inventory items @@ -126,7 +126,7 @@ Next expansion areas: ## Planning Direction -Planning is now the live scheduling and visibility layer over projects and manufacturing instead of a placeholder wrapper. The current slice ships a gantt surface backed by active projects, due-date milestones, linked work orders, standalone manufacturing queue visibility, and exception cards for overdue or at-risk schedule items. +Planning is now the live scheduling and visibility layer over projects and manufacturing instead of a placeholder wrapper. The current slice ships a planning workbench backed by active projects, due-date milestones, linked work orders, standalone manufacturing queue visibility, exception rails, dense load heatmaps, focus-drawer inspection, agenda sequencing, and a gantt lens for timeline review. Current interactions: diff --git a/SHIPPED.md b/SHIPPED.md index 18c5751..50e3579 100644 --- a/SHIPPED.md +++ b/SHIPPED.md @@ -55,6 +55,7 @@ This file tracks roadmap phases, slices, and major foundations that have already - Startup brand-theme hydration so Company Settings colors and font persist correctly across refresh - Full-site density normalization pass across active CRM, inventory, settings, dashboard, and login screens - Live planning gantt timelines driven by project and manufacturing data +- Planning workbench with gantt, heatmap, overview, and agenda modes plus exception rail and focus drawer - Sales-order demand planning with multi-level BOM explosion, stock/open-supply netting, and build/buy recommendations - Multi-stage Docker packaging and migration-aware entrypoint - Docker image validated locally with successful app startup and login flow diff --git a/client/src/modules/gantt/GanttPage.tsx b/client/src/modules/gantt/GanttPage.tsx index 6d90f47..62a5a15 100644 --- a/client/src/modules/gantt/GanttPage.tsx +++ b/client/src/modules/gantt/GanttPage.tsx @@ -1,31 +1,112 @@ -import { useEffect, useState } from "react"; import { Gantt } from "@svar-ui/react-gantt"; import "@svar-ui/react-gantt/style.css"; -import { Link } from "react-router-dom"; - import type { DemandPlanningRollupDto, GanttTaskDto, PlanningExceptionDto, PlanningTimelineDto } from "@mrp/shared"; +import { useEffect, useMemo, useState } from "react"; +import { Link } from "react-router-dom"; import { useAuth } from "../../auth/AuthProvider"; import { ApiError, api } from "../../lib/api"; import { useTheme } from "../../theme/ThemeProvider"; -function formatDate(value: string | null) { +type WorkbenchMode = "overview" | "gantt" | "heatmap" | "agenda"; +type FocusRecord = { + id: string; + 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; +}; + +type HeatmapCell = { + dateKey: string; + count: number; + lateCount: number; + blockedCount: number; + tasks: FocusRecord[]; +}; + +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", { + 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, + 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, + })); +} + export function GanttPage() { const { token } = useAuth(); const { mode } = useTheme(); const [timeline, setTimeline] = useState(null); const [planningRollup, setPlanningRollup] = useState(null); const [status, setStatus] = useState("Loading live planning timeline..."); + const [workbenchMode, setWorkbenchMode] = useState("overview"); + const [selectedFocusId, setSelectedFocusId] = useState(null); + const [selectedHeatmapDate, setSelectedHeatmapDate] = useState(null); useEffect(() => { if (!token) { @@ -36,7 +117,7 @@ export function GanttPage() { .then(([data, rollup]) => { setTimeline(data); setPlanningRollup(rollup); - setStatus("Planning timeline loaded."); + setStatus("Planning workbench loaded."); }) .catch((error: unknown) => { const message = error instanceof ApiError ? error.message : "Unable to load planning timeline."; @@ -48,149 +129,368 @@ export function GanttPage() { const links = timeline?.links ?? []; const summary = timeline?.summary; const exceptions = timeline?.exceptions ?? []; - const ganttCellHeight = 44; - const ganttScaleHeight = 56; - const ganttHeight = Math.max(420, tasks.length * ganttCellHeight + ganttScaleHeight); + const focusRecords = useMemo(() => buildFocusRecords(tasks), [tasks]); + const focusById = useMemo(() => new Map(focusRecords.map((record) => [record.id, record])), [focusRecords]); + const selectedFocus = selectedFocusId ? focusById.get(selectedFocusId) ?? null : focusRecords[0] ?? null; + const ganttCellHeight = 38; + const ganttScaleHeight = 54; + const ganttHeight = Math.max(520, tasks.length * ganttCellHeight + ganttScaleHeight); + + const heatmap = useMemo(() => { + const start = summary ? startOfDay(new Date(summary.horizonStart)) : startOfDay(new Date()); + const cells = new Map(); + 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 focusRecords) { + 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()]; + }, [focusRecords, summary]); + + const selectedHeatmapCell = selectedHeatmapDate ? heatmap.find((cell) => cell.dateKey === selectedHeatmapDate) ?? null : null; + const agendaItems = useMemo( + () => [...focusRecords] + .filter((record) => record.kind !== "OPERATION") + .sort((left, right) => new Date(left.end).getTime() - new Date(right.end).getTime()) + .slice(0, 18), + [focusRecords] + ); + + const modeOptions: Array<{ value: WorkbenchMode; label: string; detail: string }> = [ + { value: "overview", label: "Overview", detail: "Dense planner board" }, + { value: "gantt", label: "Timeline", detail: "Classic gantt lens" }, + { value: "heatmap", label: "Heatmap", detail: "Load by day" }, + { value: "agenda", label: "Agenda", detail: "Upcoming due flow" }, + ]; return (
-
+

Planning

-

Live Project + Manufacturing Gantt

-

- The planning surface now reads directly from active projects and open work orders so schedule pressure, due-date risk, and standalone manufacturing load are visible in one place. -

+

Planning Workbench

+

A reactive planning surface for projects, work orders, operations, shortages, and schedule risk. Use it as the daily planner cockpit, not just a chart.

-
Timeline Status
+
Workbench Status
{status}
-
-
-
-

Active Projects

-
{summary?.activeProjects ?? 0}
-
-
-

At Risk

-
{summary?.atRiskProjects ?? 0}
-
-
-

Overdue Projects

-
{summary?.overdueProjects ?? 0}
-
-
-

Active Work Orders

-
{summary?.activeWorkOrders ?? 0}
-
-
-

Overdue Work

-
{summary?.overdueWorkOrders ?? 0}
-
-
-

Unscheduled Work

-
{summary?.unscheduledWorkOrders ?? 0}
-
-
-

Shortage Items

-
{planningRollup?.summary.uncoveredItemCount ?? 0}
-
-
-

Build / Buy

-
- {planningRollup ? `${planningRollup.summary.totalBuildQuantity} / ${planningRollup.summary.totalPurchaseQuantity}` : "0 / 0"} -
-
-
-
-
-
-
-

Schedule Window

-

- {summary ? `${formatDate(summary.horizonStart)} through ${formatDate(summary.horizonEnd)}` : "Waiting for live schedule data."} -

-
-
- {tasks.length} schedule rows -
-
-
- ({ - ...task, - start: new Date(task.start), - end: new Date(task.end), - parent: task.parentId ?? undefined, - }))} - links={links} - cellHeight={ganttCellHeight} - scaleHeight={ganttScaleHeight} - /> -
+
+ {modeOptions.map((option) => ( + + ))}
+
+ +
+ + + + + + + + +
+ +
+ +
+ {workbenchMode === "overview" ? : null} + {workbenchMode === "gantt" ? ( +
+
+
+

Schedule Window

+

{summary ? `${formatDate(summary.horizonStart)} through ${formatDate(summary.horizonEnd)}` : "Waiting for live schedule data."}

+
+
{tasks.length} schedule rows
+
+
+ ({ + ...task, + start: new Date(task.start), + end: new Date(task.end), + parent: task.parentId ?? undefined, + }))} + links={links} + cellHeight={ganttCellHeight} + scaleHeight={ganttScaleHeight} + /> +
+
+ ) : null} + {workbenchMode === "heatmap" ? : null} + {workbenchMode === "agenda" ? : null} +
+ +
); } +function MetricCard({ label, value }: { label: string; value: string | number }) { + return ( +
+

{label}

+
{value}
+
+ ); +} + +function OverviewBoard({ focusRecords, onSelect }: { focusRecords: FocusRecord[]; onSelect: (id: string) => void }) { + const projects = focusRecords.filter((record) => record.kind === "PROJECT").slice(0, 6); + const operations = focusRecords.filter((record) => record.kind === "OPERATION").slice(0, 10); + const workOrders = focusRecords.filter((record) => record.kind === "WORK_ORDER").slice(0, 10); + + return ( +
+
+
+

Overview

+

Scan project rollups, active work, and operation load without leaving the planner.

+
+
+
+
+

Program Queue

+
+ {projects.map((record) => ( + + ))} +
+
+
+

Operation Load

+
+ {operations.map((record) => ( + + ))} +
+
+
+
+

Active Work Orders

+
+ {workOrders.map((record) => ( + + ))} +
+
+
+ ); +} + +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 ( +
+
+

Load Heatmap

+

Dense daily load scan for operations and work orders, with late and blocked pressure highlighted.

+
+
+
+
+ {["M", "T", "W", "T", "F", "S", "S"].map((label) =>
{label}
)} +
+ {weeks.map((week, weekIndex) => ( +
+
{formatDate(week[0]?.dateKey ?? null, { month: "short" })}
+ {week.map((cell) => ( + + ))} +
+ ))} +
+
+
+ ); +} + +function AgendaBoard({ records, onSelect, compact = false }: { records: FocusRecord[]; onSelect: (id: string) => void; compact?: boolean }) { + return ( +
+ {!compact ? ( +
+

Agenda

+

Upcoming projects, work orders, and milestones ordered by due date.

+
+ ) : null} +
+ {records.map((record) => ( + + ))} +
+
+ ); +} + +function SelectedDayPanel({ cell, onSelect }: { cell: HeatmapCell; onSelect: (id: string) => void }) { + return ( +
+
+
{formatDate(cell.dateKey, { weekday: "short", month: "short", day: "numeric" })}
+
+ {cell.count} scheduled + {cell.lateCount} late +
+
+
+ {cell.tasks.slice(0, 8).map((task) => ( + + ))} +
+
+ ); +}