planning payload
This commit is contained in:
@@ -8,6 +8,7 @@ This file is the running release and change log for CODEXIUM. Keep it updated wh
|
|||||||
|
|
||||||
- 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
|
- 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 planning screen with mode switching, dense exception rail, heatmap load view, agenda view, and focus drawer
|
- Planning workbench replacing the old one-note planning screen with mode switching, dense exception rail, heatmap load view, agenda view, and focus drawer
|
||||||
|
- Planning workbench dispatch upgrade with station load summaries, readiness scoring, release-ready and blocker filters, richer planner rows, and inline release/build/buy actions
|
||||||
- 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 workbench with live project/manufacturing schedule data, exception rail, heatmap load view, agenda view, and focus drawer
|
- planning workbench with live project/manufacturing schedule data, exception rail, heatmap load view, agenda view, focus drawer, station load grouping, readiness filters, and inline dispatch actions
|
||||||
- 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
|
||||||
@@ -126,12 +126,12 @@ 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 planning workbench backed by active projects, due-date milestones, linked work orders, standalone manufacturing queue visibility, exception rails, dense load heatmaps, focus-drawer inspection, and agenda sequencing.
|
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, station load summaries, readiness scoring, focus-drawer inspection, inline release/build/buy follow-through, and agenda sequencing.
|
||||||
|
|
||||||
Current interactions:
|
Current interactions:
|
||||||
|
|
||||||
- Projects: project timelines and due dates anchor the top-level planning rows
|
- Projects: project timelines and due dates anchor the top-level planning rows
|
||||||
- Manufacturing: open work orders feed task rows, sequencing links, and execution progress
|
- Manufacturing: open work orders feed task rows, sequencing links, execution progress, release-ready state, and station capacity load
|
||||||
- Dashboard: planning now appears as a first-class module with schedule visibility links
|
- Dashboard: planning now appears as a first-class module with schedule visibility links
|
||||||
|
|
||||||
Next expansion areas:
|
Next expansion areas:
|
||||||
|
|||||||
@@ -102,13 +102,13 @@ This file tracks work that still needs to be completed. Shipped phase history an
|
|||||||
### Planning and scheduling
|
### Planning and scheduling
|
||||||
|
|
||||||
- Task dependencies, milestones, and progress updates
|
- Task dependencies, milestones, and progress updates
|
||||||
- Manufacturing calendar views and bottleneck visibility
|
- Manufacturing calendar views and deeper bottleneck visibility beyond the shipped station load and overload workbench summaries
|
||||||
- Labor and machine scheduling support
|
- Labor and machine scheduling support
|
||||||
- Theme-compliant workbench scheduling surfaces for light/dark mode
|
- Theme-compliant workbench scheduling surfaces for light/dark mode
|
||||||
- Collapsible schedule groupings and saved planner views
|
- Collapsible schedule groupings and saved planner views
|
||||||
- Drag-and-drop rescheduling improvements
|
- Drag-and-drop rescheduling improvements
|
||||||
- Critical-path and overdue highlighting
|
- Critical-path and overdue highlighting
|
||||||
- Capacity warnings for overloaded work centers
|
- Richer finite-capacity warnings and rescheduling controls beyond the shipped workbench overload indicators
|
||||||
- Better mobile and tablet behavior for shop-floor lookups
|
- Better mobile and tablet behavior for shop-floor lookups
|
||||||
- Faster filtering by project, customer, work center, and status
|
- Faster filtering by project, customer, work center, and status
|
||||||
|
|
||||||
|
|||||||
@@ -55,7 +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 workbench timelines driven by project and manufacturing data
|
- Live planning workbench timelines driven by project and manufacturing data
|
||||||
- Planning workbench with heatmap, overview, and agenda modes plus exception rail and focus drawer
|
- Planning workbench with heatmap, overview, and agenda modes plus exception rail, focus drawer, station load grouping, readiness scoring, and inline dispatch actions
|
||||||
- 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
|
||||||
@@ -90,6 +90,7 @@ This file tracks roadmap phases, slices, and major foundations that have already
|
|||||||
- Live workbench schedule backed by active projects and open manufacturing work orders
|
- Live workbench schedule backed by active projects and open manufacturing work orders
|
||||||
- Project due-date milestones, manufacturing sequencing links, and standalone work-queue visibility
|
- Project due-date milestones, manufacturing sequencing links, and standalone work-queue visibility
|
||||||
- Planning exception queue for overdue or at-risk project/manufacturing schedule items
|
- Planning exception queue for overdue or at-risk project/manufacturing schedule items
|
||||||
|
- Station load summaries, release-ready visibility, and inline workbench follow-through actions for release/build/buy dispatch
|
||||||
|
|
||||||
### Phase 8: Demand planning and supply generation
|
### Phase 8: Demand planning and supply generation
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import type { DemandPlanningRollupDto, GanttTaskDto, PlanningExceptionDto, PlanningTimelineDto } from "@mrp/shared";
|
import type {
|
||||||
|
DemandPlanningRollupDto,
|
||||||
|
GanttTaskDto,
|
||||||
|
PlanningExceptionDto,
|
||||||
|
PlanningStationLoadDto,
|
||||||
|
PlanningTaskActionDto,
|
||||||
|
PlanningTimelineDto,
|
||||||
|
} from "@mrp/shared";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link, useNavigate } 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";
|
||||||
@@ -16,6 +23,23 @@ type FocusRecord = {
|
|||||||
progress: number;
|
progress: number;
|
||||||
detailHref: string | null;
|
detailHref: string | null;
|
||||||
parentId: 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;
|
||||||
|
actions: PlanningTaskActionDto[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type HeatmapCell = {
|
type HeatmapCell = {
|
||||||
@@ -26,6 +50,9 @@ type HeatmapCell = {
|
|||||||
tasks: FocusRecord[];
|
tasks: FocusRecord[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type WorkbenchGroup = "projects" | "stations" | "exceptions";
|
||||||
|
type WorkbenchFilter = "all" | "release-ready" | "blocked" | "shortage" | "overdue";
|
||||||
|
|
||||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
function formatDate(value: string | null, options?: Intl.DateTimeFormatOptions) {
|
function formatDate(value: string | null, options?: Intl.DateTimeFormatOptions) {
|
||||||
@@ -91,15 +118,50 @@ function buildFocusRecords(tasks: GanttTaskDto[]) {
|
|||||||
progress: task.progress,
|
progress: task.progress,
|
||||||
detailHref: task.detailHref ?? null,
|
detailHref: task.detailHref ?? null,
|
||||||
parentId: task.parentId ?? 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,
|
||||||
|
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() {
|
export function WorkbenchPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
const { token } = useAuth();
|
const { token } = useAuth();
|
||||||
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 [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 [selectedFocusId, setSelectedFocusId] = useState<string | null>(null);
|
||||||
const [selectedHeatmapDate, setSelectedHeatmapDate] = useState<string | null>(null);
|
const [selectedHeatmapDate, setSelectedHeatmapDate] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -123,9 +185,11 @@ export function WorkbenchPage() {
|
|||||||
const tasks = timeline?.tasks ?? [];
|
const tasks = timeline?.tasks ?? [];
|
||||||
const summary = timeline?.summary;
|
const summary = timeline?.summary;
|
||||||
const exceptions = timeline?.exceptions ?? [];
|
const exceptions = timeline?.exceptions ?? [];
|
||||||
|
const stationLoads = timeline?.stationLoads ?? [];
|
||||||
const focusRecords = useMemo(() => buildFocusRecords(tasks), [tasks]);
|
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 focusById = useMemo(() => new Map(focusRecords.map((record) => [record.id, record])), [focusRecords]);
|
||||||
const selectedFocus = selectedFocusId ? focusById.get(selectedFocusId) ?? null : focusRecords[0] ?? null;
|
const selectedFocus = selectedFocusId ? focusById.get(selectedFocusId) ?? null : filteredFocusRecords[0] ?? focusRecords[0] ?? null;
|
||||||
|
|
||||||
const heatmap = useMemo(() => {
|
const heatmap = useMemo(() => {
|
||||||
const start = summary ? startOfDay(new Date(summary.horizonStart)) : startOfDay(new Date());
|
const start = summary ? startOfDay(new Date(summary.horizonStart)) : startOfDay(new Date());
|
||||||
@@ -135,7 +199,7 @@ export function WorkbenchPage() {
|
|||||||
cells.set(dateKey(nextDate), { dateKey: dateKey(nextDate), count: 0, lateCount: 0, blockedCount: 0, tasks: [] });
|
cells.set(dateKey(nextDate), { dateKey: dateKey(nextDate), count: 0, lateCount: 0, blockedCount: 0, tasks: [] });
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const record of focusRecords) {
|
for (const record of filteredFocusRecords) {
|
||||||
if (record.kind === "PROJECT") {
|
if (record.kind === "PROJECT") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -159,15 +223,16 @@ export function WorkbenchPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return [...cells.values()];
|
return [...cells.values()];
|
||||||
}, [focusRecords, summary]);
|
}, [filteredFocusRecords, summary]);
|
||||||
|
|
||||||
const selectedHeatmapCell = selectedHeatmapDate ? heatmap.find((cell) => cell.dateKey === selectedHeatmapDate) ?? null : null;
|
const selectedHeatmapCell = selectedHeatmapDate ? heatmap.find((cell) => cell.dateKey === selectedHeatmapDate) ?? null : null;
|
||||||
const agendaItems = useMemo(
|
const agendaItems = useMemo(
|
||||||
() => [...focusRecords]
|
() => [...focusRecords]
|
||||||
.filter((record) => record.kind !== "OPERATION")
|
.filter((record) => record.kind !== "OPERATION")
|
||||||
|
.filter((record) => matchesWorkbenchFilter(record, workbenchFilter))
|
||||||
.sort((left, right) => new Date(left.end).getTime() - new Date(right.end).getTime())
|
.sort((left, right) => new Date(left.end).getTime() - new Date(right.end).getTime())
|
||||||
.slice(0, 18),
|
.slice(0, 18),
|
||||||
[focusRecords]
|
[focusRecords, workbenchFilter]
|
||||||
);
|
);
|
||||||
|
|
||||||
const modeOptions: Array<{ value: WorkbenchMode; label: string; detail: string }> = [
|
const modeOptions: Array<{ value: WorkbenchMode; label: string; detail: string }> = [
|
||||||
@@ -175,6 +240,34 @@ export function WorkbenchPage() {
|
|||||||
{ value: "heatmap", label: "Heatmap", detail: "Load by day" },
|
{ value: "heatmap", label: "Heatmap", detail: "Load by day" },
|
||||||
{ value: "agenda", label: "Agenda", detail: "Upcoming due flow" },
|
{ 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 handleTaskAction(action: PlanningTaskActionDto) {
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action.kind === "RELEASE_WORK_ORDER" && action.workOrderId) {
|
||||||
|
await api.updateWorkOrderStatus(token, action.workOrderId, "RELEASED");
|
||||||
|
const refreshed = await api.getPlanningTimeline(token);
|
||||||
|
setTimeline(refreshed);
|
||||||
|
setStatus("Workbench refreshed after release.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action.href) {
|
||||||
|
navigate(action.href);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
@@ -198,15 +291,32 @@ export function WorkbenchPage() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<section className="grid gap-3 xl:grid-cols-8">
|
<section className="grid gap-3 xl:grid-cols-10">
|
||||||
<MetricCard label="Active Projects" value={summary?.activeProjects ?? 0} />
|
<MetricCard label="Active Projects" value={summary?.activeProjects ?? 0} />
|
||||||
<MetricCard label="At Risk" value={summary?.atRiskProjects ?? 0} />
|
<MetricCard label="At Risk" value={summary?.atRiskProjects ?? 0} />
|
||||||
<MetricCard label="Overdue Projects" value={summary?.overdueProjects ?? 0} />
|
<MetricCard label="Overdue Projects" value={summary?.overdueProjects ?? 0} />
|
||||||
<MetricCard label="Active Work" value={summary?.activeWorkOrders ?? 0} />
|
<MetricCard label="Active Work" value={summary?.activeWorkOrders ?? 0} />
|
||||||
<MetricCard label="Overdue Work" value={summary?.overdueWorkOrders ?? 0} />
|
<MetricCard label="Overdue Work" value={summary?.overdueWorkOrders ?? 0} />
|
||||||
<MetricCard label="Unscheduled" value={summary?.unscheduledWorkOrders ?? 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="Shortage Items" value={planningRollup?.summary.uncoveredItemCount ?? 0} />
|
||||||
<MetricCard label="Build / Buy" value={planningRollup ? `${planningRollup.summary.totalBuildQuantity} / ${planningRollup.summary.totalPurchaseQuantity}` : "0 / 0"} />
|
<MetricCard label="Build / Buy" value={planningRollup ? `${planningRollup.summary.totalBuildQuantity} / ${planningRollup.summary.totalPurchaseQuantity}` : "0 / 0"} />
|
||||||
</section>
|
</section>
|
||||||
@@ -229,9 +339,9 @@ export function WorkbenchPage() {
|
|||||||
<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">
|
<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">{exception.status.replaceAll("_", " ")}</span>
|
<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>
|
||||||
@@ -246,6 +356,7 @@ export function WorkbenchPage() {
|
|||||||
<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"><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">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">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>
|
||||||
<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">Open projects</Link>
|
<Link to="/projects" className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Open projects</Link>
|
||||||
@@ -256,7 +367,7 @@ export function WorkbenchPage() {
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel">
|
<div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel">
|
||||||
{workbenchMode === "overview" ? <OverviewBoard focusRecords={focusRecords} onSelect={setSelectedFocusId} /> : null}
|
{workbenchMode === "overview" ? <OverviewBoard focusRecords={filteredFocusRecords} stationLoads={stationLoads} groupMode={workbenchGroup} onSelect={setSelectedFocusId} /> : null}
|
||||||
{workbenchMode === "heatmap" ? <HeatmapBoard heatmap={heatmap} selectedDate={selectedHeatmapDate} onSelectDate={setSelectedHeatmapDate} /> : null}
|
{workbenchMode === "heatmap" ? <HeatmapBoard heatmap={heatmap} selectedDate={selectedHeatmapDate} onSelectDate={setSelectedHeatmapDate} /> : null}
|
||||||
{workbenchMode === "agenda" ? <AgendaBoard records={agendaItems} onSelect={setSelectedFocusId} /> : null}
|
{workbenchMode === "agenda" ? <AgendaBoard records={agendaItems} onSelect={setSelectedFocusId} /> : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -273,11 +384,24 @@ export function WorkbenchPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3 text-sm">
|
<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="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">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">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>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<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}
|
{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>
|
<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>
|
</div>
|
||||||
@@ -306,54 +430,117 @@ function MetricCard({ label, value }: { label: string; value: string | number })
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function OverviewBoard({ focusRecords, onSelect }: { focusRecords: FocusRecord[]; onSelect: (id: string) => void }) {
|
function OverviewBoard({
|
||||||
|
focusRecords,
|
||||||
|
stationLoads,
|
||||||
|
groupMode,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
focusRecords: FocusRecord[];
|
||||||
|
stationLoads: PlanningStationLoadDto[];
|
||||||
|
groupMode: WorkbenchGroup;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
}) {
|
||||||
const projects = focusRecords.filter((record) => record.kind === "PROJECT").slice(0, 6);
|
const projects = focusRecords.filter((record) => record.kind === "PROJECT").slice(0, 6);
|
||||||
const operations = focusRecords.filter((record) => record.kind === "OPERATION").slice(0, 10);
|
const operations = focusRecords.filter((record) => record.kind === "OPERATION").slice(0, 10);
|
||||||
const workOrders = focusRecords.filter((record) => record.kind === "WORK_ORDER").slice(0, 10);
|
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);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Overview</p>
|
<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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.15fr)_minmax(0,0.85fr)]">
|
{groupMode === "projects" ? (
|
||||||
<section className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.15fr)_minmax(0,0.85fr)]">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Program Queue</p>
|
<section className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
||||||
<div className="mt-3 space-y-3">
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Program Queue</p>
|
||||||
{projects.map((record) => (
|
<div className="mt-3 space-y-3">
|
||||||
<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">
|
{projects.map((record) => (
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<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.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>
|
||||||
<div className="font-semibold text-text">{record.title}</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 className="mt-1 text-xs text-muted">{record.ownerLabel ?? "No parent work order"}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right text-xs text-muted">
|
<div className="text-right text-xs text-muted">
|
||||||
<div>{record.status.replaceAll("_", " ")}</div>
|
<div>{record.stationCode ?? "No station"}</div>
|
||||||
<div>{record.progress}% progress</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">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Work Center Load</p>
|
||||||
|
<div className="mt-3 grid gap-3 xl:grid-cols-2">
|
||||||
|
{stationLoads.slice(0, 10).map((station) => (
|
||||||
|
<div key={station.stationId} className="rounded-[16px] border border-line/70 bg-surface/80 p-3">
|
||||||
|
<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>
|
</div>
|
||||||
</button>
|
<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>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
) : null}
|
||||||
|
{groupMode === "exceptions" ? (
|
||||||
<section className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
<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>
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Dispatch Exceptions</p>
|
||||||
<div className="mt-3 space-y-2">
|
<div className="mt-3 space-y-2">
|
||||||
{operations.map((record) => (
|
{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">
|
<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>
|
||||||
<div className="font-semibold text-text">{record.title}</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 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>
|
</div>
|
||||||
<div className="text-xs text-muted">{formatDate(record.start)} - {formatDate(record.end)}</div>
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
) : null}
|
||||||
<section className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
<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>
|
<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">
|
<div className="mt-3 grid gap-3 xl:grid-cols-2">
|
||||||
@@ -361,7 +548,7 @@ function OverviewBoard({ focusRecords, onSelect }: { focusRecords: FocusRecord[]
|
|||||||
<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">
|
<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="font-semibold text-text">{record.title}</div>
|
||||||
<div className="mt-2 flex items-center justify-between gap-3 text-xs text-muted">
|
<div className="mt-2 flex items-center justify-between gap-3 text-xs text-muted">
|
||||||
<span>{record.status.replaceAll("_", " ")}</span>
|
<span>{record.readinessState}</span>
|
||||||
<span>{record.progress}%</span>
|
<span>{record.progress}%</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,8 +1,94 @@
|
|||||||
import type { GanttLinkDto, GanttTaskDto, PlanningTimelineDto } from "@mrp/shared";
|
import type {
|
||||||
|
GanttLinkDto,
|
||||||
|
GanttTaskDto,
|
||||||
|
PlanningReadinessState,
|
||||||
|
PlanningStationLoadDto,
|
||||||
|
PlanningTaskActionDto,
|
||||||
|
PlanningTimelineDto,
|
||||||
|
} from "@mrp/shared";
|
||||||
|
|
||||||
import { prisma } from "../../lib/prisma.js";
|
import { prisma } from "../../lib/prisma.js";
|
||||||
|
|
||||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
const SHIFT_MINUTES_PER_DAY = 8 * 60;
|
||||||
|
|
||||||
|
type PlanningProjectRecord = {
|
||||||
|
id: string;
|
||||||
|
projectNumber: string;
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
dueDate: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
customer: { name: string };
|
||||||
|
owner: { firstName: string; lastName: string } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PlanningWorkOrderRecord = {
|
||||||
|
id: string;
|
||||||
|
workOrderNumber: string;
|
||||||
|
status: string;
|
||||||
|
quantity: number;
|
||||||
|
completedQuantity: number;
|
||||||
|
dueDate: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
projectId: string | null;
|
||||||
|
salesOrderId: string | null;
|
||||||
|
salesOrderLineId: string | null;
|
||||||
|
warehouseId: string;
|
||||||
|
locationId: string;
|
||||||
|
item: {
|
||||||
|
id: string;
|
||||||
|
sku: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
isPurchasable: boolean;
|
||||||
|
bomLines: Array<{
|
||||||
|
quantity: number;
|
||||||
|
componentItem: {
|
||||||
|
id: string;
|
||||||
|
sku: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
isPurchasable: boolean;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
operations: Array<{
|
||||||
|
id: string;
|
||||||
|
sequence: number;
|
||||||
|
plannedStart: Date;
|
||||||
|
plannedEnd: Date;
|
||||||
|
plannedMinutes: number;
|
||||||
|
station: { id: string; code: string; name: string };
|
||||||
|
}>;
|
||||||
|
materialIssues: Array<{ componentItemId: string; quantity: number }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WorkOrderInsight = {
|
||||||
|
readinessState: PlanningReadinessState;
|
||||||
|
readinessScore: number;
|
||||||
|
shortageItemCount: number;
|
||||||
|
totalShortageQuantity: number;
|
||||||
|
linkedSupplyQuantity: number;
|
||||||
|
openSupplyQuantity: number;
|
||||||
|
releaseReady: boolean;
|
||||||
|
blockedReason: string | null;
|
||||||
|
overdue: boolean;
|
||||||
|
actions: PlanningTaskActionDto[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type StationAccumulator = {
|
||||||
|
stationId: string;
|
||||||
|
stationCode: string;
|
||||||
|
stationName: string;
|
||||||
|
operationCount: number;
|
||||||
|
workOrderIds: Set<string>;
|
||||||
|
totalPlannedMinutes: number;
|
||||||
|
blockedCount: number;
|
||||||
|
readyCount: number;
|
||||||
|
lateCount: number;
|
||||||
|
dayKeys: Set<string>;
|
||||||
|
};
|
||||||
|
|
||||||
function clampProgress(value: number) {
|
function clampProgress(value: number) {
|
||||||
return Math.max(0, Math.min(100, Math.round(value)));
|
return Math.max(0, Math.min(100, Math.round(value)));
|
||||||
@@ -20,6 +106,10 @@ function endOfDay(value: Date) {
|
|||||||
return new Date(value.getFullYear(), value.getMonth(), value.getDate(), 23, 59, 59, 999);
|
return new Date(value.getFullYear(), value.getMonth(), value.getDate(), 23, 59, 59, 999);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dateKey(value: Date) {
|
||||||
|
return value.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
function projectProgressFromStatus(status: string) {
|
function projectProgressFromStatus(status: string) {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "COMPLETE":
|
case "COMPLETE":
|
||||||
@@ -39,147 +129,402 @@ function workOrderProgress(quantity: number, completedQuantity: number, status:
|
|||||||
if (status === "COMPLETE") {
|
if (status === "COMPLETE") {
|
||||||
return 100;
|
return 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (quantity <= 0) {
|
if (quantity <= 0) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
return clampProgress((completedQuantity / quantity) * 100);
|
return clampProgress((completedQuantity / quantity) * 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildOwnerLabel(ownerName: string | null, customerName: string | null) {
|
function buildOwnerLabel(ownerName: string | null, customerName: string | null) {
|
||||||
if (ownerName && customerName) {
|
if (ownerName && customerName) {
|
||||||
return `${ownerName} • ${customerName}`;
|
return `${ownerName} | ${customerName}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return ownerName ?? customerName ?? null;
|
return ownerName ?? customerName ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function encodeQuery(params: Record<string, string | null | undefined>) {
|
||||||
|
const search = new URLSearchParams();
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
if (value && value.trim().length > 0) {
|
||||||
|
search.set(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const query = search.toString();
|
||||||
|
return query.length > 0 ? `?${query}` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBuildItem(type: string) {
|
||||||
|
return type === "ASSEMBLY" || type === "MANUFACTURED";
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldBuyItem(type: string, isPurchasable: boolean) {
|
||||||
|
return type === "PURCHASED" || isPurchasable;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAvailabilityKey(itemId: string, warehouseId: string, locationId: string) {
|
||||||
|
return `${itemId}:${warehouseId}:${locationId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStationLoad(record: StationAccumulator): PlanningStationLoadDto {
|
||||||
|
const capacityMinutes = Math.max(record.dayKeys.size, 1) * SHIFT_MINUTES_PER_DAY;
|
||||||
|
const utilizationPercent = capacityMinutes > 0 ? Math.round((record.totalPlannedMinutes / capacityMinutes) * 100) : 0;
|
||||||
|
return {
|
||||||
|
stationId: record.stationId,
|
||||||
|
stationCode: record.stationCode,
|
||||||
|
stationName: record.stationName,
|
||||||
|
operationCount: record.operationCount,
|
||||||
|
workOrderCount: record.workOrderIds.size,
|
||||||
|
totalPlannedMinutes: record.totalPlannedMinutes,
|
||||||
|
capacityMinutes,
|
||||||
|
utilizationPercent,
|
||||||
|
overloaded: utilizationPercent > 100,
|
||||||
|
blockedCount: record.blockedCount,
|
||||||
|
readyCount: record.readyCount,
|
||||||
|
lateCount: record.lateCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildProjectTask(
|
||||||
|
project: PlanningProjectRecord,
|
||||||
|
projectWorkOrders: PlanningWorkOrderRecord[],
|
||||||
|
workOrderInsights: Map<string, WorkOrderInsight>,
|
||||||
|
now: Date
|
||||||
|
): GanttTaskDto {
|
||||||
|
const ownerName = project.owner ? `${project.owner.firstName} ${project.owner.lastName}`.trim() : null;
|
||||||
|
const ownerLabel = buildOwnerLabel(ownerName, project.customer.name);
|
||||||
|
const dueDates = projectWorkOrders.map((workOrder) => workOrder.dueDate).filter((value): value is Date => Boolean(value));
|
||||||
|
const earliestWorkStart = projectWorkOrders[0]?.createdAt ?? project.createdAt;
|
||||||
|
const lastDueDate = dueDates.sort((left, right) => left.getTime() - right.getTime()).at(-1) ?? project.dueDate ?? addDays(project.createdAt, 14);
|
||||||
|
const insights = projectWorkOrders.map((workOrder) => workOrderInsights.get(workOrder.id)).filter(Boolean) as WorkOrderInsight[];
|
||||||
|
const readinessState: PlanningReadinessState =
|
||||||
|
insights.some((entry) => entry.readinessState === "BLOCKED") ? "BLOCKED"
|
||||||
|
: insights.some((entry) => entry.readinessState === "SHORTAGE") ? "SHORTAGE"
|
||||||
|
: insights.some((entry) => entry.readinessState === "PENDING_SUPPLY") ? "PENDING_SUPPLY"
|
||||||
|
: insights.length > 0 && insights.every((entry) => entry.readinessState === "UNSCHEDULED") ? "UNSCHEDULED"
|
||||||
|
: "READY";
|
||||||
|
return {
|
||||||
|
id: `project-${project.id}`,
|
||||||
|
text: `${project.projectNumber} - ${project.name}`,
|
||||||
|
start: startOfDay(earliestWorkStart).toISOString(),
|
||||||
|
end: endOfDay(lastDueDate).toISOString(),
|
||||||
|
progress: clampProgress(
|
||||||
|
projectWorkOrders.length > 0
|
||||||
|
? projectWorkOrders.reduce((sum, workOrder) => sum + workOrderProgress(workOrder.quantity, workOrder.completedQuantity, workOrder.status), 0) /
|
||||||
|
projectWorkOrders.length
|
||||||
|
: projectProgressFromStatus(project.status)
|
||||||
|
),
|
||||||
|
type: "project",
|
||||||
|
entityId: project.id,
|
||||||
|
projectId: project.id,
|
||||||
|
status: project.status,
|
||||||
|
ownerLabel,
|
||||||
|
detailHref: `/projects/${project.id}`,
|
||||||
|
readinessState,
|
||||||
|
readinessScore: insights.length > 0 ? Math.round(insights.reduce((sum, entry) => sum + entry.readinessScore, 0) / insights.length) : 80,
|
||||||
|
shortageItemCount: insights.reduce((sum, entry) => sum + entry.shortageItemCount, 0),
|
||||||
|
totalShortageQuantity: insights.reduce((sum, entry) => sum + entry.totalShortageQuantity, 0),
|
||||||
|
openSupplyQuantity: insights.reduce((sum, entry) => sum + entry.openSupplyQuantity, 0),
|
||||||
|
releaseReady: insights.some((entry) => entry.releaseReady),
|
||||||
|
overdue: project.dueDate ? project.dueDate.getTime() < now.getTime() : false,
|
||||||
|
blockedReason:
|
||||||
|
readinessState === "BLOCKED" ? "A linked work order is blocked."
|
||||||
|
: readinessState === "SHORTAGE" ? "Linked work orders have shortages."
|
||||||
|
: readinessState === "PENDING_SUPPLY" ? "Linked work orders are waiting on supply."
|
||||||
|
: readinessState === "UNSCHEDULED" ? "Linked work orders are unscheduled."
|
||||||
|
: null,
|
||||||
|
actions: [{ kind: "OPEN_RECORD", label: "Open record", href: `/projects/${project.id}` }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function getPlanningTimeline(): Promise<PlanningTimelineDto> {
|
export async function getPlanningTimeline(): Promise<PlanningTimelineDto> {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const planningProjects = await prisma.project.findMany({
|
const [projects, workOrders] = await Promise.all([
|
||||||
where: {
|
prisma.project.findMany({
|
||||||
status: {
|
where: { status: { not: "COMPLETE" } },
|
||||||
not: "COMPLETE",
|
select: {
|
||||||
|
id: true,
|
||||||
|
projectNumber: true,
|
||||||
|
name: true,
|
||||||
|
status: true,
|
||||||
|
dueDate: true,
|
||||||
|
createdAt: true,
|
||||||
|
customer: { select: { name: true } },
|
||||||
|
owner: { select: { firstName: true, lastName: true } },
|
||||||
},
|
},
|
||||||
},
|
orderBy: [{ dueDate: "asc" }, { createdAt: "asc" }],
|
||||||
include: {
|
}),
|
||||||
customer: {
|
prisma.workOrder.findMany({
|
||||||
select: {
|
where: { status: { notIn: ["COMPLETE", "CANCELLED"] } },
|
||||||
name: true,
|
select: {
|
||||||
},
|
id: true,
|
||||||
},
|
workOrderNumber: true,
|
||||||
owner: {
|
status: true,
|
||||||
select: {
|
quantity: true,
|
||||||
firstName: true,
|
completedQuantity: true,
|
||||||
lastName: true,
|
dueDate: true,
|
||||||
},
|
createdAt: true,
|
||||||
},
|
projectId: true,
|
||||||
workOrders: {
|
salesOrderId: true,
|
||||||
where: {
|
salesOrderLineId: true,
|
||||||
status: {
|
warehouseId: true,
|
||||||
notIn: ["COMPLETE", "CANCELLED"],
|
locationId: true,
|
||||||
},
|
item: {
|
||||||
},
|
select: {
|
||||||
select: {
|
id: true,
|
||||||
id: true,
|
sku: true,
|
||||||
workOrderNumber: true,
|
name: true,
|
||||||
status: true,
|
type: true,
|
||||||
quantity: true,
|
isPurchasable: true,
|
||||||
completedQuantity: true,
|
bomLines: {
|
||||||
dueDate: true,
|
select: {
|
||||||
createdAt: true,
|
quantity: true,
|
||||||
operations: {
|
componentItem: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
sequence: true,
|
sku: true,
|
||||||
plannedStart: true,
|
name: true,
|
||||||
plannedEnd: true,
|
type: true,
|
||||||
plannedMinutes: true,
|
isPurchasable: true,
|
||||||
station: {
|
},
|
||||||
select: {
|
|
||||||
code: true,
|
|
||||||
name: true,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
|
||||||
orderBy: [{ sequence: "asc" }],
|
|
||||||
},
|
|
||||||
item: {
|
|
||||||
select: {
|
|
||||||
sku: true,
|
|
||||||
name: true,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
orderBy: [{ dueDate: "asc" }, { createdAt: "asc" }],
|
operations: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
sequence: true,
|
||||||
|
plannedStart: true,
|
||||||
|
plannedEnd: true,
|
||||||
|
plannedMinutes: true,
|
||||||
|
station: { select: { id: true, code: true, name: true } },
|
||||||
|
},
|
||||||
|
orderBy: [{ sequence: "asc" }],
|
||||||
|
},
|
||||||
|
materialIssues: { select: { componentItemId: true, quantity: true } },
|
||||||
},
|
},
|
||||||
},
|
orderBy: [{ dueDate: "asc" }, { createdAt: "asc" }],
|
||||||
orderBy: [{ dueDate: "asc" }, { createdAt: "asc" }],
|
}),
|
||||||
});
|
]);
|
||||||
|
|
||||||
const standaloneWorkOrders = await prisma.workOrder.findMany({
|
const planningProjects = projects as PlanningProjectRecord[];
|
||||||
where: {
|
const openWorkOrders = workOrders as PlanningWorkOrderRecord[];
|
||||||
projectId: null,
|
const workOrdersByProjectId = new Map<string, PlanningWorkOrderRecord[]>();
|
||||||
status: {
|
const standaloneWorkOrders: PlanningWorkOrderRecord[] = [];
|
||||||
notIn: ["COMPLETE", "CANCELLED"],
|
for (const workOrder of openWorkOrders) {
|
||||||
},
|
if (workOrder.projectId) {
|
||||||
},
|
const existing = workOrdersByProjectId.get(workOrder.projectId) ?? [];
|
||||||
include: {
|
existing.push(workOrder);
|
||||||
item: {
|
workOrdersByProjectId.set(workOrder.projectId, existing);
|
||||||
select: {
|
} else {
|
||||||
sku: true,
|
standaloneWorkOrders.push(workOrder);
|
||||||
name: true,
|
}
|
||||||
},
|
}
|
||||||
},
|
|
||||||
operations: {
|
const componentItemIds = [...new Set(openWorkOrders.flatMap((workOrder) => workOrder.item.bomLines.map((line) => line.componentItem.id)))];
|
||||||
select: {
|
const warehouseIds = [...new Set(openWorkOrders.map((workOrder) => workOrder.warehouseId))];
|
||||||
id: true,
|
const locationIds = [...new Set(openWorkOrders.map((workOrder) => workOrder.locationId))];
|
||||||
sequence: true,
|
|
||||||
plannedStart: true,
|
const [transactions, reservations, supplyWorkOrders, purchaseOrderLines] = componentItemIds.length > 0
|
||||||
plannedEnd: true,
|
? await Promise.all([
|
||||||
plannedMinutes: true,
|
prisma.inventoryTransaction.findMany({
|
||||||
station: {
|
where: { itemId: { in: componentItemIds }, warehouseId: { in: warehouseIds }, locationId: { in: locationIds } },
|
||||||
select: {
|
select: { itemId: true, warehouseId: true, locationId: true, transactionType: true, quantity: true },
|
||||||
code: true,
|
}),
|
||||||
name: true,
|
prisma.inventoryReservation.findMany({
|
||||||
},
|
where: { itemId: { in: componentItemIds }, warehouseId: { in: warehouseIds }, locationId: { in: locationIds }, status: "ACTIVE" },
|
||||||
},
|
select: { itemId: true, warehouseId: true, locationId: true, quantity: true },
|
||||||
},
|
}),
|
||||||
orderBy: [{ sequence: "asc" }],
|
prisma.workOrder.findMany({
|
||||||
},
|
where: { itemId: { in: componentItemIds }, status: { notIn: ["COMPLETE", "CANCELLED"] } },
|
||||||
},
|
select: { itemId: true, quantity: true, completedQuantity: true },
|
||||||
orderBy: [{ dueDate: "asc" }, { createdAt: "asc" }],
|
}),
|
||||||
|
prisma.purchaseOrderLine.findMany({
|
||||||
|
where: { itemId: { in: componentItemIds }, purchaseOrder: { status: { not: "CLOSED" } } },
|
||||||
|
select: { itemId: true, quantity: true, receiptLines: { select: { quantity: true } } },
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
: [[], [], [], []];
|
||||||
|
|
||||||
|
const availabilityByKey = new Map<string, { onHandQuantity: number; reservedQuantity: number }>();
|
||||||
|
for (const transaction of transactions) {
|
||||||
|
const key = getAvailabilityKey(transaction.itemId, transaction.warehouseId, transaction.locationId);
|
||||||
|
const current = availabilityByKey.get(key) ?? { onHandQuantity: 0, reservedQuantity: 0 };
|
||||||
|
current.onHandQuantity += transaction.transactionType === "RECEIPT" || transaction.transactionType === "ADJUSTMENT_IN" ? transaction.quantity : -transaction.quantity;
|
||||||
|
availabilityByKey.set(key, current);
|
||||||
|
}
|
||||||
|
for (const reservation of reservations) {
|
||||||
|
if (!reservation.itemId || !reservation.warehouseId || !reservation.locationId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const key = getAvailabilityKey(reservation.itemId, reservation.warehouseId, reservation.locationId);
|
||||||
|
const current = availabilityByKey.get(key) ?? { onHandQuantity: 0, reservedQuantity: 0 };
|
||||||
|
current.reservedQuantity += reservation.quantity;
|
||||||
|
availabilityByKey.set(key, current);
|
||||||
|
}
|
||||||
|
|
||||||
|
const openWorkSupplyByItemId = new Map<string, number>();
|
||||||
|
for (const workOrder of supplyWorkOrders) {
|
||||||
|
openWorkSupplyByItemId.set(workOrder.itemId, (openWorkSupplyByItemId.get(workOrder.itemId) ?? 0) + Math.max(workOrder.quantity - workOrder.completedQuantity, 0));
|
||||||
|
}
|
||||||
|
const openPurchaseSupplyByItemId = new Map<string, number>();
|
||||||
|
for (const line of purchaseOrderLines) {
|
||||||
|
const remainingQuantity = Math.max(line.quantity - line.receiptLines.reduce((sum, receiptLine) => sum + receiptLine.quantity, 0), 0);
|
||||||
|
openPurchaseSupplyByItemId.set(line.itemId, (openPurchaseSupplyByItemId.get(line.itemId) ?? 0) + remainingQuantity);
|
||||||
|
}
|
||||||
|
|
||||||
|
const workOrderInsights = new Map<string, WorkOrderInsight>();
|
||||||
|
for (const workOrder of openWorkOrders) {
|
||||||
|
const issuedByComponent = new Map<string, number>();
|
||||||
|
for (const issue of workOrder.materialIssues) {
|
||||||
|
issuedByComponent.set(issue.componentItemId, (issuedByComponent.get(issue.componentItemId) ?? 0) + issue.quantity);
|
||||||
|
}
|
||||||
|
|
||||||
|
let shortageItemCount = 0;
|
||||||
|
let totalShortageQuantity = 0;
|
||||||
|
let openSupplyQuantity = 0;
|
||||||
|
let firstShortageAction: PlanningTaskActionDto | null = null;
|
||||||
|
|
||||||
|
for (const line of workOrder.item.bomLines) {
|
||||||
|
const key = getAvailabilityKey(line.componentItem.id, workOrder.warehouseId, workOrder.locationId);
|
||||||
|
const availability = availabilityByKey.get(key) ?? { onHandQuantity: 0, reservedQuantity: 0 };
|
||||||
|
const availableQuantity = availability.onHandQuantity - availability.reservedQuantity;
|
||||||
|
const requiredQuantity = line.quantity * workOrder.quantity;
|
||||||
|
const issuedQuantity = issuedByComponent.get(line.componentItem.id) ?? 0;
|
||||||
|
const shortageQuantity = Math.max(requiredQuantity - issuedQuantity - Math.max(availableQuantity, 0), 0);
|
||||||
|
if (shortageQuantity <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
shortageItemCount += 1;
|
||||||
|
totalShortageQuantity += shortageQuantity;
|
||||||
|
const openSupplyForItem = (openWorkSupplyByItemId.get(line.componentItem.id) ?? 0) + (openPurchaseSupplyByItemId.get(line.componentItem.id) ?? 0);
|
||||||
|
openSupplyQuantity += openSupplyForItem;
|
||||||
|
if (!firstShortageAction) {
|
||||||
|
if (shouldBuyItem(line.componentItem.type, line.componentItem.isPurchasable) && workOrder.salesOrderId) {
|
||||||
|
firstShortageAction = {
|
||||||
|
kind: "CREATE_PURCHASE_ORDER",
|
||||||
|
label: `Buy ${line.componentItem.sku}`,
|
||||||
|
href: `/purchasing/orders/new${encodeQuery({ planningOrderId: workOrder.salesOrderId, itemId: line.componentItem.id })}`,
|
||||||
|
itemId: line.componentItem.id,
|
||||||
|
};
|
||||||
|
} else if (isBuildItem(line.componentItem.type)) {
|
||||||
|
firstShortageAction = {
|
||||||
|
kind: "CREATE_WORK_ORDER",
|
||||||
|
label: `Build ${line.componentItem.sku}`,
|
||||||
|
href: `/manufacturing/work-orders/new${encodeQuery({
|
||||||
|
projectId: workOrder.projectId,
|
||||||
|
itemId: line.componentItem.id,
|
||||||
|
salesOrderId: workOrder.salesOrderId,
|
||||||
|
salesOrderLineId: workOrder.salesOrderLineId,
|
||||||
|
quantity: Math.ceil(shortageQuantity).toString(),
|
||||||
|
status: "DRAFT",
|
||||||
|
notes: `Workbench follow-through from ${workOrder.workOrderNumber}`,
|
||||||
|
})}`,
|
||||||
|
itemId: line.componentItem.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let readinessState: PlanningReadinessState = "READY";
|
||||||
|
let readinessScore = 90;
|
||||||
|
let blockedReason: string | null = null;
|
||||||
|
if (workOrder.status === "ON_HOLD") {
|
||||||
|
readinessState = "BLOCKED";
|
||||||
|
readinessScore = 15;
|
||||||
|
blockedReason = "Work order is on hold.";
|
||||||
|
} else if (!workOrder.dueDate) {
|
||||||
|
readinessState = "UNSCHEDULED";
|
||||||
|
readinessScore = 25;
|
||||||
|
blockedReason = "Work order has no due date.";
|
||||||
|
} else if (totalShortageQuantity > 0 && openSupplyQuantity > 0) {
|
||||||
|
readinessState = "PENDING_SUPPLY";
|
||||||
|
readinessScore = 55;
|
||||||
|
blockedReason = "Material is short but open supply exists.";
|
||||||
|
} else if (totalShortageQuantity > 0) {
|
||||||
|
readinessState = "SHORTAGE";
|
||||||
|
readinessScore = 30;
|
||||||
|
blockedReason = "Material shortage blocks release or execution.";
|
||||||
|
} else if (workOrder.status === "DRAFT") {
|
||||||
|
readinessScore = 80;
|
||||||
|
}
|
||||||
|
|
||||||
|
const releaseReady = workOrder.status === "DRAFT" && totalShortageQuantity === 0 && workOrder.dueDate !== null;
|
||||||
|
const actions: PlanningTaskActionDto[] = [{ kind: "OPEN_RECORD", label: "Open record", href: `/manufacturing/work-orders/${workOrder.id}`, workOrderId: workOrder.id }];
|
||||||
|
if (releaseReady) {
|
||||||
|
actions.push({ kind: "RELEASE_WORK_ORDER", label: "Release work order", workOrderId: workOrder.id });
|
||||||
|
}
|
||||||
|
if (firstShortageAction) {
|
||||||
|
actions.push(firstShortageAction);
|
||||||
|
}
|
||||||
|
|
||||||
|
workOrderInsights.set(workOrder.id, {
|
||||||
|
readinessState,
|
||||||
|
readinessScore,
|
||||||
|
shortageItemCount,
|
||||||
|
totalShortageQuantity,
|
||||||
|
linkedSupplyQuantity: 0,
|
||||||
|
openSupplyQuantity,
|
||||||
|
releaseReady,
|
||||||
|
blockedReason,
|
||||||
|
overdue: workOrder.dueDate ? workOrder.dueDate.getTime() < now.getTime() : false,
|
||||||
|
actions,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const stationAccumulators = new Map<string, StationAccumulator>();
|
||||||
|
for (const workOrder of openWorkOrders) {
|
||||||
|
const insight = workOrderInsights.get(workOrder.id);
|
||||||
|
for (const operation of workOrder.operations) {
|
||||||
|
const current = stationAccumulators.get(operation.station.id) ?? {
|
||||||
|
stationId: operation.station.id,
|
||||||
|
stationCode: operation.station.code,
|
||||||
|
stationName: operation.station.name,
|
||||||
|
operationCount: 0,
|
||||||
|
workOrderIds: new Set<string>(),
|
||||||
|
totalPlannedMinutes: 0,
|
||||||
|
blockedCount: 0,
|
||||||
|
readyCount: 0,
|
||||||
|
lateCount: 0,
|
||||||
|
dayKeys: new Set<string>(),
|
||||||
|
};
|
||||||
|
current.operationCount += 1;
|
||||||
|
current.workOrderIds.add(workOrder.id);
|
||||||
|
current.totalPlannedMinutes += operation.plannedMinutes;
|
||||||
|
if (insight?.readinessState === "BLOCKED" || insight?.readinessState === "SHORTAGE" || insight?.readinessState === "PENDING_SUPPLY") {
|
||||||
|
current.blockedCount += 1;
|
||||||
|
}
|
||||||
|
if (insight?.releaseReady || insight?.readinessState === "READY") {
|
||||||
|
current.readyCount += 1;
|
||||||
|
}
|
||||||
|
if (insight?.overdue) {
|
||||||
|
current.lateCount += 1;
|
||||||
|
}
|
||||||
|
for (let cursor = startOfDay(operation.plannedStart).getTime(); cursor <= startOfDay(operation.plannedEnd).getTime(); cursor += DAY_MS) {
|
||||||
|
current.dayKeys.add(dateKey(new Date(cursor)));
|
||||||
|
}
|
||||||
|
stationAccumulators.set(operation.station.id, current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stationLoads = [...stationAccumulators.values()].map(createStationLoad).sort((left, right) => {
|
||||||
|
if (right.utilizationPercent !== left.utilizationPercent) {
|
||||||
|
return right.utilizationPercent - left.utilizationPercent;
|
||||||
|
}
|
||||||
|
return left.stationCode.localeCompare(right.stationCode);
|
||||||
});
|
});
|
||||||
|
const stationLoadById = new Map(stationLoads.map((load) => [load.stationId, load]));
|
||||||
|
|
||||||
const tasks: GanttTaskDto[] = [];
|
const tasks: GanttTaskDto[] = [];
|
||||||
const links: GanttLinkDto[] = [];
|
const links: GanttLinkDto[] = [];
|
||||||
const exceptions: PlanningTimelineDto["exceptions"] = [];
|
const exceptions: PlanningTimelineDto["exceptions"] = [];
|
||||||
|
|
||||||
for (const project of planningProjects) {
|
for (const project of planningProjects) {
|
||||||
const ownerName = project.owner ? `${project.owner.firstName} ${project.owner.lastName}`.trim() : null;
|
const projectWorkOrders = workOrdersByProjectId.get(project.id) ?? [];
|
||||||
const ownerLabel = buildOwnerLabel(ownerName, project.customer.name);
|
const projectTask = buildProjectTask(project, projectWorkOrders, workOrderInsights, now);
|
||||||
const dueDates = project.workOrders.map((workOrder) => workOrder.dueDate).filter((value): value is Date => Boolean(value));
|
tasks.push(projectTask);
|
||||||
const earliestWorkStart = project.workOrders[0]?.createdAt ?? project.createdAt;
|
|
||||||
const lastDueDate = dueDates.sort((left, right) => left.getTime() - right.getTime()).at(-1) ?? project.dueDate ?? addDays(project.createdAt, 14);
|
|
||||||
const start = startOfDay(earliestWorkStart);
|
|
||||||
const end = endOfDay(lastDueDate);
|
|
||||||
|
|
||||||
tasks.push({
|
|
||||||
id: `project-${project.id}`,
|
|
||||||
text: `${project.projectNumber} - ${project.name}`,
|
|
||||||
start: start.toISOString(),
|
|
||||||
end: end.toISOString(),
|
|
||||||
progress: clampProgress(
|
|
||||||
project.workOrders.length > 0
|
|
||||||
? project.workOrders.reduce((sum, workOrder) => sum + workOrderProgress(workOrder.quantity, workOrder.completedQuantity, workOrder.status), 0) / project.workOrders.length
|
|
||||||
: projectProgressFromStatus(project.status)
|
|
||||||
),
|
|
||||||
type: "project",
|
|
||||||
status: project.status,
|
|
||||||
ownerLabel,
|
|
||||||
detailHref: `/projects/${project.id}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (project.dueDate) {
|
if (project.dueDate) {
|
||||||
tasks.push({
|
tasks.push({
|
||||||
@@ -190,56 +535,67 @@ export async function getPlanningTimeline(): Promise<PlanningTimelineDto> {
|
|||||||
progress: project.status === "COMPLETE" ? 100 : 0,
|
progress: project.status === "COMPLETE" ? 100 : 0,
|
||||||
type: "milestone",
|
type: "milestone",
|
||||||
parentId: `project-${project.id}`,
|
parentId: `project-${project.id}`,
|
||||||
|
entityId: project.id,
|
||||||
|
projectId: project.id,
|
||||||
status: project.status,
|
status: project.status,
|
||||||
ownerLabel,
|
ownerLabel: projectTask.ownerLabel ?? null,
|
||||||
detailHref: `/projects/${project.id}`,
|
detailHref: `/projects/${project.id}`,
|
||||||
|
readinessState: projectTask.readinessState,
|
||||||
|
readinessScore: projectTask.readinessScore,
|
||||||
|
shortageItemCount: projectTask.shortageItemCount,
|
||||||
|
totalShortageQuantity: projectTask.totalShortageQuantity,
|
||||||
|
releaseReady: projectTask.releaseReady,
|
||||||
|
overdue: project.dueDate.getTime() < now.getTime(),
|
||||||
|
actions: [{ kind: "OPEN_RECORD", label: "Open record", href: `/projects/${project.id}` }],
|
||||||
});
|
});
|
||||||
links.push({
|
links.push({ id: `project-link-${project.id}`, source: `project-${project.id}`, target: `project-milestone-${project.id}`, type: "e2e" });
|
||||||
id: `project-link-${project.id}`,
|
|
||||||
source: `project-${project.id}`,
|
|
||||||
target: `project-milestone-${project.id}`,
|
|
||||||
type: "e2e",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let previousTaskId: string | null = null;
|
let previousTaskId: string | null = null;
|
||||||
for (const workOrder of project.workOrders) {
|
for (const workOrder of projectWorkOrders) {
|
||||||
const workOrderStart = startOfDay(workOrder.createdAt);
|
const insight = workOrderInsights.get(workOrder.id)!;
|
||||||
const workOrderEnd = endOfDay(workOrder.dueDate ?? addDays(workOrder.createdAt, 7));
|
|
||||||
const workOrderTaskId = `work-order-${workOrder.id}`;
|
const workOrderTaskId = `work-order-${workOrder.id}`;
|
||||||
tasks.push({
|
tasks.push({
|
||||||
id: workOrderTaskId,
|
id: workOrderTaskId,
|
||||||
text: `${workOrder.workOrderNumber} - ${workOrder.item.sku}`,
|
text: `${workOrder.workOrderNumber} - ${workOrder.item.sku}`,
|
||||||
start: workOrderStart.toISOString(),
|
start: startOfDay(workOrder.createdAt).toISOString(),
|
||||||
end: workOrderEnd.toISOString(),
|
end: endOfDay(workOrder.dueDate ?? addDays(workOrder.createdAt, 7)).toISOString(),
|
||||||
progress: workOrderProgress(workOrder.quantity, workOrder.completedQuantity, workOrder.status),
|
progress: workOrderProgress(workOrder.quantity, workOrder.completedQuantity, workOrder.status),
|
||||||
type: "task",
|
type: "task",
|
||||||
parentId: `project-${project.id}`,
|
parentId: `project-${project.id}`,
|
||||||
|
entityId: workOrder.id,
|
||||||
|
projectId: project.id,
|
||||||
|
workOrderId: workOrder.id,
|
||||||
|
salesOrderId: workOrder.salesOrderId,
|
||||||
|
salesOrderLineId: workOrder.salesOrderLineId,
|
||||||
|
itemId: workOrder.item.id,
|
||||||
|
itemSku: workOrder.item.sku,
|
||||||
status: workOrder.status,
|
status: workOrder.status,
|
||||||
ownerLabel: workOrder.item.name,
|
ownerLabel: workOrder.item.name,
|
||||||
detailHref: `/manufacturing/work-orders/${workOrder.id}`,
|
detailHref: `/manufacturing/work-orders/${workOrder.id}`,
|
||||||
|
readinessState: insight.readinessState,
|
||||||
|
readinessScore: insight.readinessScore,
|
||||||
|
shortageItemCount: insight.shortageItemCount,
|
||||||
|
totalShortageQuantity: insight.totalShortageQuantity,
|
||||||
|
linkedSupplyQuantity: insight.linkedSupplyQuantity,
|
||||||
|
openSupplyQuantity: insight.openSupplyQuantity,
|
||||||
|
releaseReady: insight.releaseReady,
|
||||||
|
overdue: insight.overdue,
|
||||||
|
blockedReason: insight.blockedReason,
|
||||||
|
loadMinutes: workOrder.operations.reduce((sum, operation) => sum + operation.plannedMinutes, 0),
|
||||||
|
actions: insight.actions,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (previousTaskId) {
|
if (previousTaskId) {
|
||||||
links.push({
|
links.push({ id: `sequence-${previousTaskId}-${workOrderTaskId}`, source: previousTaskId, target: workOrderTaskId, type: "e2e" });
|
||||||
id: `sequence-${previousTaskId}-${workOrderTaskId}`,
|
|
||||||
source: previousTaskId,
|
|
||||||
target: workOrderTaskId,
|
|
||||||
type: "e2e",
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
links.push({
|
links.push({ id: `project-start-${project.id}-${workOrder.id}`, source: `project-${project.id}`, target: workOrderTaskId, type: "e2e" });
|
||||||
id: `project-start-${project.id}-${workOrder.id}`,
|
|
||||||
source: `project-${project.id}`,
|
|
||||||
target: workOrderTaskId,
|
|
||||||
type: "e2e",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
previousTaskId = workOrderTaskId;
|
previousTaskId = workOrderTaskId;
|
||||||
|
|
||||||
let previousOperationTaskId: string | null = null;
|
let previousOperationTaskId: string | null = null;
|
||||||
for (const operation of workOrder.operations) {
|
for (const operation of workOrder.operations) {
|
||||||
|
const stationLoad = stationLoadById.get(operation.station.id) ?? null;
|
||||||
const operationTaskId = `work-order-operation-${operation.id}`;
|
const operationTaskId = `work-order-operation-${operation.id}`;
|
||||||
tasks.push({
|
tasks.push({
|
||||||
id: operationTaskId,
|
id: operationTaskId,
|
||||||
@@ -249,18 +605,34 @@ export async function getPlanningTimeline(): Promise<PlanningTimelineDto> {
|
|||||||
progress: workOrderProgress(workOrder.quantity, workOrder.completedQuantity, workOrder.status),
|
progress: workOrderProgress(workOrder.quantity, workOrder.completedQuantity, workOrder.status),
|
||||||
type: "task",
|
type: "task",
|
||||||
parentId: workOrderTaskId,
|
parentId: workOrderTaskId,
|
||||||
|
entityId: operation.id,
|
||||||
|
projectId: project.id,
|
||||||
|
workOrderId: workOrder.id,
|
||||||
|
salesOrderId: workOrder.salesOrderId,
|
||||||
|
salesOrderLineId: workOrder.salesOrderLineId,
|
||||||
|
itemId: workOrder.item.id,
|
||||||
|
itemSku: workOrder.item.sku,
|
||||||
|
stationId: operation.station.id,
|
||||||
|
stationCode: operation.station.code,
|
||||||
|
stationName: operation.station.name,
|
||||||
status: workOrder.status,
|
status: workOrder.status,
|
||||||
ownerLabel: workOrder.workOrderNumber,
|
ownerLabel: workOrder.workOrderNumber,
|
||||||
detailHref: `/manufacturing/work-orders/${workOrder.id}`,
|
detailHref: `/manufacturing/work-orders/${workOrder.id}`,
|
||||||
|
readinessState: insight.readinessState,
|
||||||
|
readinessScore: insight.readinessScore,
|
||||||
|
shortageItemCount: insight.shortageItemCount,
|
||||||
|
totalShortageQuantity: insight.totalShortageQuantity,
|
||||||
|
linkedSupplyQuantity: insight.linkedSupplyQuantity,
|
||||||
|
openSupplyQuantity: insight.openSupplyQuantity,
|
||||||
|
releaseReady: insight.releaseReady,
|
||||||
|
overdue: insight.overdue || operation.plannedEnd.getTime() < now.getTime(),
|
||||||
|
blockedReason: insight.blockedReason,
|
||||||
|
loadMinutes: operation.plannedMinutes,
|
||||||
|
capacityMinutes: stationLoad?.capacityMinutes ?? null,
|
||||||
|
utilizationPercent: stationLoad?.utilizationPercent ?? null,
|
||||||
|
actions: insight.actions,
|
||||||
});
|
});
|
||||||
|
links.push({ id: `work-order-operation-parent-${workOrder.id}-${operation.id}`, source: workOrderTaskId, target: operationTaskId, type: "e2e" });
|
||||||
links.push({
|
|
||||||
id: `work-order-operation-parent-${workOrder.id}-${operation.id}`,
|
|
||||||
source: workOrderTaskId,
|
|
||||||
target: operationTaskId,
|
|
||||||
type: "e2e",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (previousOperationTaskId) {
|
if (previousOperationTaskId) {
|
||||||
links.push({
|
links.push({
|
||||||
id: `work-order-operation-sequence-${previousOperationTaskId}-${operationTaskId}`,
|
id: `work-order-operation-sequence-${previousOperationTaskId}-${operationTaskId}`,
|
||||||
@@ -269,41 +641,30 @@ export async function getPlanningTimeline(): Promise<PlanningTimelineDto> {
|
|||||||
type: "e2e",
|
type: "e2e",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
previousOperationTaskId = operationTaskId;
|
previousOperationTaskId = operationTaskId;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (workOrder.dueDate && workOrder.dueDate.getTime() < now.getTime()) {
|
if (insight.overdue || insight.readinessState === "BLOCKED" || insight.readinessState === "SHORTAGE" || insight.readinessState === "PENDING_SUPPLY") {
|
||||||
exceptions.push({
|
exceptions.push({
|
||||||
id: `work-order-${workOrder.id}`,
|
id: `work-order-${workOrder.id}`,
|
||||||
kind: "WORK_ORDER",
|
kind: "WORK_ORDER",
|
||||||
title: `${workOrder.workOrderNumber} - ${workOrder.item.sku}`,
|
title: `${workOrder.workOrderNumber} - ${workOrder.item.sku}`,
|
||||||
status: workOrder.status,
|
status: insight.readinessState === "READY" ? workOrder.status : insight.readinessState,
|
||||||
dueDate: workOrder.dueDate.toISOString(),
|
dueDate: workOrder.dueDate ? workOrder.dueDate.toISOString() : null,
|
||||||
ownerLabel: project.projectNumber,
|
ownerLabel: project.projectNumber,
|
||||||
detailHref: `/manufacturing/work-orders/${workOrder.id}`,
|
detailHref: `/manufacturing/work-orders/${workOrder.id}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (project.dueDate && project.dueDate.getTime() < now.getTime()) {
|
if ((project.dueDate && project.dueDate.getTime() < now.getTime()) || project.status === "AT_RISK") {
|
||||||
exceptions.push({
|
|
||||||
id: `project-${project.id}`,
|
|
||||||
kind: "PROJECT",
|
|
||||||
title: `${project.projectNumber} - ${project.name}`,
|
|
||||||
status: project.status,
|
|
||||||
dueDate: project.dueDate.toISOString(),
|
|
||||||
ownerLabel,
|
|
||||||
detailHref: `/projects/${project.id}`,
|
|
||||||
});
|
|
||||||
} else if (project.status === "AT_RISK") {
|
|
||||||
exceptions.push({
|
exceptions.push({
|
||||||
id: `project-${project.id}`,
|
id: `project-${project.id}`,
|
||||||
kind: "PROJECT",
|
kind: "PROJECT",
|
||||||
title: `${project.projectNumber} - ${project.name}`,
|
title: `${project.projectNumber} - ${project.name}`,
|
||||||
status: project.status,
|
status: project.status,
|
||||||
dueDate: project.dueDate ? project.dueDate.toISOString() : null,
|
dueDate: project.dueDate ? project.dueDate.toISOString() : null,
|
||||||
ownerLabel,
|
ownerLabel: projectTask.ownerLabel ?? null,
|
||||||
detailHref: `/projects/${project.id}`,
|
detailHref: `/projects/${project.id}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -315,14 +676,13 @@ export async function getPlanningTimeline(): Promise<PlanningTimelineDto> {
|
|||||||
standaloneWorkOrders.reduce((earliest, workOrder) => (workOrder.createdAt < earliest ? workOrder.createdAt : earliest), firstStandaloneWorkOrder.createdAt)
|
standaloneWorkOrders.reduce((earliest, workOrder) => (workOrder.createdAt < earliest ? workOrder.createdAt : earliest), firstStandaloneWorkOrder.createdAt)
|
||||||
);
|
);
|
||||||
const bucketEnd = endOfDay(
|
const bucketEnd = endOfDay(
|
||||||
standaloneWorkOrders.reduce(
|
standaloneWorkOrders.reduce((latest, workOrder) => {
|
||||||
(latest, workOrder) => {
|
const candidate = workOrder.dueDate ?? addDays(workOrder.createdAt, 7);
|
||||||
const candidate = workOrder.dueDate ?? addDays(workOrder.createdAt, 7);
|
return candidate > latest ? candidate : latest;
|
||||||
return candidate > latest ? candidate : latest;
|
}, firstStandaloneWorkOrder.dueDate ?? addDays(firstStandaloneWorkOrder.createdAt, 7))
|
||||||
},
|
|
||||||
firstStandaloneWorkOrder.dueDate ?? addDays(firstStandaloneWorkOrder.createdAt, 7)
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
const standaloneInsights = standaloneWorkOrders.map((workOrder) => workOrderInsights.get(workOrder.id)).filter(Boolean) as WorkOrderInsight[];
|
||||||
|
|
||||||
tasks.push({
|
tasks.push({
|
||||||
id: "standalone-manufacturing",
|
id: "standalone-manufacturing",
|
||||||
text: "Standalone Manufacturing Queue",
|
text: "Standalone Manufacturing Queue",
|
||||||
@@ -336,10 +696,19 @@ export async function getPlanningTimeline(): Promise<PlanningTimelineDto> {
|
|||||||
status: "ACTIVE",
|
status: "ACTIVE",
|
||||||
ownerLabel: "Manufacturing",
|
ownerLabel: "Manufacturing",
|
||||||
detailHref: "/manufacturing/work-orders",
|
detailHref: "/manufacturing/work-orders",
|
||||||
|
readinessState:
|
||||||
|
standaloneInsights.some((entry) => entry.readinessState === "BLOCKED") ? "BLOCKED"
|
||||||
|
: standaloneInsights.some((entry) => entry.readinessState === "SHORTAGE") ? "SHORTAGE"
|
||||||
|
: standaloneInsights.some((entry) => entry.readinessState === "PENDING_SUPPLY") ? "PENDING_SUPPLY"
|
||||||
|
: standaloneInsights.some((entry) => entry.readinessState === "UNSCHEDULED") ? "UNSCHEDULED"
|
||||||
|
: "READY",
|
||||||
|
readinessScore: clampProgress(standaloneInsights.reduce((sum, entry) => sum + entry.readinessScore, 0) / standaloneInsights.length),
|
||||||
|
actions: [{ kind: "OPEN_RECORD", label: "Open record", href: "/manufacturing/work-orders" }],
|
||||||
});
|
});
|
||||||
|
|
||||||
let previousStandaloneTaskId: string | null = null;
|
let previousStandaloneTaskId: string | null = null;
|
||||||
for (const workOrder of standaloneWorkOrders) {
|
for (const workOrder of standaloneWorkOrders) {
|
||||||
|
const insight = workOrderInsights.get(workOrder.id)!;
|
||||||
const workOrderTaskId = `work-order-${workOrder.id}`;
|
const workOrderTaskId = `work-order-${workOrder.id}`;
|
||||||
tasks.push({
|
tasks.push({
|
||||||
id: workOrderTaskId,
|
id: workOrderTaskId,
|
||||||
@@ -349,24 +718,35 @@ export async function getPlanningTimeline(): Promise<PlanningTimelineDto> {
|
|||||||
progress: workOrderProgress(workOrder.quantity, workOrder.completedQuantity, workOrder.status),
|
progress: workOrderProgress(workOrder.quantity, workOrder.completedQuantity, workOrder.status),
|
||||||
type: "task",
|
type: "task",
|
||||||
parentId: "standalone-manufacturing",
|
parentId: "standalone-manufacturing",
|
||||||
|
entityId: workOrder.id,
|
||||||
|
workOrderId: workOrder.id,
|
||||||
|
salesOrderId: workOrder.salesOrderId,
|
||||||
|
salesOrderLineId: workOrder.salesOrderLineId,
|
||||||
|
itemId: workOrder.item.id,
|
||||||
|
itemSku: workOrder.item.sku,
|
||||||
status: workOrder.status,
|
status: workOrder.status,
|
||||||
ownerLabel: workOrder.item.name,
|
ownerLabel: workOrder.item.name,
|
||||||
detailHref: `/manufacturing/work-orders/${workOrder.id}`,
|
detailHref: `/manufacturing/work-orders/${workOrder.id}`,
|
||||||
|
readinessState: insight.readinessState,
|
||||||
|
readinessScore: insight.readinessScore,
|
||||||
|
shortageItemCount: insight.shortageItemCount,
|
||||||
|
totalShortageQuantity: insight.totalShortageQuantity,
|
||||||
|
linkedSupplyQuantity: insight.linkedSupplyQuantity,
|
||||||
|
openSupplyQuantity: insight.openSupplyQuantity,
|
||||||
|
releaseReady: insight.releaseReady,
|
||||||
|
overdue: insight.overdue,
|
||||||
|
blockedReason: insight.blockedReason,
|
||||||
|
loadMinutes: workOrder.operations.reduce((sum, operation) => sum + operation.plannedMinutes, 0),
|
||||||
|
actions: insight.actions,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (previousStandaloneTaskId) {
|
if (previousStandaloneTaskId) {
|
||||||
links.push({
|
links.push({ id: `sequence-${previousStandaloneTaskId}-${workOrderTaskId}`, source: previousStandaloneTaskId, target: workOrderTaskId, type: "e2e" });
|
||||||
id: `sequence-${previousStandaloneTaskId}-${workOrderTaskId}`,
|
|
||||||
source: previousStandaloneTaskId,
|
|
||||||
target: workOrderTaskId,
|
|
||||||
type: "e2e",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
previousStandaloneTaskId = workOrderTaskId;
|
previousStandaloneTaskId = workOrderTaskId;
|
||||||
|
|
||||||
let previousOperationTaskId: string | null = null;
|
let previousOperationTaskId: string | null = null;
|
||||||
for (const operation of workOrder.operations) {
|
for (const operation of workOrder.operations) {
|
||||||
|
const stationLoad = stationLoadById.get(operation.station.id) ?? null;
|
||||||
const operationTaskId = `work-order-operation-${operation.id}`;
|
const operationTaskId = `work-order-operation-${operation.id}`;
|
||||||
tasks.push({
|
tasks.push({
|
||||||
id: operationTaskId,
|
id: operationTaskId,
|
||||||
@@ -376,18 +756,33 @@ export async function getPlanningTimeline(): Promise<PlanningTimelineDto> {
|
|||||||
progress: workOrderProgress(workOrder.quantity, workOrder.completedQuantity, workOrder.status),
|
progress: workOrderProgress(workOrder.quantity, workOrder.completedQuantity, workOrder.status),
|
||||||
type: "task",
|
type: "task",
|
||||||
parentId: workOrderTaskId,
|
parentId: workOrderTaskId,
|
||||||
|
entityId: operation.id,
|
||||||
|
workOrderId: workOrder.id,
|
||||||
|
salesOrderId: workOrder.salesOrderId,
|
||||||
|
salesOrderLineId: workOrder.salesOrderLineId,
|
||||||
|
itemId: workOrder.item.id,
|
||||||
|
itemSku: workOrder.item.sku,
|
||||||
|
stationId: operation.station.id,
|
||||||
|
stationCode: operation.station.code,
|
||||||
|
stationName: operation.station.name,
|
||||||
status: workOrder.status,
|
status: workOrder.status,
|
||||||
ownerLabel: workOrder.workOrderNumber,
|
ownerLabel: workOrder.workOrderNumber,
|
||||||
detailHref: `/manufacturing/work-orders/${workOrder.id}`,
|
detailHref: `/manufacturing/work-orders/${workOrder.id}`,
|
||||||
|
readinessState: insight.readinessState,
|
||||||
|
readinessScore: insight.readinessScore,
|
||||||
|
shortageItemCount: insight.shortageItemCount,
|
||||||
|
totalShortageQuantity: insight.totalShortageQuantity,
|
||||||
|
linkedSupplyQuantity: insight.linkedSupplyQuantity,
|
||||||
|
openSupplyQuantity: insight.openSupplyQuantity,
|
||||||
|
releaseReady: insight.releaseReady,
|
||||||
|
overdue: insight.overdue || operation.plannedEnd.getTime() < now.getTime(),
|
||||||
|
blockedReason: insight.blockedReason,
|
||||||
|
loadMinutes: operation.plannedMinutes,
|
||||||
|
capacityMinutes: stationLoad?.capacityMinutes ?? null,
|
||||||
|
utilizationPercent: stationLoad?.utilizationPercent ?? null,
|
||||||
|
actions: insight.actions,
|
||||||
});
|
});
|
||||||
|
links.push({ id: `work-order-operation-parent-${workOrder.id}-${operation.id}`, source: workOrderTaskId, target: operationTaskId, type: "e2e" });
|
||||||
links.push({
|
|
||||||
id: `work-order-operation-parent-${workOrder.id}-${operation.id}`,
|
|
||||||
source: workOrderTaskId,
|
|
||||||
target: operationTaskId,
|
|
||||||
type: "e2e",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (previousOperationTaskId) {
|
if (previousOperationTaskId) {
|
||||||
links.push({
|
links.push({
|
||||||
id: `work-order-operation-sequence-${previousOperationTaskId}-${operationTaskId}`,
|
id: `work-order-operation-sequence-${previousOperationTaskId}-${operationTaskId}`,
|
||||||
@@ -396,27 +791,16 @@ export async function getPlanningTimeline(): Promise<PlanningTimelineDto> {
|
|||||||
type: "e2e",
|
type: "e2e",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
previousOperationTaskId = operationTaskId;
|
previousOperationTaskId = operationTaskId;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (workOrder.dueDate === null) {
|
if (workOrder.dueDate === null || insight.overdue || insight.readinessState === "BLOCKED" || insight.readinessState === "SHORTAGE" || insight.readinessState === "PENDING_SUPPLY") {
|
||||||
exceptions.push({
|
exceptions.push({
|
||||||
id: `work-order-unscheduled-${workOrder.id}`,
|
id: workOrder.dueDate === null ? `work-order-unscheduled-${workOrder.id}` : `work-order-${workOrder.id}`,
|
||||||
kind: "WORK_ORDER",
|
kind: "WORK_ORDER",
|
||||||
title: `${workOrder.workOrderNumber} - ${workOrder.item.sku}`,
|
title: `${workOrder.workOrderNumber} - ${workOrder.item.sku}`,
|
||||||
status: workOrder.status,
|
status: workOrder.dueDate === null ? "UNSCHEDULED" : insight.readinessState === "READY" ? workOrder.status : insight.readinessState,
|
||||||
dueDate: null,
|
dueDate: workOrder.dueDate ? workOrder.dueDate.toISOString() : null,
|
||||||
ownerLabel: "No project",
|
|
||||||
detailHref: `/manufacturing/work-orders/${workOrder.id}`,
|
|
||||||
});
|
|
||||||
} else if (workOrder.dueDate.getTime() < now.getTime()) {
|
|
||||||
exceptions.push({
|
|
||||||
id: `work-order-${workOrder.id}`,
|
|
||||||
kind: "WORK_ORDER",
|
|
||||||
title: `${workOrder.workOrderNumber} - ${workOrder.item.sku}`,
|
|
||||||
status: workOrder.status,
|
|
||||||
dueDate: workOrder.dueDate.toISOString(),
|
|
||||||
ownerLabel: "No project",
|
ownerLabel: "No project",
|
||||||
detailHref: `/manufacturing/work-orders/${workOrder.id}`,
|
detailHref: `/manufacturing/work-orders/${workOrder.id}`,
|
||||||
});
|
});
|
||||||
@@ -435,26 +819,27 @@ export async function getPlanningTimeline(): Promise<PlanningTimelineDto> {
|
|||||||
activeProjects: planningProjects.filter((project) => project.status === "ACTIVE").length,
|
activeProjects: planningProjects.filter((project) => project.status === "ACTIVE").length,
|
||||||
atRiskProjects: planningProjects.filter((project) => project.status === "AT_RISK").length,
|
atRiskProjects: planningProjects.filter((project) => project.status === "AT_RISK").length,
|
||||||
overdueProjects: planningProjects.filter((project) => project.dueDate && project.dueDate.getTime() < now.getTime()).length,
|
overdueProjects: planningProjects.filter((project) => project.dueDate && project.dueDate.getTime() < now.getTime()).length,
|
||||||
activeWorkOrders: [...planningProjects.flatMap((project) => project.workOrders), ...standaloneWorkOrders].filter((workOrder) =>
|
activeWorkOrders: openWorkOrders.filter((workOrder) => ["RELEASED", "IN_PROGRESS", "ON_HOLD"].includes(workOrder.status)).length,
|
||||||
["RELEASED", "IN_PROGRESS", "ON_HOLD"].includes(workOrder.status)
|
overdueWorkOrders: openWorkOrders.filter((workOrder) => workOrder.dueDate && workOrder.dueDate.getTime() < now.getTime()).length,
|
||||||
).length,
|
unscheduledWorkOrders: openWorkOrders.filter((workOrder) => workOrder.dueDate === null).length,
|
||||||
overdueWorkOrders: [...planningProjects.flatMap((project) => project.workOrders), ...standaloneWorkOrders].filter(
|
releaseReadyWorkOrders: [...workOrderInsights.values()].filter((insight) => insight.releaseReady).length,
|
||||||
(workOrder) => workOrder.dueDate && workOrder.dueDate.getTime() < now.getTime()
|
blockedWorkOrders: [...workOrderInsights.values()].filter((insight) => insight.readinessState !== "READY").length,
|
||||||
).length,
|
stationCount: stationLoads.length,
|
||||||
unscheduledWorkOrders: standaloneWorkOrders.filter((workOrder) => workOrder.dueDate === null).length,
|
overloadedStations: stationLoads.filter((station) => station.overloaded).length,
|
||||||
horizonStart: horizonStart.toISOString(),
|
horizonStart: horizonStart.toISOString(),
|
||||||
horizonEnd: horizonEnd.toISOString(),
|
horizonEnd: horizonEnd.toISOString(),
|
||||||
},
|
},
|
||||||
exceptions: exceptions
|
exceptions: exceptions
|
||||||
.sort((left, right) => {
|
.sort((left, right) => {
|
||||||
if (!left.dueDate) {
|
if (!left.dueDate) {
|
||||||
return 1;
|
return -1;
|
||||||
}
|
}
|
||||||
if (!right.dueDate) {
|
if (!right.dueDate) {
|
||||||
return -1;
|
return 1;
|
||||||
}
|
}
|
||||||
return new Date(left.dueDate).getTime() - new Date(right.dueDate).getTime();
|
return new Date(left.dueDate).getTime() - new Date(right.dueDate).getTime();
|
||||||
})
|
})
|
||||||
.slice(0, 12),
|
.slice(0, 12),
|
||||||
|
stationLoads,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,15 @@
|
|||||||
|
export const planningReadinessStates = ["READY", "SHORTAGE", "PENDING_SUPPLY", "UNSCHEDULED", "BLOCKED"] as const;
|
||||||
|
|
||||||
|
export type PlanningReadinessState = (typeof planningReadinessStates)[number];
|
||||||
|
|
||||||
|
export interface PlanningTaskActionDto {
|
||||||
|
kind: "OPEN_RECORD" | "RELEASE_WORK_ORDER" | "CREATE_WORK_ORDER" | "CREATE_PURCHASE_ORDER";
|
||||||
|
label: string;
|
||||||
|
href?: string | null;
|
||||||
|
workOrderId?: string | null;
|
||||||
|
itemId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface GanttTaskDto {
|
export interface GanttTaskDto {
|
||||||
id: string;
|
id: string;
|
||||||
text: string;
|
text: string;
|
||||||
@@ -9,6 +21,29 @@ export interface GanttTaskDto {
|
|||||||
status?: string;
|
status?: string;
|
||||||
ownerLabel?: string | null;
|
ownerLabel?: string | null;
|
||||||
detailHref?: string | null;
|
detailHref?: string | null;
|
||||||
|
entityId?: string | null;
|
||||||
|
projectId?: string | null;
|
||||||
|
workOrderId?: string | null;
|
||||||
|
salesOrderId?: string | null;
|
||||||
|
salesOrderLineId?: string | null;
|
||||||
|
itemId?: string | null;
|
||||||
|
itemSku?: string | null;
|
||||||
|
stationId?: string | null;
|
||||||
|
stationCode?: string | null;
|
||||||
|
stationName?: string | null;
|
||||||
|
readinessState?: PlanningReadinessState;
|
||||||
|
readinessScore?: number;
|
||||||
|
shortageItemCount?: number;
|
||||||
|
totalShortageQuantity?: number;
|
||||||
|
linkedSupplyQuantity?: number;
|
||||||
|
openSupplyQuantity?: number;
|
||||||
|
releaseReady?: boolean;
|
||||||
|
overdue?: boolean;
|
||||||
|
blockedReason?: string | null;
|
||||||
|
loadMinutes?: number;
|
||||||
|
capacityMinutes?: number | null;
|
||||||
|
utilizationPercent?: number | null;
|
||||||
|
actions?: PlanningTaskActionDto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GanttLinkDto {
|
export interface GanttLinkDto {
|
||||||
@@ -25,6 +60,10 @@ export interface PlanningSummaryDto {
|
|||||||
activeWorkOrders: number;
|
activeWorkOrders: number;
|
||||||
overdueWorkOrders: number;
|
overdueWorkOrders: number;
|
||||||
unscheduledWorkOrders: number;
|
unscheduledWorkOrders: number;
|
||||||
|
releaseReadyWorkOrders: number;
|
||||||
|
blockedWorkOrders: number;
|
||||||
|
stationCount: number;
|
||||||
|
overloadedStations: number;
|
||||||
horizonStart: string;
|
horizonStart: string;
|
||||||
horizonEnd: string;
|
horizonEnd: string;
|
||||||
}
|
}
|
||||||
@@ -39,9 +78,25 @@ export interface PlanningExceptionDto {
|
|||||||
detailHref: string;
|
detailHref: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PlanningStationLoadDto {
|
||||||
|
stationId: string;
|
||||||
|
stationCode: string;
|
||||||
|
stationName: string;
|
||||||
|
operationCount: number;
|
||||||
|
workOrderCount: number;
|
||||||
|
totalPlannedMinutes: number;
|
||||||
|
capacityMinutes: number;
|
||||||
|
utilizationPercent: number;
|
||||||
|
overloaded: boolean;
|
||||||
|
blockedCount: number;
|
||||||
|
readyCount: number;
|
||||||
|
lateCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PlanningTimelineDto {
|
export interface PlanningTimelineDto {
|
||||||
tasks: GanttTaskDto[];
|
tasks: GanttTaskDto[];
|
||||||
links: GanttLinkDto[];
|
links: GanttLinkDto[];
|
||||||
summary: PlanningSummaryDto;
|
summary: PlanningSummaryDto;
|
||||||
exceptions: PlanningExceptionDto[];
|
exceptions: PlanningExceptionDto[];
|
||||||
|
stationLoads: PlanningStationLoadDto[];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user