Compare commits

..

14 Commits

Author SHA1 Message Date
e65ed892f1 shipping label fix - codex 2026-03-19 21:47:26 -05:00
jason
ce2d52db53 hopeful 2026-03-19 16:38:41 -05:00
jason
39fd876d51 fixing 2026-03-19 16:34:18 -05:00
jason
0c3b2cf6fe fixes 2026-03-19 16:19:48 -05:00
jason
6423dfb91b again 2026-03-19 16:14:35 -05:00
jason
26b188de87 fix 2026-03-19 16:12:10 -05:00
jason
0b43b4ebf5 shipping fix 2026-03-19 16:06:50 -05:00
jason
3c312733ca shipping label 2 2026-03-19 16:01:32 -05:00
jason
9d54dc2ecd shipping label 2026-03-19 15:57:39 -05:00
jason
b762c70238 clean up usage guide 2026-03-19 15:37:51 -05:00
jason
9562c1cc9c usage guide 2026-03-19 13:09:29 -05:00
3eba7c5fa6 workbench 2026-03-19 07:41:06 -05:00
4949b6033f more workbench usability 2026-03-19 07:38:08 -05:00
cf54e4ba58 usability workbench 2026-03-18 23:48:14 -05:00
8 changed files with 698 additions and 34 deletions

View File

@@ -40,6 +40,13 @@ This file is the running release and change log for CODEXIUM. Keep it updated wh
- Workbench usability depth with keyboard row navigation, enter-to-open behavior, escape-to-clear, and inline readiness/shortage/hold signal pills across planner rows and day-detail cards
- Workbench dispatch workflow depth with saved planner views, a release queue for visible ready work, queued-record visibility in the sticky control bar, and batch release directly from the workbench
- Workbench batch operation rebalance with multi-operation selection, sticky-bar batch reschedule controls, station reassignment across selected operations, and selected-operation visibility in row signals and focus context
- Workbench conflict-intelligence pass with projected batch target load, overload warnings before batch station moves, and best-alternate-station suggestions inside the sticky rebalance controls
- Workbench date-aware slot guidance using station working-day calendars and queue settings to suggest the next workable batch landing dates directly from the sticky rebalance controls
- Planning timeline now includes station day-load rollups, and Workbench slot suggestions use that server-backed per-day capacity data instead of only summary-level utilization heuristics
- Workbench now surfaces day-level capacity directly in the planner, including hot-station day counts on heatmap cells, selected-day station load breakdowns, and per-station hot-day chips in station grouping mode
- Workbench exception prioritization now scores and ranks projects, work orders, agenda rows, and dispatch exceptions by lateness, blockage, shortage, readiness, and overload pressure, with inline priority chips for faster triage
- Workbench now surfaces top-priority action lanes for `DO NOW`, `UNBLOCK`, and `RELEASE READY` records so planners can jump straight into ranked dispatch queues before working deeper lists
- Workbench action lanes now support direct follow-through from the lane cards themselves, including queue-release and the first inline build/buy/open actions without requiring a second step into the focus drawer
- 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
- Thumbnail image attachment staging on inventory item create/edit pages, with upload-on-save and replacement/removal support
@@ -96,6 +103,7 @@ This file is the running release and change log for CODEXIUM. Keep it updated wh
### Changed
- Shipping-label PDFs now render inside an explicit single-page 4x6 canvas with tighter print-safe spacing and overflow-safe text wrapping to prevent second-sheet runover on label printers
- Project records now persist milestone plans directly on create/edit instead of treating schedule checkpoints as freeform notes only
- Company theme colors and font now persist correctly across refresh through startup brand-profile hydration in the frontend theme provider
- Demand-planning purchase-order draft generation now links sales-order lines only when the purchase item matches the originating sales item

View File

