Compare commits
2 Commits
cdbd54b8cc
...
b02b764b2f
| Author | SHA1 | Date | |
|---|---|---|---|
| b02b764b2f | |||
| c06cb66893 |
@@ -6,7 +6,8 @@ This file is the running release and change log for CODEXIUM. Keep it updated wh
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Project cockpit section on project detail pages for commercial, supply, execution, delivery, purchasing, readiness-risk, and project cost snapshot rollups
|
- 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 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
|
- 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
|
- Inventory SKU master builder with family-level sequence codes, branch-aware taxonomy management, and generated SKU previews on the item form
|
||||||
|
|||||||
@@ -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
|
- 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
|
- 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
|
- 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
|
- 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
|
- 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
|
- pegged WO/PO supply tracking back to sales demand with preferred-vendor sourcing on inventory items
|
||||||
@@ -91,7 +91,7 @@ Navigation direction:
|
|||||||
|
|
||||||
## Projects Direction
|
## Projects Direction
|
||||||
|
|
||||||
Projects are now the long-running program and delivery layer for cross-module execution. The current slice ships project records with customer linkage, owner assignment, priority, due dates, milestones, project-side milestone/work-order rollups, cockpit-style commercial/supply/execution/delivery/purchasing visibility, readiness-risk scoring, a cost snapshot from linked purchasing and manufacturing data, notes, commercial document links, shipment links, attachments, and dashboard visibility.
|
Projects are now the long-running program and delivery layer for cross-module execution. The current slice ships project records with customer linkage, owner assignment, priority, due dates, milestones, project-side milestone/work-order rollups, cockpit-style commercial/supply/execution/delivery/purchasing visibility, readiness-risk scoring, a cost snapshot from linked purchasing and manufacturing data, direct launch paths into prefilled purchasing/manufacturing follow-through, an activity timeline across linked execution records, notes, commercial document links, shipment links, attachments, and dashboard visibility.
|
||||||
|
|
||||||
Current interactions:
|
Current interactions:
|
||||||
|
|
||||||
@@ -126,7 +126,7 @@ Next expansion areas:
|
|||||||
|
|
||||||
## Planning Direction
|
## 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:
|
Current interactions:
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ This file tracks roadmap phases, slices, and major foundations that have already
|
|||||||
- Logistics attachments directly on shipment records
|
- Logistics attachments directly on shipment records
|
||||||
- Projects foundation with customer, quote, sales-order, shipment, owner, due-date, notes, and attachment linkage
|
- Projects foundation with customer, quote, sales-order, shipment, owner, due-date, notes, and attachment linkage
|
||||||
- Project milestones and project-side milestone/work-order rollups
|
- Project milestones and project-side milestone/work-order rollups
|
||||||
- Project cockpit section on detail pages for commercial, supply, execution, delivery, purchasing, readiness-risk, and cost-snapshot visibility
|
- Project cockpit section on detail pages for commercial, supply, execution, delivery, purchasing, readiness-risk, and cost-snapshot visibility, with direct launch paths into prefilled project work orders and demand-linked purchase orders plus a project activity timeline
|
||||||
- Project list/detail/create/edit workflows and dashboard program widgets
|
- Project list/detail/create/edit workflows and dashboard program widgets
|
||||||
- Manufacturing foundation with work orders, project linkage, material issue posting, completion posting, and work-order attachments
|
- Manufacturing foundation with work orders, project linkage, material issue posting, completion posting, and work-order attachments
|
||||||
- Manufacturing stations, item routing templates, and automatic work-order operation planning for gantt scheduling
|
- Manufacturing stations, item routing templates, and automatic work-order operation planning for gantt scheduling
|
||||||
@@ -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
|
- 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
|
- Full-site density normalization pass across active CRM, inventory, settings, dashboard, and login screens
|
||||||
- Live planning gantt timelines driven by project and manufacturing data
|
- 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
|
- 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
|
- Multi-stage Docker packaging and migration-aware entrypoint
|
||||||
- Docker image validated locally with successful app startup and login flow
|
- Docker image validated locally with successful app startup and login flow
|
||||||
|
|||||||
@@ -1,31 +1,112 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import { Gantt } from "@svar-ui/react-gantt";
|
import { Gantt } from "@svar-ui/react-gantt";
|
||||||
import "@svar-ui/react-gantt/style.css";
|
import "@svar-ui/react-gantt/style.css";
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
|
|
||||||
import type { DemandPlanningRollupDto, GanttTaskDto, PlanningExceptionDto, PlanningTimelineDto } from "@mrp/shared";
|
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 { useAuth } from "../../auth/AuthProvider";
|
||||||
import { ApiError, api } from "../../lib/api";
|
import { ApiError, api } from "../../lib/api";
|
||||||
import { useTheme } from "../../theme/ThemeProvider";
|
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) {
|
if (!value) {
|
||||||
return "Unscheduled";
|
return "Unscheduled";
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Intl.DateTimeFormat("en-US", {
|
return new Intl.DateTimeFormat("en-US", options ?? {
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
}).format(new Date(value));
|
}).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() {
|
export function GanttPage() {
|
||||||
const { token } = useAuth();
|
const { token } = useAuth();
|
||||||
const { mode } = useTheme();
|
const { mode } = useTheme();
|
||||||
const [timeline, setTimeline] = useState<PlanningTimelineDto | null>(null);
|
const [timeline, setTimeline] = useState<PlanningTimelineDto | null>(null);
|
||||||
const [planningRollup, setPlanningRollup] = useState<DemandPlanningRollupDto | null>(null);
|
const [planningRollup, setPlanningRollup] = useState<DemandPlanningRollupDto | null>(null);
|
||||||
const [status, setStatus] = useState("Loading live planning timeline...");
|
const [status, setStatus] = useState("Loading live planning timeline...");
|
||||||
|
const [workbenchMode, setWorkbenchMode] = useState<WorkbenchMode>("overview");
|
||||||
|
const [selectedFocusId, setSelectedFocusId] = useState<string | null>(null);
|
||||||
|
const [selectedHeatmapDate, setSelectedHeatmapDate] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
@@ -36,7 +117,7 @@ export function GanttPage() {
|
|||||||
.then(([data, rollup]) => {
|
.then(([data, rollup]) => {
|
||||||
setTimeline(data);
|
setTimeline(data);
|
||||||
setPlanningRollup(rollup);
|
setPlanningRollup(rollup);
|
||||||
setStatus("Planning timeline loaded.");
|
setStatus("Planning workbench loaded.");
|
||||||
})
|
})
|
||||||
.catch((error: unknown) => {
|
.catch((error: unknown) => {
|
||||||
const message = error instanceof ApiError ? error.message : "Unable to load planning timeline.";
|
const message = error instanceof ApiError ? error.message : "Unable to load planning timeline.";
|
||||||
@@ -48,149 +129,368 @@ export function GanttPage() {
|
|||||||
const links = timeline?.links ?? [];
|
const links = timeline?.links ?? [];
|
||||||
const summary = timeline?.summary;
|
const summary = timeline?.summary;
|
||||||
const exceptions = timeline?.exceptions ?? [];
|
const exceptions = timeline?.exceptions ?? [];
|
||||||
const ganttCellHeight = 44;
|
const focusRecords = useMemo(() => buildFocusRecords(tasks), [tasks]);
|
||||||
const ganttScaleHeight = 56;
|
const focusById = useMemo(() => new Map(focusRecords.map((record) => [record.id, record])), [focusRecords]);
|
||||||
const ganttHeight = Math.max(420, tasks.length * ganttCellHeight + ganttScaleHeight);
|
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<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 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 (
|
return (
|
||||||
<section className="space-y-4">
|
<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="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
<div className="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Planning</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Planning</p>
|
||||||
<h3 className="mt-2 text-2xl font-bold text-text">Live Project + Manufacturing Gantt</h3>
|
<h3 className="mt-2 text-2xl font-bold text-text">Planning Workbench</h3>
|
||||||
<p className="mt-2 max-w-3xl text-sm text-muted">
|
<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>
|
||||||
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.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-[18px] border border-line/70 bg-page/60 px-3 py-3 text-sm">
|
<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">Timeline Status</div>
|
<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 className="mt-2 font-semibold text-text">{status}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="mt-5 grid gap-3 xl:grid-cols-4">
|
||||||
<section className="grid gap-3 xl:grid-cols-6">
|
{modeOptions.map((option) => (
|
||||||
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
<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"}`}>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Active Projects</p>
|
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">{option.label}</div>
|
||||||
<div className="mt-2 text-xl font-extrabold text-text">{summary?.activeProjects ?? 0}</div>
|
<div className="mt-2 text-sm font-semibold text-text">{option.detail}</div>
|
||||||
</article>
|
</button>
|
||||||
<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">At Risk</p>
|
|
||||||
<div className="mt-2 text-xl font-extrabold text-text">{summary?.atRiskProjects ?? 0}</div>
|
|
||||||
</article>
|
|
||||||
<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">Overdue Projects</p>
|
|
||||||
<div className="mt-2 text-xl font-extrabold text-text">{summary?.overdueProjects ?? 0}</div>
|
|
||||||
</article>
|
|
||||||
<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">Active Work Orders</p>
|
|
||||||
<div className="mt-2 text-xl font-extrabold text-text">{summary?.activeWorkOrders ?? 0}</div>
|
|
||||||
</article>
|
|
||||||
<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">Overdue Work</p>
|
|
||||||
<div className="mt-2 text-xl font-extrabold text-text">{summary?.overdueWorkOrders ?? 0}</div>
|
|
||||||
</article>
|
|
||||||
<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">Unscheduled Work</p>
|
|
||||||
<div className="mt-2 text-xl font-extrabold text-text">{summary?.unscheduledWorkOrders ?? 0}</div>
|
|
||||||
</article>
|
|
||||||
<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">Shortage Items</p>
|
|
||||||
<div className="mt-2 text-xl font-extrabold text-text">{planningRollup?.summary.uncoveredItemCount ?? 0}</div>
|
|
||||||
</article>
|
|
||||||
<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">Build / Buy</p>
|
|
||||||
<div className="mt-2 text-xl font-extrabold text-text">
|
|
||||||
{planningRollup ? `${planningRollup.summary.totalBuildQuantity} / ${planningRollup.summary.totalPurchaseQuantity}` : "0 / 0"}
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</section>
|
|
||||||
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.2fr)_360px]">
|
|
||||||
<div
|
|
||||||
className={`gantt-theme overflow-x-auto overflow-y-visible rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5 ${
|
|
||||||
mode === "dark" ? "wx-willow-dark-theme" : "wx-willow-theme"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Schedule Window</p>
|
|
||||||
<p className="mt-2 text-sm text-muted">
|
|
||||||
{summary ? `${formatDate(summary.horizonStart)} through ${formatDate(summary.horizonEnd)}` : "Waiting for live schedule data."}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-2xl border border-line/70 bg-page/60 px-3 py-2 text-xs font-semibold uppercase tracking-[0.18em] text-muted">
|
|
||||||
{tasks.length} schedule rows
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ height: `${ganttHeight}px`, minWidth: "100%" }}>
|
|
||||||
<Gantt
|
|
||||||
tasks={tasks.map((task: GanttTaskDto) => ({
|
|
||||||
...task,
|
|
||||||
start: new Date(task.start),
|
|
||||||
end: new Date(task.end),
|
|
||||||
parent: task.parentId ?? undefined,
|
|
||||||
}))}
|
|
||||||
links={links}
|
|
||||||
cellHeight={ganttCellHeight}
|
|
||||||
scaleHeight={ganttScaleHeight}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="grid gap-3 xl:grid-cols-8">
|
||||||
|
<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="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">
|
<aside className="space-y-3">
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<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">Planning Exceptions</p>
|
<div className="flex items-center justify-between gap-3">
|
||||||
<p className="mt-2 text-sm text-muted">Priority schedule issues from live project due dates and manufacturing commitments.</p>
|
<div>
|
||||||
{exceptions.length === 0 ? (
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Exception Rail</p>
|
||||||
<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">
|
<p className="mt-2 text-sm text-muted">Late, at-risk, and unscheduled items that require planner attention.</p>
|
||||||
No planning exceptions are active.
|
|
||||||
</div>
|
</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">
|
<div className="mt-5 space-y-3">
|
||||||
{exceptions.map((exception: PlanningExceptionDto) => (
|
{exceptions.map((exception: PlanningExceptionDto) => (
|
||||||
<Link key={exception.id} to={exception.detailHref} className="block rounded-[18px] border border-line/70 bg-page/60 p-3 transition hover:bg-page/80">
|
<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 className="flex items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">{exception.kind === "PROJECT" ? "Project" : "Work Order"}</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-1 font-semibold text-text">{exception.title}</div>
|
||||||
<div className="mt-2 text-xs text-muted">{exception.ownerLabel ?? "No owner context"}</div>
|
<div className="mt-2 text-xs text-muted">{exception.ownerLabel ?? "No owner context"}</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="rounded-full border border-line/70 px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-muted">
|
<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>
|
||||||
{exception.status.replaceAll("_", " ")}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 text-xs text-muted">Due: {formatDate(exception.dueDate)}</div>
|
<div className="mt-3 text-xs text-muted">Due: {formatDate(exception.dueDate)}</div>
|
||||||
</Link>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<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>
|
<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="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">
|
<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>
|
||||||
<span className="text-muted">Uncovered quantity</span>
|
<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>
|
||||||
<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>
|
</div>
|
||||||
<div className="mt-4 flex flex-wrap gap-2">
|
<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">
|
<Link to="/projects" className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Open projects</Link>
|
||||||
Open projects
|
<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>
|
<Link to="/purchasing/orders" className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Open purchasing</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="/" className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
|
||||||
Back to dashboard
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
<div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel">
|
||||||
|
{workbenchMode === "overview" ? <OverviewBoard focusRecords={focusRecords} onSelect={setSelectedFocusId} /> : null}
|
||||||
|
{workbenchMode === "gantt" ? (
|
||||||
|
<div className={`gantt-theme overflow-x-auto overflow-y-visible rounded-[18px] border border-line/70 bg-page/60 p-4 ${mode === "dark" ? "wx-willow-dark-theme" : "wx-willow-theme"}`}>
|
||||||
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Schedule Window</p>
|
||||||
|
<p className="mt-2 text-sm text-muted">{summary ? `${formatDate(summary.horizonStart)} through ${formatDate(summary.horizonEnd)}` : "Waiting for live schedule data."}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-line/70 bg-surface/80 px-3 py-2 text-xs font-semibold uppercase tracking-[0.18em] text-muted">{tasks.length} schedule rows</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ height: `${ganttHeight}px`, minWidth: "100%" }}>
|
||||||
|
<Gantt
|
||||||
|
tasks={tasks.map((task: GanttTaskDto) => ({
|
||||||
|
...task,
|
||||||
|
start: new Date(task.start),
|
||||||
|
end: new Date(task.end),
|
||||||
|
parent: task.parentId ?? undefined,
|
||||||
|
}))}
|
||||||
|
links={links}
|
||||||
|
cellHeight={ganttCellHeight}
|
||||||
|
scaleHeight={ganttScaleHeight}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : 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">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>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{selectedFocus.detailHref ? <Link to={selectedFocus.detailHref} className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white">Open record</Link> : null}
|
||||||
|
<button type="button" onClick={() => setWorkbenchMode("gantt")} className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">View in timeline</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>
|
</div>
|
||||||
</section>
|
</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, 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 (
|
||||||
|
<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, and operation load without leaving the planner.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<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.status.replaceAll("_", " ")}</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.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-xs text-muted">{formatDate(record.start)} - {formatDate(record.end)}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<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.status.replaceAll("_", " ")}</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -95,6 +95,8 @@ export function ProjectDetailPage() {
|
|||||||
const materialExceptionItems = planning
|
const materialExceptionItems = planning
|
||||||
? planning.items.filter((item) => item.uncoveredQuantity > 0 || item.recommendedBuildQuantity > 0 || item.recommendedPurchaseQuantity > 0).slice(0, 5)
|
? planning.items.filter((item) => item.uncoveredQuantity > 0 || item.recommendedBuildQuantity > 0 || item.recommendedPurchaseQuantity > 0).slice(0, 5)
|
||||||
: [];
|
: [];
|
||||||
|
const topBuildRecommendation = planning?.items.find((item) => item.recommendedBuildQuantity > 0) ?? null;
|
||||||
|
const topPurchaseRecommendation = planning?.items.find((item) => item.recommendedPurchaseQuantity > 0) ?? null;
|
||||||
const completionPercent = project.rollups.milestoneCount > 0
|
const completionPercent = project.rollups.milestoneCount > 0
|
||||||
? Math.round((project.rollups.completedMilestoneCount / project.rollups.milestoneCount) * 100)
|
? Math.round((project.rollups.completedMilestoneCount / project.rollups.milestoneCount) * 100)
|
||||||
: 0;
|
: 0;
|
||||||
@@ -180,6 +182,62 @@ export function ProjectDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section className="grid gap-3 xl:grid-cols-[minmax(0,1.15fr)_minmax(320px,0.85fr)]">
|
<section className="grid gap-3 xl:grid-cols-[minmax(0,1.15fr)_minmax(320px,0.85fr)]">
|
||||||
|
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Actionable Cockpit</p>
|
||||||
|
<p className="mt-2 text-sm text-muted">Turn current exceptions into purchasing, manufacturing, and planning follow-through.</p>
|
||||||
|
</div>
|
||||||
|
<Link to="/planning/gantt" className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
||||||
|
Open gantt
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 grid gap-3 xl:grid-cols-2">
|
||||||
|
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Build Follow-Through</p>
|
||||||
|
<div className="mt-2 text-base font-bold text-text">{topBuildRecommendation ? topBuildRecommendation.itemSku : "No build recommendation"}</div>
|
||||||
|
<div className="mt-1 text-xs text-muted">
|
||||||
|
{topBuildRecommendation ? `Recommended build qty ${topBuildRecommendation.recommendedBuildQuantity}` : "Planning does not currently recommend a new build."}
|
||||||
|
</div>
|
||||||
|
{topBuildRecommendation && project.salesOrderId ? (
|
||||||
|
<Link
|
||||||
|
to={`/manufacturing/work-orders/new?projectId=${project.id}&itemId=${topBuildRecommendation.itemId}&salesOrderId=${project.salesOrderId}&quantity=${topBuildRecommendation.recommendedBuildQuantity}¬es=${encodeURIComponent(`Project cockpit launch from ${project.projectNumber}`)}`}
|
||||||
|
className="mt-4 inline-flex items-center justify-center rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white"
|
||||||
|
>
|
||||||
|
Launch work order
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Buy Follow-Through</p>
|
||||||
|
<div className="mt-2 text-base font-bold text-text">{topPurchaseRecommendation ? topPurchaseRecommendation.itemSku : "No buy recommendation"}</div>
|
||||||
|
<div className="mt-1 text-xs text-muted">
|
||||||
|
{topPurchaseRecommendation ? `Recommended buy qty ${topPurchaseRecommendation.recommendedPurchaseQuantity}` : "Planning does not currently recommend a new purchase."}
|
||||||
|
</div>
|
||||||
|
{topPurchaseRecommendation && project.salesOrderId ? (
|
||||||
|
<Link
|
||||||
|
to={`/purchasing/orders/new?planningOrderId=${project.salesOrderId}&itemId=${topPurchaseRecommendation.itemId}`}
|
||||||
|
className="mt-4 inline-flex items-center justify-center rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white"
|
||||||
|
>
|
||||||
|
Launch purchase order
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex flex-wrap gap-3">
|
||||||
|
<Link to={`/manufacturing/work-orders/new?projectId=${project.id}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
||||||
|
New project work order
|
||||||
|
</Link>
|
||||||
|
{project.salesOrderId ? (
|
||||||
|
<Link to={`/sales/orders/${project.salesOrderId}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
||||||
|
Open sales order
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
<Link to="/purchasing/orders" className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
||||||
|
Review purchasing
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
<div className="flex items-center justify-between gap-3"><div><p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Linked Purchasing</p><p className="mt-2 text-sm text-muted">Purchase orders and receipts tied back to the project sales order.</p></div>{project.salesOrderId ? <Link to="/purchasing/orders" className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Open purchasing</Link> : null}</div>
|
<div className="flex items-center justify-between gap-3"><div><p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Linked Purchasing</p><p className="mt-2 text-sm text-muted">Purchase orders and receipts tied back to the project sales order.</p></div>{project.salesOrderId ? <Link to="/purchasing/orders" className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Open purchasing</Link> : null}</div>
|
||||||
{project.cockpit.purchasing.purchaseOrders.length === 0 ? <div className="mt-6 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No linked purchase orders are tied to this project yet.</div> : <div className="mt-6 space-y-3">{project.cockpit.purchasing.purchaseOrders.slice(0, 5).map((purchaseOrder) => (<Link key={purchaseOrder.id} to={`/purchasing/orders/${purchaseOrder.id}`} className="block rounded-[18px] border border-line/70 bg-page/60 p-3 transition hover:bg-page/80"><div className="flex flex-wrap items-start justify-between gap-3"><div><div className="font-semibold text-text">{purchaseOrder.documentNumber}</div><div className="mt-1 text-xs text-muted">{purchaseOrder.vendorName} - {purchaseOrder.status.replaceAll("_", " ")}</div></div><div className="text-right text-xs text-muted"><div>${purchaseOrder.linkedLineValue.toFixed(2)} linked value</div><div>{purchaseOrder.totalReceivedQuantity}/{purchaseOrder.totalOrderedQuantity} received</div></div></div></Link>))}</div>}
|
{project.cockpit.purchasing.purchaseOrders.length === 0 ? <div className="mt-6 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No linked purchase orders are tied to this project yet.</div> : <div className="mt-6 space-y-3">{project.cockpit.purchasing.purchaseOrders.slice(0, 5).map((purchaseOrder) => (<Link key={purchaseOrder.id} to={`/purchasing/orders/${purchaseOrder.id}`} className="block rounded-[18px] border border-line/70 bg-page/60 p-3 transition hover:bg-page/80"><div className="flex flex-wrap items-start justify-between gap-3"><div><div className="font-semibold text-text">{purchaseOrder.documentNumber}</div><div className="mt-1 text-xs text-muted">{purchaseOrder.vendorName} - {purchaseOrder.status.replaceAll("_", " ")}</div></div><div className="text-right text-xs text-muted"><div>${purchaseOrder.linkedLineValue.toFixed(2)} linked value</div><div>{purchaseOrder.totalReceivedQuantity}/{purchaseOrder.totalOrderedQuantity} received</div></div></div></Link>))}</div>}
|
||||||
@@ -256,6 +314,34 @@ export function ProjectDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
{workOrders.length === 0 ? <div className="mt-6 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No work orders are linked to this project yet.</div> : <div className="mt-6 space-y-3">{workOrders.map((workOrder) => (<Link key={workOrder.id} to={`/manufacturing/work-orders/${workOrder.id}`} className="block rounded-[18px] border border-line/70 bg-page/60 p-3 transition hover:bg-page/80"><div className="flex flex-wrap items-center justify-between gap-3"><div><div className="font-semibold text-text">{workOrder.workOrderNumber}</div><div className="mt-1 text-xs text-muted">{workOrder.itemSku} - {workOrder.completedQuantity}/{workOrder.quantity} complete</div></div><div className="text-sm font-semibold text-text">{workOrder.status.replace("_", " ")}</div></div></Link>))}</div>}
|
{workOrders.length === 0 ? <div className="mt-6 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No work orders are linked to this project yet.</div> : <div className="mt-6 space-y-3">{workOrders.map((workOrder) => (<Link key={workOrder.id} to={`/manufacturing/work-orders/${workOrder.id}`} className="block rounded-[18px] border border-line/70 bg-page/60 p-3 transition hover:bg-page/80"><div className="flex flex-wrap items-center justify-between gap-3"><div><div className="font-semibold text-text">{workOrder.workOrderNumber}</div><div className="mt-1 text-xs text-muted">{workOrder.itemSku} - {workOrder.completedQuantity}/{workOrder.quantity} complete</div></div><div className="text-sm font-semibold text-text">{workOrder.status.replace("_", " ")}</div></div></Link>))}</div>}
|
||||||
</section>
|
</section>
|
||||||
|
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div><p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Activity Timeline</p><p className="mt-2 text-sm text-muted">Chronological project, milestone, purchasing, manufacturing, sales, and shipping history.</p></div>
|
||||||
|
</div>
|
||||||
|
{project.timeline.length === 0 ? (
|
||||||
|
<div className="mt-6 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No timeline activity is available for this project yet.</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-6 space-y-3">
|
||||||
|
{project.timeline.map((entry) => (
|
||||||
|
<div key={entry.id} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">{entry.sourceType}</div>
|
||||||
|
<div className="mt-1 font-semibold text-text">
|
||||||
|
{entry.href ? <Link to={entry.href} className="hover:text-brand">{entry.title}</Link> : entry.title}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-sm text-muted">{entry.detail}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right text-xs text-muted">
|
||||||
|
<div>{new Date(entry.createdAt).toLocaleString()}</div>
|
||||||
|
<div>{entry.actorName || "System"}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
<FileAttachmentsPanel ownerType="PROJECT" ownerId={project.id} eyebrow="Project Documents" title="Program file hub" description="Store drawings, revision references, correspondence, and support files directly on the project record." emptyMessage="No project files have been uploaded yet." />
|
<FileAttachmentsPanel ownerType="PROJECT" ownerId={project.id} eyebrow="Project Documents" title="Program file hub" description="Store drawings, revision references, correspondence, and support files directly on the project record." emptyMessage="No project files have been uploaded yet." />
|
||||||
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm text-muted">{status}</div>
|
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm text-muted">{status}</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
1
fabdash
Submodule
1
fabdash
Submodule
Submodule fabdash added at fe4d8b120c
@@ -4,6 +4,7 @@ import type {
|
|||||||
ProjectCockpitReceiptDto,
|
ProjectCockpitReceiptDto,
|
||||||
ProjectCockpitRiskLevel,
|
ProjectCockpitRiskLevel,
|
||||||
ProjectCockpitVendorDto,
|
ProjectCockpitVendorDto,
|
||||||
|
ProjectTimelineEntryDto,
|
||||||
ProjectCustomerOptionDto,
|
ProjectCustomerOptionDto,
|
||||||
ProjectDetailDto,
|
ProjectDetailDto,
|
||||||
ProjectDocumentOptionDto,
|
ProjectDocumentOptionDto,
|
||||||
@@ -156,6 +157,19 @@ type ProjectCostWorkOrderRecord = {
|
|||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ProjectAuditEventRecord = {
|
||||||
|
id: string;
|
||||||
|
entityType: string;
|
||||||
|
entityId: string | null;
|
||||||
|
action: string;
|
||||||
|
summary: string;
|
||||||
|
createdAt: Date;
|
||||||
|
actor: {
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
function roundMoney(value: number) {
|
function roundMoney(value: number) {
|
||||||
return Math.round(value * 100) / 100;
|
return Math.round(value * 100) / 100;
|
||||||
}
|
}
|
||||||
@@ -223,6 +237,160 @@ function deriveProjectRiskLevel(score: number): ProjectCockpitRiskLevel {
|
|||||||
return "HIGH";
|
return "HIGH";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getActorName(actor: { firstName: string; lastName: string } | null) {
|
||||||
|
return actor ? `${actor.firstName} ${actor.lastName}`.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildProjectTimeline(record: ProjectRecord): Promise<ProjectTimelineEntryDto[]> {
|
||||||
|
const relatedEntityFilters = [
|
||||||
|
{ entityType: "project", entityId: record.id },
|
||||||
|
...(record.salesQuote ? [{ entityType: "sales-quote", entityId: record.salesQuote.id }] : []),
|
||||||
|
...(record.salesOrder ? [{ entityType: "sales-order", entityId: record.salesOrder.id }] : []),
|
||||||
|
...(record.shipment ? [{ entityType: "shipment", entityId: record.shipment.id }] : []),
|
||||||
|
...record.workOrders.map((workOrder) => ({ entityType: "work-order", entityId: workOrder.id })),
|
||||||
|
];
|
||||||
|
|
||||||
|
const [auditEvents, purchaseOrders] = await Promise.all([
|
||||||
|
prisma.auditEvent.findMany({
|
||||||
|
where: {
|
||||||
|
OR: relatedEntityFilters,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
actor: {
|
||||||
|
select: {
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [{ createdAt: "desc" }],
|
||||||
|
take: 30,
|
||||||
|
}),
|
||||||
|
record.salesOrder
|
||||||
|
? prisma.purchaseOrder.findMany({
|
||||||
|
where: {
|
||||||
|
lines: {
|
||||||
|
some: {
|
||||||
|
salesOrderId: record.salesOrder.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
documentNumber: true,
|
||||||
|
createdAt: true,
|
||||||
|
receipts: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
receiptNumber: true,
|
||||||
|
receivedAt: true,
|
||||||
|
createdBy: {
|
||||||
|
select: {
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [{ receivedAt: "desc" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: Promise.resolve([]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const timeline: ProjectTimelineEntryDto[] = [];
|
||||||
|
|
||||||
|
for (const milestone of record.milestones) {
|
||||||
|
timeline.push({
|
||||||
|
id: `milestone-${milestone.id}-created`,
|
||||||
|
sourceType: "MILESTONE",
|
||||||
|
title: `Milestone planned: ${milestone.title}`,
|
||||||
|
detail: milestone.dueDate ? `Due ${milestone.dueDate.toLocaleDateString()}` : "No due date assigned",
|
||||||
|
createdAt: milestone.dueDate?.toISOString() ?? new Date(0).toISOString(),
|
||||||
|
actorName: null,
|
||||||
|
href: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (milestone.completedAt) {
|
||||||
|
timeline.push({
|
||||||
|
id: `milestone-${milestone.id}-completed`,
|
||||||
|
sourceType: "MILESTONE",
|
||||||
|
title: `Milestone completed: ${milestone.title}`,
|
||||||
|
detail: "Checkpoint marked complete.",
|
||||||
|
createdAt: milestone.completedAt.toISOString(),
|
||||||
|
actorName: null,
|
||||||
|
href: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const auditEvent of auditEvents as ProjectAuditEventRecord[]) {
|
||||||
|
let sourceType: ProjectTimelineEntryDto["sourceType"] = "PROJECT";
|
||||||
|
let href: string | null = null;
|
||||||
|
if (auditEvent.entityType === "sales-quote" || auditEvent.entityType === "sales-order") {
|
||||||
|
sourceType = "SALES";
|
||||||
|
href = auditEvent.entityType === "sales-quote" ? `/sales/quotes/${auditEvent.entityId}` : `/sales/orders/${auditEvent.entityId}`;
|
||||||
|
} else if (auditEvent.entityType === "shipment") {
|
||||||
|
sourceType = "SHIPPING";
|
||||||
|
href = `/shipping/shipments/${auditEvent.entityId}`;
|
||||||
|
} else if (auditEvent.entityType === "work-order") {
|
||||||
|
sourceType = "MANUFACTURING";
|
||||||
|
href = `/manufacturing/work-orders/${auditEvent.entityId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
timeline.push({
|
||||||
|
id: `audit-${auditEvent.id}`,
|
||||||
|
sourceType,
|
||||||
|
title: auditEvent.summary,
|
||||||
|
detail: `${auditEvent.entityType} · ${auditEvent.action}`.replaceAll("-", " "),
|
||||||
|
createdAt: auditEvent.createdAt.toISOString(),
|
||||||
|
actorName: getActorName(auditEvent.actor),
|
||||||
|
href,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const purchaseOrder of purchaseOrders as Array<{
|
||||||
|
id: string;
|
||||||
|
documentNumber: string;
|
||||||
|
createdAt: Date;
|
||||||
|
receipts: Array<{
|
||||||
|
id: string;
|
||||||
|
receiptNumber: string;
|
||||||
|
receivedAt: Date;
|
||||||
|
createdBy: {
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
} | null;
|
||||||
|
}>;
|
||||||
|
}>) {
|
||||||
|
timeline.push({
|
||||||
|
id: `purchase-order-${purchaseOrder.id}`,
|
||||||
|
sourceType: "PURCHASING",
|
||||||
|
title: `Linked purchase order ${purchaseOrder.documentNumber}`,
|
||||||
|
detail: "Project demand is now covered by purchasing.",
|
||||||
|
createdAt: purchaseOrder.createdAt.toISOString(),
|
||||||
|
actorName: null,
|
||||||
|
href: `/purchasing/orders/${purchaseOrder.id}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const receipt of purchaseOrder.receipts) {
|
||||||
|
timeline.push({
|
||||||
|
id: `receipt-${receipt.id}`,
|
||||||
|
sourceType: "PURCHASING",
|
||||||
|
title: `Receipt posted: ${receipt.receiptNumber}`,
|
||||||
|
detail: `Received against ${purchaseOrder.documentNumber}.`,
|
||||||
|
createdAt: receipt.receivedAt.toISOString(),
|
||||||
|
actorName: getActorName(receipt.createdBy),
|
||||||
|
href: `/purchasing/orders/${purchaseOrder.id}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return timeline
|
||||||
|
.sort((left, right) => new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime())
|
||||||
|
.slice(0, 20);
|
||||||
|
}
|
||||||
|
|
||||||
async function buildProjectCockpit(record: ProjectRecord, rollups: ProjectRollupDto): Promise<ProjectCockpitDto> {
|
async function buildProjectCockpit(record: ProjectRecord, rollups: ProjectRollupDto): Promise<ProjectCockpitDto> {
|
||||||
const blockedMilestoneCount = record.milestones.filter((milestone) => milestone.status === "BLOCKED").length;
|
const blockedMilestoneCount = record.milestones.filter((milestone) => milestone.status === "BLOCKED").length;
|
||||||
|
|
||||||
@@ -594,7 +762,7 @@ function mapProjectSummary(record: ProjectRecord): ProjectSummaryDto {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapProjectDetail(record: ProjectRecord, cockpit: ProjectCockpitDto): ProjectDetailDto {
|
function mapProjectDetail(record: ProjectRecord, cockpit: ProjectCockpitDto, timeline: ProjectTimelineEntryDto[]): ProjectDetailDto {
|
||||||
return {
|
return {
|
||||||
...mapProjectSummary(record),
|
...mapProjectSummary(record),
|
||||||
notes: record.notes,
|
notes: record.notes,
|
||||||
@@ -609,6 +777,7 @@ function mapProjectDetail(record: ProjectRecord, cockpit: ProjectCockpitDto): Pr
|
|||||||
customerPhone: record.customer.phone,
|
customerPhone: record.customer.phone,
|
||||||
milestones: record.milestones.map(mapProjectMilestone),
|
milestones: record.milestones.map(mapProjectMilestone),
|
||||||
cockpit,
|
cockpit,
|
||||||
|
timeline,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -980,8 +1149,11 @@ export async function getProjectById(projectId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mappedProject = project as ProjectRecord;
|
const mappedProject = project as ProjectRecord;
|
||||||
const cockpit = await buildProjectCockpit(mappedProject, buildProjectRollups(mappedProject));
|
const [cockpit, timeline] = await Promise.all([
|
||||||
return mapProjectDetail(mappedProject, cockpit);
|
buildProjectCockpit(mappedProject, buildProjectRollups(mappedProject)),
|
||||||
|
buildProjectTimeline(mappedProject),
|
||||||
|
]);
|
||||||
|
return mapProjectDetail(mappedProject, cockpit, timeline);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createProject(payload: ProjectInput, actorId?: string | null) {
|
export async function createProject(payload: ProjectInput, actorId?: string | null) {
|
||||||
|
|||||||
@@ -170,6 +170,16 @@ export interface ProjectCockpitDto {
|
|||||||
risk: ProjectCockpitRiskDto;
|
risk: ProjectCockpitRiskDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProjectTimelineEntryDto {
|
||||||
|
id: string;
|
||||||
|
sourceType: "PROJECT" | "MILESTONE" | "SALES" | "PURCHASING" | "MANUFACTURING" | "SHIPPING";
|
||||||
|
title: string;
|
||||||
|
detail: string;
|
||||||
|
createdAt: string;
|
||||||
|
actorName: string | null;
|
||||||
|
href: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProjectMilestoneInput {
|
export interface ProjectMilestoneInput {
|
||||||
id?: string | null;
|
id?: string | null;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -192,6 +202,7 @@ export interface ProjectDetailDto extends ProjectSummaryDto {
|
|||||||
customerPhone: string;
|
customerPhone: string;
|
||||||
milestones: ProjectMilestoneDto[];
|
milestones: ProjectMilestoneDto[];
|
||||||
cockpit: ProjectCockpitDto;
|
cockpit: ProjectCockpitDto;
|
||||||
|
timeline: ProjectTimelineEntryDto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProjectInput {
|
export interface ProjectInput {
|
||||||
|
|||||||
Reference in New Issue
Block a user