@@ -50,6 +50,7 @@ type HeatmapCell = {
count: number;
lateCount: number;
blockedCount: number;
hotStationCount: number;
tasks: FocusRecord[];
};
@@ -92,6 +93,38 @@ function dateKey(value: Date) {
return value.toISOString().slice(0, 10);
}
function toLocalDateTimeValue(value: Date) {
const year = value.getFullYear();
const month = String(value.getMonth() + 1).padStart(2, "0");
const day = String(value.getDate()).padStart(2, "0");
const hours = String(value.getHours()).padStart(2, "0");
const minutes = String(value.getMinutes()).padStart(2, "0");
return `${year}-${month}-${day}T${hours}:${minutes}`;
}
function nextWorkingSlot(base: Date, workingDays: number[], workingDaySkips = 0) {
const next = new Date(base);
const allowedDays = new Set(workingDays);
if (allowedDays.size === 0) {
return next;
}
let remainingSkips = workingDaySkips;
let attempts = 0;
while (attempts < 21) {
const isWorkingDay = allowedDays.has(next.getDay());
if (isWorkingDay && remainingSkips <= 0) {
return next;
}
if (isWorkingDay && remainingSkips > 0) {
remainingSkips -= 1;
}
next.setDate(next.getDate() + 1);
attempts += 1;
}
return next;
}
function parseFocusKind(task: GanttTaskDto): FocusRecord["kind"] {
if (task.type === "project") {
return "PROJECT";
@@ -109,6 +142,9 @@ function densityTone(cell: HeatmapCell) {
if (cell.lateCount > 0) {
return "border-rose-400/60 bg-rose-500/25";
}
if (cell.hotStationCount > 0) {
return "border-amber-400/70 bg-amber-400/25";
}
if (cell.blockedCount > 0) {
return "border-amber-300/60 bg-amber-400/25";
}
@@ -156,6 +192,37 @@ function readinessLabel(record: FocusRecord) {
return record.readinessState.replaceAll("_", " ");
}
function priorityScore(record: FocusRecord) {
let score = 0;
if (record.overdue) {
score += 45;
}
if (record.readinessState === "BLOCKED" || record.blockedReason) {
score += 30;
}
if (record.readinessState === "SHORTAGE" || record.readinessState === "PENDING_SUPPLY" || record.totalShortageQuantity > 0) {
score += 24;
}
if (record.kind === "OPERATION" && record.utilizationPercent && record.utilizationPercent > 100) {
score += 18;
}
if (record.kind === "WORK_ORDER" && record.releaseReady) {
score += 8;
}
score += Math.min(record.shortageItemCount * 3, 12);
score += Math.min(Math.round(record.totalShortageQuantity), 15);
score += Math.max(0, 100 - record.readinessScore) / 5;
return Math.round(score);
}
function comparePriority(left: FocusRecord, right: FocusRecord) {
const delta = priorityScore(right) - priorityScore(left);
if (delta !== 0) {
return delta;
}
return new Date(left.end).getTime() - new Date(right.end).getTime();
}
function buildFocusRecords(tasks: GanttTaskDto[]) {
return tasks.map((task) => ({
id: task.id,
@@ -283,6 +350,7 @@ export function WorkbenchPage() {
const summary = timeline?.summary;
const exceptions = timeline?.exceptions ?? [];
const stationLoads = timeline?.stationLoads ?? [];
const stationDayLoads = timeline?.stationDayLoads ?? [];
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]);
@@ -344,7 +412,7 @@ export function WorkbenchPage() {
const cells = new Map<string, HeatmapCell>();
for (let index = 0; index < 84; index += 1) {
const nextDate = new Date(start.getTime() + index * DAY_MS);
cells.set(dateKey(nextDate), { dateKey: dateKey(nextDate), count: 0, lateCount: 0, blockedCount: 0, tasks: [] });
cells.set(dateKey(nextDate), { dateKey: dateKey(nextDate), count: 0, lateCount: 0, blockedCount: 0, hotStationCount: 0, tasks: [] });
}
for (const record of filteredFocusRecords) {
@@ -370,21 +438,131 @@ export function WorkbenchPage() {
}
}
for (const dayLoad of stationDayLoads) {
if (!dayLoad.overloaded) {
continue;
}
const current = cells.get(dayLoad.dateKey);
if (!current) {
continue;
}
current.hotStationCount += 1;
}
return [...cells.values()];
}, [filteredFocusRecords, summary]);
}, [filteredFocusRecords, stationDayLoads, summary]);
const selectedHeatmapCell = selectedHeatmapDate ? heatmap.find((cell) => cell.dateKey === selectedHeatmapDate) ?? null : null;
const stationLoadById = useMemo(() => new Map(stationLoads.map((station) => [station.stationId, station])), [stationLoads]);
const stationDayLoadsByKey = useMemo(() => new Map(stationDayLoads.map((entry) => [`${entry.stationId}:${entry.dateKey}`, entry])), [stationDayLoads]);
const selectedDayStationLoads = useMemo(() => selectedHeatmapDate
? stationDayLoads
.filter((entry) => entry.dateKey === selectedHeatmapDate)
.sort((left, right) => right.utilizationPercent - left.utilizationPercent)
: [], [selectedHeatmapDate, stationDayLoads]);
const stationHotDaysByStationId = useMemo(() => {
const grouped = new Map<string, typeof stationDayLoads>();
for (const entry of stationDayLoads) {
const bucket = grouped.get(entry.stationId) ?? [];
bucket.push(entry);
grouped.set(entry.stationId, bucket);
}
for (const bucket of grouped.values()) {
bucket.sort((left, right) => right.utilizationPercent - left.utilizationPercent);
}
return grouped;
}, [stationDayLoads]);
const selectedRescheduleStation = rescheduleStationId ? stations.find((station) => station.id === rescheduleStationId) ?? null : null;
const selectedRescheduleLoad = selectedRescheduleStation ? stationLoadById.get(selectedRescheduleStation.id) ?? null : null;
const selectedOperationLoadMinutes = useMemo(() => selectedOperations.reduce((sum, record) => sum + Math.max(record.loadMinutes, 1), 0), [selectedOperations]);
const batchTargetLoad = useMemo(() => {
if (!batchStationId) {
return null;
}
const station = stations.find((candidate) => candidate.id === batchStationId) ?? null;
const load = stationLoadById.get(batchStationId) ?? null;
if (!station || !load) {
return null;
}
const projectedMinutes = load.totalPlannedMinutes + selectedOperationLoadMinutes;
const projectedUtilizationPercent = Math.round((projectedMinutes / Math.max(load.capacityMinutes, 1)) * 100);
return {
station,
load,
projectedMinutes,
projectedUtilizationPercent,
overloaded: projectedUtilizationPercent > 100,
};
}, [batchStationId, selectedOperationLoadMinutes, stationLoadById, stations]);
const batchSlotSuggestions = useMemo(() => {
if (!batchTargetLoad || selectedOperations.length === 0) {
return [];
}
const base = batchRescheduleStart ? new Date(batchRescheduleStart) : new Date(selectedOperations[0]?.start ?? new Date().toISOString());
const queueOffset = Math.max(batchTargetLoad.station.queueDays, 0);
const suggestions: Array<{ label: string; value: string; utilizationPercent: number; overloaded: boolean }> = [];
let searchOffset = queueOffset;
let attempts = 0;
while (suggestions.length < 3 && attempts < 14) {
const slotDate = nextWorkingSlot(base, batchTargetLoad.station.workingDays, searchOffset);
const slotKey = dateKey(slotDate);
const currentDayLoad = stationDayLoadsByKey.get(`${batchTargetLoad.station.id}:${slotKey}`);
const capacityMinutes = currentDayLoad?.capacityMinutes ?? Math.max(batchTargetLoad.station.dailyCapacityMinutes, 60) * Math.max(batchTargetLoad.station.parallelCapacity, 1);
const plannedMinutes = (currentDayLoad?.plannedMinutes ?? 0) + selectedOperationLoadMinutes;
const utilizationPercent = Math.round((plannedMinutes / Math.max(capacityMinutes, 1)) * 100);
suggestions.push({
label: formatDate(slotDate.toISOString(), { weekday: "short", month: "short", day: "numeric" }),
value: toLocalDateTimeValue(slotDate),
utilizationPercent,
overloaded: utilizationPercent > 100,
});
searchOffset += utilizationPercent > 100 ? Math.max(1, Math.ceil(utilizationPercent / 100) - 1) : 1;
attempts += 1;
}
return suggestions.map((suggestion) => ({
...suggestion,
}));
}, [batchRescheduleStart, batchTargetLoad, selectedOperationLoadMinutes, selectedOperations, stationDayLoadsByKey]);
const batchStationSuggestions = useMemo(() => {
if (selectedOperations.length === 0) {
return [];
}
return stations
.filter((station) => station.isActive)
.map((station) => {
const load = stationLoadById.get(station.id);
const projectedMinutes = (load?.totalPlannedMinutes ?? 0) + selectedOperationLoadMinutes;
const capacityMinutes = Math.max(load?.capacityMinutes ?? station.dailyCapacityMinutes * station.parallelCapacity, 1);
const projectedUtilizationPercent = Math.round((projectedMinutes / capacityMinutes) * 100);
return {
station,
projectedUtilizationPercent,
overloaded: projectedUtilizationPercent > 100,
};
})
.sort((left, right) => left.projectedUtilizationPercent - right.projectedUtilizationPercent)
.slice(0, 3);
}, [selectedOperationLoadMinutes, selectedOperations.length, stationLoadById, stations]);
const agendaItems = useMemo(
() => [...focusRecords]
.filter((record) => record.kind !== "OPERATION")
.filter((record) => matchesWorkbenchFilter(record, workbenchFilter))
.sort((left, right) => new Date(left.end).getTime() - new Date(right.end).getTime())
.sort(comparePriority)
.slice(0, 18),
[focusRecords, workbenchFilter]
);
const prioritizedRecords = useMemo(() => [...filteredFocusRecords].sort(comparePriority), [filteredFocusRecords]);
const actionLanes = useMemo(() => ({
doNow: prioritizedRecords
.filter((record) => record.overdue || (record.kind === "OPERATION" && (record.utilizationPercent ?? 0) > 100))
.slice(0, 4),
unblock: prioritizedRecords
.filter((record) => record.readinessState === "BLOCKED" || record.readinessState === "SHORTAGE" || record.readinessState === "PENDING_SUPPLY" || Boolean(record.blockedReason))
.slice(0, 4),
releaseReady: prioritizedRecords
.filter((record) => canQueueRelease(record))
.slice(0, 4),
}), [prioritizedRecords]);
const keyboardRecords = useMemo(() => {
if (workbenchMode === "agenda") {
return agendaItems;
@@ -393,11 +571,12 @@ export function WorkbenchPage() {
return (selectedHeatmapCell?.tasks ?? filteredFocusRecords.filter((record) => record.kind !== "PROJECT")).slice(0, 18);
}
const projects = filteredFocusRecords.filter((record) => record.kind === "PROJECT").slice(0, 6);
const operations = filteredFocusRecords.filter((record) => record.kind === "OPERATION");
const workOrders = filteredFocusRecords.filter((record) => record.kind === "WORK_ORDER").slice(0, 10);
const projects = filteredFocusRecords.filter((record) => record.kind === "PROJECT").sort(comparePriority).slice(0, 6);
const operations = filteredFocusRecords.filter((record) => record.kind === "OPERATION").sort(comparePriority);
const workOrders = filteredFocusRecords.filter((record) => record.kind === "WORK_ORDER").sort(comparePriority).slice(0, 10);
const exceptionRows = filteredFocusRecords
.filter((record) => record.kind !== "PROJECT" && (record.overdue || record.totalShortageQuantity > 0 || record.readinessState === "BLOCKED" || record.readinessState === "PENDING_SUPPLY"))
.sort(comparePriority)
.slice(0, 10);
if (workbenchGroup === "projects") {
@@ -417,7 +596,7 @@ export function WorkbenchPage() {
stationBuckets.set(record.stationId, bucket);
}
for (const bucket of stationBuckets.values()) {
bucket.sort((left, right) => new Date(left.start).getTime() - new Date(right.start).getTime());
bucket.sort(comparePriority);
}
return stationLoads
.slice(0, 10)
@@ -819,6 +998,58 @@ export function WorkbenchPage() {
{isBatchRescheduling ? "Applying..." : "Apply batch"}
</button>
</div>
<div className="mt-3 grid gap-2 xl:grid-cols-2">
<div className={`rounded-[16px] border px-2 py-2 text-xs ${batchTargetLoad?.overloaded ? "border-amber-300/60 bg-amber-400/10 text-amber-300" : "border-line/70 bg-surface text-muted"}`}>
<div className="section-kicker">TARGET LOAD</div>
{batchTargetLoad ? (
<div className="mt-2 space-y-1">
<div className="font-semibold text-text">{batchTargetLoad.station.code} - {batchTargetLoad.station.name}</div>
<div>Projected util: <span className="font-semibold text-text">{batchTargetLoad.projectedUtilizationPercent}%</span></div>
<div>Projected minutes: <span className="font-semibold text-text">{batchTargetLoad.projectedMinutes}</span></div>
<div>{batchTargetLoad.overloaded ? "This batch move will overload the target station." : "This batch move stays within summarized station capacity."}</div>
<div>Working days: <span className="font-semibold text-text">{batchTargetLoad.station.workingDays.map((day) => ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][day]).join(", ")}</span></div>
</div>
) : (
<div className="mt-2">Keeping current stations. Pick a station to preview the batch landing load.</div>
)}
</div>
<div className="rounded-[16px] border border-line/70 bg-surface px-2 py-2 text-xs text-muted">
<div className="section-kicker">BEST ALTERNATES</div>
<div className="mt-2 space-y-2">
{batchStationSuggestions.map((suggestion) => (
<button
key={suggestion.station.id}
type="button"
onClick={() => setBatchStationId(suggestion.station.id)}
className={`flex w-full items-center justify-between rounded-[14px] border px-2 py-2 text-left ${batchStationId === suggestion.station.id ? "border-brand bg-brand/10" : "border-line/70 bg-page/60"}`}
>
<span className="font-semibold text-text">{suggestion.station.code} - {suggestion.station.name}</span>
<span className={suggestion.overloaded ? "text-amber-300" : "text-text"}>{suggestion.projectedUtilizationPercent}%</span>
</button>
))}
</div>
</div>
</div>
{batchSlotSuggestions.length > 0 ? (
<div className="mt-3 rounded-[16px] border border-line/70 bg-surface px-2 py-2 text-xs text-muted">
<div className="section-kicker">NEXT SLOT OPTIONS</div>
<div className="mt-2 flex flex-wrap gap-2">
{batchSlotSuggestions.map((suggestion) => (
<button
key={suggestion.value}
type="button"
onClick={() => setBatchRescheduleStart(suggestion.value)}
className={`rounded-2xl border px-2 py-2 text-xs font-semibold ${batchRescheduleStart === suggestion.value ? "border-brand bg-brand/10 text-text" : "border-line/70 bg-page/60 text-text"}`}
>
{suggestion.label} · {suggestion.utilizationPercent}%
</button>
))}
</div>
<div className="mt-2">
Suggestions use the selected station calendar and current summarized load to move the batch onto the next workable slot instead of forcing a same-day overload.
</div>
</div>
) : null}
<div className="mt-3 flex flex-wrap gap-2 text-xs text-muted">
{selectedOperations.slice(0, 6).map((record) => (
<span key={record.id} className="rounded-full border border-brand/40 bg-brand/10 px-2 py-1 text-text">
@@ -845,6 +1076,39 @@ export function WorkbenchPage() {
<MetricCard label="Build / Buy" value={planningRollup ? `${planningRollup.summary.totalBuildQuantity} / ${planningRollup.summary.totalPurchaseQuantity}` : "0 / 0"} />
</section>
<section className="grid gap-3 xl:grid-cols-3">
<ActionLane
title="DO NOW"
accent="border-rose-300/60 bg-rose-500/10 text-rose-200"
records={actionLanes.doNow}
selectedId={selectedFocus?.id ?? null}
onSelect={setSelectedFocusId}
onTaskAction={handleTaskAction}
onQueueRelease={addRecordToQueue}
queuedWorkOrderIds={queuedWorkOrderIds}
/>
<ActionLane
title="UNBLOCK"
accent="border-amber-300/60 bg-amber-400/10 text-amber-300"
records={actionLanes.unblock}
selectedId={selectedFocus?.id ?? null}
onSelect={setSelectedFocusId}
onTaskAction={handleTaskAction}
onQueueRelease={addRecordToQueue}
queuedWorkOrderIds={queuedWorkOrderIds}
/>
<ActionLane
title="RELEASE READY"
accent="border-emerald-300/60 bg-emerald-500/10 text-emerald-300"
records={actionLanes.releaseReady}
selectedId={selectedFocus?.id ?? null}
onSelect={setSelectedFocusId}
onTaskAction={handleTaskAction}
onQueueRelease={addRecordToQueue}
queuedWorkOrderIds={queuedWorkOrderIds}
/>
</section>
<div className="grid gap-3 xl:grid-cols-[320px_minmax(0,1fr)_360px]">
<aside className="space-y-3">
<section className="surface-panel">
@@ -902,6 +1166,7 @@ export function WorkbenchPage() {
<OverviewBoard
focusRecords={filteredFocusRecords}
stationLoads={stationLoads}
stationHotDaysByStationId={stationHotDaysByStationId}
groupMode={workbenchGroup}
onSelect={setSelectedFocusId}
selectedOperationIds={selectedOperationIds}
@@ -1066,7 +1331,7 @@ export function WorkbenchPage() {
<section className="surface-panel">
<p className="section-kicker">{workbenchMode === "heatmap" ? "SELECTED DAY" : "UPCOMING AGENDA"}</p>
{workbenchMode === "heatmap"
? (selectedHeatmapCell ? <SelectedDayPanel cell={selectedHeatmapCell} onSelect={setSelectedFocusId} selectedOperationIds={selectedOperationIds} selectedId={selectedFocus?.id ?? null} /> : <div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">Select a day in the heatmap to inspect its load.</div>)
? (selectedHeatmapCell ? <SelectedDayPanel cell={selectedHeatmapCell} stationLoads={selectedDayStationLoads} stationLookup={stationLoadById} onSelect={setSelectedFocusId} selectedOperationIds={selectedOperationIds} selectedId={selectedFocus?.id ?? null} /> : <div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">Select a day in the heatmap to inspect its load.</div>)
: <AgendaBoard records={agendaItems.slice(0, 8)} onSelect={setSelectedFocusId} selectedOperationIds={selectedOperationIds} selectedId={selectedFocus?.id ?? null} compact />}
</section>
</aside>
@@ -1084,9 +1349,91 @@ function MetricCard({ label, value }: { label: string; value: string | number })
);
}
function ActionLane({
title,
accent,
records,
selectedId,
onSelect,
onTaskAction,
onQueueRelease,
queuedWorkOrderIds,
}: {
title: string;
accent: string;
records: FocusRecord[];
selectedId: string | null;
onSelect: (id: string) => void;
onTaskAction: (action: PlanningTaskActionDto) => void | Promise<void>;
onQueueRelease: (record: FocusRecord) => void;
queuedWorkOrderIds: string[];
}) {
return (
<section className="surface-panel">
<div className="flex items-center justify-between gap-3">
<p className="section-kicker">{title}</p>
<span className={`rounded-full border px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] ${accent}`}>{records.length}</span>
</div>
{records.length === 0 ? (
<div className="mt-3 rounded-[16px] border border-dashed border-line/70 bg-page/60 px-3 py-4 text-sm text-muted">No items in this lane.</div>
) : (
<div className="mt-3 space-y-2">
{records.map((record) => (
<div
key={record.id}
className={`rounded-[16px] border px-2 py-2 ${selectedId === record.id ? "border-brand bg-brand/10" : "border-line/70 bg-page/60"}`}
>
<button type="button" onClick={() => onSelect(record.id)} className="block w-full text-left">
<div className="flex items-start justify-between gap-3">
<div>
<div className="font-semibold text-text">{record.title}</div>
<div className="mt-1 text-xs text-muted">{record.kind} · {record.ownerLabel ?? "No context"}</div>
</div>
<div className="text-right text-xs text-muted">
<div>{formatDate(record.end)}</div>
<div>P{priorityScore(record)}</div>
</div>
</div>
<div className="mt-2 flex flex-wrap gap-2">
<RecordSignals record={record} />
</div>
</button>
<div className="mt-2 flex flex-wrap gap-2">
{canQueueRelease(record) ? (
<button
type="button"
onClick={() => onQueueRelease(record)}
disabled={queuedWorkOrderIds.includes(record.workOrderId ?? record.id)}
className="rounded-2xl border border-line/70 px-2 py-2 text-xs font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60"
>
{queuedWorkOrderIds.includes(record.workOrderId ?? record.id) ? "Queued" : "Queue release"}
</button>
) : null}
{record.actions.slice(0, 2).map((action, index) => (
<button
key={`${record.id}-${action.kind}-${index}`}
type="button"
onClick={() => void onTaskAction(action)}
className={`${index === 0 ? "bg-brand text-white" : "border border-line/70 text-text"} rounded-2xl px-2 py-2 text-xs font-semibold`}
>
{action.label}
</button>
))}
</div>
</div>
))}
</div>
)}
</section>
);
}
function RecordSignals({ record, queued = false, selected = false }: { record: FocusRecord; queued?: boolean; selected?: boolean }) {
return (
<>
<span className="rounded-full border border-line/70 bg-page/60 px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-text">
P{priorityScore(record)}
</span>
<span className={`rounded-full border px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] ${readinessTone(record)}`}>
{readinessLabel(record)}
</span>
@@ -1102,6 +1449,7 @@ function RecordSignals({ record, queued = false, selected = false }: { record: F
function OverviewBoard({
focusRecords,
stationLoads,
stationHotDaysByStationId,
groupMode,
onSelect,
selectedOperationIds,
@@ -1115,6 +1463,7 @@ function OverviewBoard({
}: {
focusRecords: FocusRecord[];
stationLoads: PlanningStationLoadDto[];
stationHotDaysByStationId: Map<string, Array<{ dateKey: string; utilizationPercent: number; overloaded: boolean }>>;
groupMode: WorkbenchGroup;
onSelect: (id: string) => void;
selectedOperationIds: string[];
@@ -1155,7 +1504,10 @@ function OverviewBoard({
{groupMode === "projects" ? (
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.15fr)_minmax(0,0.85fr)]">
<section className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2">
<div className="flex items-center justify-between gap-3">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Program Queue</p>
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted">Priority ranked</span>
</div>
<div className="mt-3 space-y-3">
{projects.map((record) => (
<button key={record.id} type="button" onClick={() => onSelect(record.id)} className={`block w-full rounded-[16px] border px-2 py-2 text-left transition hover:bg-surface ${selectedId === record.id ? "border-brand bg-brand/10" : "border-line/70 bg-surface/80"}`}>
@@ -1177,7 +1529,10 @@ function OverviewBoard({
</div>
</section>
<section className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2">
<div className="flex items-center justify-between gap-3">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Operation Load</p>
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted">Priority ranked</span>
</div>
<div className="mt-3 space-y-2">
{operations.slice(0, 10).map((record) => (
<button key={record.id} type="button" onClick={() => onSelect(record.id)} className={`flex w-full items-center justify-between gap-3 rounded-[16px] border px-2 py-2 text-left transition hover:bg-surface ${selectedId === record.id ? "border-brand bg-brand/10" : "border-line/70 bg-surface/80"}`}>
@@ -1257,6 +1612,13 @@ function OverviewBoard({
<div>Planned {station.totalPlannedMinutes} min</div>
<div>Actual {station.totalActualMinutes} min</div>
</div>
<div className="mt-2 flex flex-wrap gap-2 text-[11px] text-muted">
{(stationHotDaysByStationId.get(station.stationId) ?? []).slice(0, 3).map((entry) => (
<span key={`${station.stationId}-${entry.dateKey}`} className={`rounded-full border px-2 py-1 ${entry.overloaded ? "border-amber-300/60 bg-amber-400/10 text-amber-300" : "border-line/70 bg-page/60"}`}>
{formatDate(entry.dateKey)} {entry.utilizationPercent}%
</span>
))}
</div>
{draggingOperation ? (
<div className="mt-2 rounded-[14px] border border-line/70 bg-page/60 px-2 py-2 text-xs text-muted">
<div className="flex items-center justify-between gap-3">
@@ -1322,7 +1684,10 @@ function OverviewBoard({
) : null}
{groupMode === "exceptions" ? (
<section className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2">
<div className="flex items-center justify-between gap-3">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Dispatch Exceptions</p>
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted">Priority ranked</span>
</div>
<div className="mt-3 space-y-2">
{exceptionRows.map((record) => (
<button key={record.id} type="button" onClick={() => onSelect(record.id)} className={`flex w-full items-center justify-between gap-3 rounded-[16px] border px-2 py-2 text-left transition hover:bg-surface ${selectedId === record.id ? "border-brand bg-brand/10" : "border-line/70 bg-surface/80"}`}>
@@ -1342,7 +1707,10 @@ function OverviewBoard({
</section>
) : null}
<section className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2">
<div className="flex items-center justify-between gap-3">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Active Work Orders</p>
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted">Priority ranked</span>
</div>
<div className="mt-3 grid gap-3 xl:grid-cols-2">
{workOrders.map((record) => (
<button key={record.id} type="button" onClick={() => onSelect(record.id)} className={`rounded-[16px] border px-2 py-2 text-left transition hover:bg-surface ${selectedId === record.id ? "border-brand bg-brand/10" : "border-line/70 bg-surface/80"}`}>
@@ -1376,6 +1744,7 @@ function HeatmapBoard({ heatmap, selectedDate, onSelectDate }: { heatmap: Heatma
<div className="flex flex-wrap items-center gap-2 text-xs text-muted">
<span className="rounded-full border border-line/70 bg-page/60 px-2 py-1">84-day horizon</span>
<span className="rounded-full border border-line/70 bg-page/60 px-2 py-1">{heatmap.reduce((sum, cell) => sum + cell.count, 0)} scheduled touchpoints</span>
<span className="rounded-full border border-line/70 bg-page/60 px-2 py-1">{heatmap.reduce((sum, cell) => sum + cell.hotStationCount, 0)} hot station-days</span>
{selectedDate ? <span className="rounded-full border border-brand/40 bg-brand/10 px-2 py-1 text-text">Selected {formatDate(selectedDate, { weekday: "short", month: "short", day: "numeric" })}</span> : null}
</div>
<div className="overflow-x-auto rounded-[18px] border border-line/70 bg-page/60 px-3 py-3">
@@ -1409,8 +1778,9 @@ function AgendaBoard({ records, onSelect, selectedOperationIds, selectedId, comp
return (
<div className={compact ? "mt-3 space-y-2" : "space-y-3"}>
{!compact ? (
<div>
<div className="flex items-center justify-between gap-3">
<p className="section-kicker">AGENDA</p>
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted">Priority ranked</span>
</div>
) : null}
<div className="space-y-2">
@@ -1434,7 +1804,21 @@ function AgendaBoard({ records, onSelect, selectedOperationIds, selectedId, comp
);
}
function SelectedDayPanel({ cell, onSelect, selectedOperationIds, selectedId }: { cell: HeatmapCell; onSelect: (id: string) => void; selectedOperationIds: string[]; selectedId: string | null }) {
function SelectedDayPanel({
cell,
stationLoads,
stationLookup,
onSelect,
selectedOperationIds,
selectedId,
}: {
cell: HeatmapCell;
stationLoads: Array<{ stationId: string; utilizationPercent: number; overloaded: boolean; plannedMinutes: number; capacityMinutes: number }>;
stationLookup: Map<string, PlanningStationLoadDto>;
onSelect: (id: string) => void;
selectedOperationIds: string[];
selectedId: string | null;
}) {
return (
<div className="mt-3 space-y-2">
<div className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2">
@@ -1443,8 +1827,27 @@ function SelectedDayPanel({ cell, onSelect, selectedOperationIds, selectedId }:
<span>{cell.count} scheduled</span>
<span>{cell.lateCount} late</span>
</div>
<div className="mt-1 text-xs text-muted">{cell.blockedCount} blocked</div>
<div className="mt-1 text-xs text-muted">{cell.blockedCount} blocked · {cell.hotStationCount} hot stations</div>
</div>
{stationLoads.length > 0 ? (
<div className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2">
<div className="section-kicker">STATION LOAD</div>
<div className="mt-2 space-y-2">
{stationLoads.slice(0, 4).map((entry) => {
const station = stationLookup.get(entry.stationId);
return (
<div key={`${entry.stationId}-${cell.dateKey}`} className={`rounded-[14px] border px-2 py-2 text-xs ${entry.overloaded ? "border-amber-300/60 bg-amber-400/10 text-amber-300" : "border-line/70 bg-surface text-muted"}`}>
<div className="flex items-center justify-between gap-3">
<span className="font-semibold text-text">{station?.stationCode ?? entry.stationId}</span>
<span>{entry.utilizationPercent}%</span>
</div>
<div className="mt-1">{entry.plannedMinutes} / {entry.capacityMinutes} min</div>
</div>
);
})}
</div>
</div>
) : null}
<div className="space-y-2">
{cell.tasks.slice(0, 8).map((task) => (
<button key={task.id} type="button" onClick={() => onSelect(task.id)} className={`block w-full rounded-[16px] border px-2 py-2 text-left transition hover:bg-page/80 ${selectedId === task.id ? "border-brand bg-brand/10" : "border-line/70 bg-page/60"}`}>

View File

@@ -1,8 +1,15 @@
import puppeteer from "puppeteer";
import puppeteer, { PaperFormat } from "puppeteer";
import { env } from "../config/env.js";
export async function renderPdf(html: string) {
interface PdfOptions {
format?: PaperFormat;
width?: string;
height?: string;
margin?: { top?: string; right?: string; bottom?: string; left?: string };
}
export async function renderPdf(html: string, options?: PdfOptions) {
const browser = await puppeteer.launch({
executablePath: env.PUPPETEER_EXECUTABLE_PATH,
headless: true,
@@ -14,7 +21,10 @@ export async function renderPdf(html: string) {
await page.setContent(html, { waitUntil: "networkidle0" });
const pdf = await page.pdf({
format: "A4",
format: options?.width || options?.height ? undefined : (options?.format || "A4"),
width: options?.width,
height: options?.height,
margin: options?.margin,
printBackground: true,
preferCSSPageSize: true,
});

View File

@@ -152,29 +152,40 @@ function buildShippingLabelPdf(options: {
<html>
<head>
<style>
@page { size: 4in 6in; margin: 8mm; }
body { font-family: ${company.theme.fontFamily}, Arial, sans-serif; color: #111827; font-size: 11px; }
.label { border: 2px solid #111827; border-radius: 12px; padding: 12px; display: flex; flex-direction: column; gap: 12px; min-height: calc(6in - 16mm); box-sizing: border-box; }
.row { display: flex; justify-content: space-between; gap: 12px; }
@page { size: 4in 6in; margin: 0; }
*, *::before, *::after { box-sizing: border-box; }
html, body { width: 4in; min-width: 4in; max-width: 4in; height: 6in; min-height: 6in; max-height: 6in; margin: 0; padding: 0; overflow: hidden; background: white; }
body { font-family: ${company.theme.fontFamily}, Arial, sans-serif; color: #111827; font-size: 10px; line-height: 1.2; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
.page { width: 4in; height: 6in; padding: 0.14in; overflow: hidden; page-break-after: avoid; break-after: avoid-page; }
.label { width: 100%; height: 100%; border: 2px solid #111827; border-radius: 10px; padding: 0.11in; display: flex; flex-direction: column; gap: 0.09in; overflow: hidden; }
.row { display: flex; justify-content: space-between; gap: 0.09in; }
.muted { font-size: 9px; text-transform: uppercase; letter-spacing: 0.08em; color: #4b5563; }
.brand { border-bottom: 2px solid ${company.theme.primaryColor}; padding-bottom: 10px; }
.brand h1 { margin: 0; font-size: 18px; color: ${company.theme.primaryColor}; }
.block { border: 1px solid #d1d5db; border-radius: 10px; padding: 10px; }
.stack { display: flex; flex-direction: column; gap: 4px; }
.barcode { border: 2px solid #111827; border-radius: 10px; padding: 8px; text-align: center; font-family: monospace; font-size: 18px; letter-spacing: 0.18em; }
.brand { border-bottom: 2px solid ${company.theme.primaryColor}; padding-bottom: 0.09in; }
.brand-row { align-items: flex-start; }
.brand-company { flex: 1; min-width: 0; padding-right: 0.06in; }
.brand h1 { margin: 0; font-size: 16px; line-height: 1.05; color: ${company.theme.primaryColor}; overflow-wrap: anywhere; }
.shipment-number { width: 1.25in; flex: 0 0 1.25in; text-align: right; }
.block { border: 1px solid #d1d5db; border-radius: 10px; padding: 0.08in; min-width: 0; }
.stack { display: flex; flex-direction: column; gap: 3px; }
.barcode { border: 2px solid #111827; border-radius: 8px; padding: 0.08in; text-align: center; font-family: monospace; font-size: 16px; line-height: 1; letter-spacing: 0.15em; }
.strong { font-weight: 700; }
.big { font-size: 16px; font-weight: 700; }
.big { font-size: 15px; line-height: 1.05; font-weight: 700; }
.footer { text-align: center; font-size: 9px; color: #4b5563; overflow-wrap: anywhere; }
.reference-text { margin-top: 6px; overflow-wrap: anywhere; word-break: break-word; }
.block > div[style="margin-top:6px;"] { overflow-wrap: anywhere; word-break: break-word; }
div[style="text-align:center; font-size:10px; color:#4b5563;"] { text-align: center; font-size: 9px; color: #4b5563; overflow-wrap: anywhere; }
</style>
</head>
<body>
<div class="page">
<div class="label">
<div class="brand">
<div class="row">
<div>
<div class="row brand-row">
<div class="brand-company">
<div class="muted">From</div>
<h1>${escapeHtml(company.companyName)}</h1>
</div>
<div style="text-align:right;">
<div class="shipment-number">
<div class="muted">Shipment</div>
<div class="big">${escapeHtml(shipment.shipmentNumber)}</div>
</div>
@@ -217,7 +228,7 @@ function buildShippingLabelPdf(options: {
</div>
</body>
</html>
`);
`, { width: "4in", height: "6in", margin: { top: "0", right: "0", bottom: "0", left: "0" } });
}
function buildBillOfLadingPdf(options: {

View File

@@ -2,6 +2,7 @@ import type {
GanttLinkDto,
GanttTaskDto,
PlanningReadinessState,
PlanningStationDayLoadDto,
PlanningStationLoadDto,
PlanningTaskActionDto,
PlanningTimelineDto,
@@ -94,6 +95,15 @@ type StationAccumulator = {
workingDays: number[];
};
type StationDayAccumulator = {
stationId: string;
dateKey: string;
plannedMinutes: number;
actualMinutes: number;
operationCount: number;
capacityMinutes: number;
};
function clampProgress(value: number) {
return Math.max(0, Math.min(100, Math.round(value)));
}
@@ -199,6 +209,23 @@ function createStationLoad(record: StationAccumulator): PlanningStationLoadDto {
};
}
function createStationDayLoad(record: StationDayAccumulator): PlanningStationDayLoadDto {
const capacityMinutes = Math.max(record.capacityMinutes, 1);
const utilizationPercent = Math.round((record.plannedMinutes / capacityMinutes) * 100);
const actualUtilizationPercent = Math.round((record.actualMinutes / capacityMinutes) * 100);
return {
stationId: record.stationId,
dateKey: record.dateKey,
plannedMinutes: record.plannedMinutes,
actualMinutes: record.actualMinutes,
capacityMinutes,
utilizationPercent,
actualUtilizationPercent,
operationCount: record.operationCount,
overloaded: utilizationPercent > 100,
};
}
function buildProjectTask(
project: PlanningProjectRecord,
projectWorkOrders: PlanningWorkOrderRecord[],
@@ -492,6 +519,7 @@ export async function getPlanningTimeline(): Promise<PlanningTimelineDto> {
}
const stationAccumulators = new Map<string, StationAccumulator>();
const stationDayAccumulators = new Map<string, StationDayAccumulator>();
for (const workOrder of openWorkOrders) {
const insight = workOrderInsights.get(workOrder.id);
for (const operation of workOrder.operations) {
@@ -525,7 +553,22 @@ export async function getPlanningTimeline(): Promise<PlanningTimelineDto> {
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)));
const currentDate = new Date(cursor);
const currentDateKey = dateKey(currentDate);
current.dayKeys.add(currentDateKey);
const dayAccumulatorKey = `${operation.station.id}:${currentDateKey}`;
const dayAccumulator = stationDayAccumulators.get(dayAccumulatorKey) ?? {
stationId: operation.station.id,
dateKey: currentDateKey,
plannedMinutes: 0,
actualMinutes: 0,
operationCount: 0,
capacityMinutes: Math.max(operation.station.dailyCapacityMinutes, 60) * Math.max(operation.station.parallelCapacity, 1),
};
dayAccumulator.plannedMinutes += operation.plannedMinutes;
dayAccumulator.actualMinutes += operation.actualMinutes;
dayAccumulator.operationCount += 1;
stationDayAccumulators.set(dayAccumulatorKey, dayAccumulator);
}
stationAccumulators.set(operation.station.id, current);
}
@@ -537,6 +580,14 @@ export async function getPlanningTimeline(): Promise<PlanningTimelineDto> {
}
return left.stationCode.localeCompare(right.stationCode);
});
const stationDayLoads = [...stationDayAccumulators.values()]
.map(createStationDayLoad)
.sort((left, right) => {
if (left.dateKey !== right.dateKey) {
return left.dateKey.localeCompare(right.dateKey);
}
return left.stationId.localeCompare(right.stationId);
});
const stationLoadById = new Map(stationLoads.map((load) => [load.stationId, load]));
const tasks: GanttTaskDto[] = [];
@@ -863,5 +914,6 @@ export async function getPlanningTimeline(): Promise<PlanningTimelineDto> {
})
.slice(0, 12),
stationLoads,
stationDayLoads,
};
}

View File

@@ -95,10 +95,23 @@ export interface PlanningStationLoadDto {
lateCount: number;
}
export interface PlanningStationDayLoadDto {
stationId: string;
dateKey: string;
plannedMinutes: number;
actualMinutes: number;
capacityMinutes: number;
utilizationPercent: number;
actualUtilizationPercent: number;
operationCount: number;
overloaded: boolean;
}
export interface PlanningTimelineDto {
tasks: GanttTaskDto[];
links: GanttLinkDto[];
summary: PlanningSummaryDto;
exceptions: PlanningExceptionDto[];
stationLoads: PlanningStationLoadDto[];
stationDayLoads: PlanningStationDayLoadDto[];
}

96
test-puppeteer.js Normal file
View File

@@ -0,0 +1,96 @@
import puppeteer from 'puppeteer';
import fs from 'fs';
const html = `
<html>
<head>
<style>
@page { size: 4in 6in; margin: 0; }
*, *::before, *::after { box-sizing: border-box; }
html, body { width: 4in; height: 6in; margin: 0; padding: 0.5in; overflow: hidden; }
body { font-family: Arial, sans-serif; color: #111827; font-size: 11px; }
.label { border: 2px solid #111827; border-radius: 12px; padding: 12px; display: flex; flex-direction: column; gap: 12px; height: 100%; overflow: hidden; }
.row { display: flex; justify-content: space-between; gap: 12px; }
.muted { font-size: 9px; text-transform: uppercase; letter-spacing: 0.08em; color: #4b5563; }
.brand { border-bottom: 2px solid #ed8936; padding-bottom: 10px; }
.brand h1 { margin: 0; font-size: 18px; color: #ed8936; }
.block { border: 1px solid #d1d5db; border-radius: 10px; padding: 10px; }
.stack { display: flex; flex-direction: column; gap: 4px; }
.barcode { border: 2px solid #111827; border-radius: 10px; padding: 8px; text-align: center; font-family: monospace; font-size: 18px; letter-spacing: 0.18em; }
.strong { font-weight: 700; }
.big { font-size: 16px; font-weight: 700; }
</style>
</head>
<body>
<div class="label">
<div class="brand">
<div class="row">
<div>
<div class="muted">From</div>
<h1>Message Point Media</h1>
</div>
<div style="text-align:right;">
<div class="muted">Shipment</div>
<div class="big">SHP-00003</div>
</div>
</div>
</div>
<div class="block">
<div class="muted">Ship To</div>
<div class="stack" style="margin-top:8px;">
<div class="strong">Northwind Fabrication</div>
<div>42 Assembly Ave</div>
<div>Milwaukee, WI 53202</div>
<div>USA</div>
</div>
</div>
<div class="row">
<div class="block" style="flex:1;">
<div class="muted">Service</div>
<div class="big" style="margin-top:6px;">GROUND</div>
</div>
<div class="block" style="width:90px;">
<div class="muted">Pkgs</div>
<div class="big" style="margin-top:6px;">1</div>
</div>
</div>
<div class="row">
<div class="block" style="flex:1;">
<div class="muted">Sales Order</div>
<div class="strong" style="margin-top:6px;">SO-00002</div>
</div>
<div class="block" style="width:110px;">
<div class="muted">Ship Date</div>
<div class="strong" style="margin-top:6px;">N/A</div>
</div>
</div>
<div class="block">
<div class="muted">Reference</div>
<div style="margin-top:6px;">FG-CTRL-BASE · Control Base Assembly</div>
</div>
<div class="barcode">
*SHP-00003*
</div>
<div style="text-align:center; font-size:10px; color:#4b5563;">Carrier pending · Tracking pending</div>
</div>
</body>
</html>
`;
async function run() {
const browser = await puppeteer.launch();
try {
const page = await browser.newPage();
await page.setContent(html, { waitUntil: "networkidle0" });
const pdf = await page.pdf({
format: "A4",
printBackground: true,
preferCSSPageSize: true,
});
fs.writeFileSync('/tmp/test-label.pdf', pdf);
console.log("PDF generated at /tmp/test-label.pdf");
} finally {
await browser.close();
}
}
run();

71
usage_guide.md Normal file
View File

@@ -0,0 +1,71 @@
# CODEXIUM: End-to-End Workflow Guide
This guide walks through the core operational workflow in CODEXIUM, starting from capturing a customer request in a Quote, all the way to shipping the final product.
## 1. Create a Quote
The process begins when a customer requests pricing.
1. Navigate to the **Quotes** module.
2. Click to create a **New Quote**.
3. Select the Customer using the searchable lookup.
4. Add **Quote Lines** by searching for the requested Inventory SKUs. The default price from the item master will automatically populate.
5. Add any necessary discounts, freight, and notes.
6. The Quote can go through an approval process. Once the customer accepts the terms, proceed to the next step.
## 2. Convert to Sales Order
Once the Quote is accepted, it becomes firm demand.
1. Open the approved **Quote**.
2. Use the action menu to **Convert to Sales Order**.
3. This creates a new Sales Order record, carrying over all lines, pricing, and customer information.
4. The Sales Order is now the authoritative commercial record for this demand.
## 3. Create a Project
For complex or long-running deliveries, create a Project to track the execution.
1. Navigate to the **Projects** module.
2. Click **New Project** and select the same Customer.
3. Define the project scope, priority, due dates, and owner.
4. Set up high-level **Milestones** to track progress on deliverables.
## 4. Assign Quote and Sales Order to Project
Link the commercial documents to the execution tracker.
1. Open the newly created **Project**.
2. In the project details, link the original **Quote** and the active **Sales Order**.
3. *Note: You can also link the Project from within the Quote and Sales Order detail pages. This reverse-link ensures that quote conversion automatically carries the project context into the Sales Order.*
4. The Project dashboard now provides visibility into the commercial value and linked deliverables.
## 5. Determine Manufacturing & Supply Requirements (Demand Planning)
With the Sales Order firm and linked to a Project, determine what needs to be made or bought.
1. Navigate to the **Workbench** module, where the **Demand Planning** view is available for the Sales Order.
2. The system runs a **Multi-level BOM explosion** against the Sales Order lines, netting against current stock and open supply (existing POs/MOs).
3. The system will generate **Build/Buy recommendations** for any shortages.
## 6. Issue Purchase Orders (POs)
Fulfill the "Buy" recommendations.
1. From the Demand Planning recommendations, use the planner-assisted conversion to draft **Purchase Orders** for the required buyout items and raw materials.
2. The POs will automatically peg back to the source Sales Order and carry the Project context.
3. Preferred vendors from the inventory item master are selected by default.
4. Send the PO PDFs to vendors and manage receiving in the **Purchase Orders** and **Warehouse** modules.
## 7. Issue Manufacturing Orders (MOs/Work Orders)
Fulfill the "Build" recommendations.
1. Again, from the Demand Planning recommendations, convert the "Build" shortages into **Work Orders**.
2. The Work Orders define the execution plan (Operations/Stations) based on the item's routing templates in the **Manufacturing** module.
3. Provide the Work Order to the shop floor.
4. As operators log labor and issue materials against the Work Order, the costs roll up, and final completion posts the finished goods to inventory, making them available for shipment.
## 8. Assign Shipping
Once production is complete, deliver the goods.
1. Navigate to the **Shipments** module and create a **New Shipment**.
2. Link the Shipment directly to the **Sales Order**.
3. The system shows the ordered vs. picked vs. remaining quantities.
4. Execute **Shipment Picking**, which pulls stock from specific warehouse locations and posts the inventory issue transactions.
5. Update logistics details: Carrier, Service Level, Tracking Number, and Package Count.
6. Generate branded logistics PDFs directly from the Shipment: **Packing Slips, Shipping Labels, and Bills of Lading**.
7. The Shipment can also be seen from the linked Project, closing the loop on the delivery lifecycle.