Compare commits
14 Commits
061057339b
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| e65ed892f1 | |||
|
|
ce2d52db53 | ||
|
|
39fd876d51 | ||
|
|
0c3b2cf6fe | ||
|
|
6423dfb91b | ||
|
|
26b188de87 | ||
|
|
0b43b4ebf5 | ||
|
|
3c312733ca | ||
|
|
9d54dc2ecd | ||
|
|
b762c70238 | ||
|
|
9562c1cc9c | ||
| 3eba7c5fa6 | |||
| 4949b6033f | |||
| cf54e4ba58 |
@@ -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
|
||||
|
||||
@@ -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">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Program Queue</p>
|
||||
<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">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Operation Load</p>
|
||||
<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">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Dispatch Exceptions</p>
|
||||
<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">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Active Work Orders</p>
|
||||
<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"}`}>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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="label">
|
||||
<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: {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
96
test-puppeteer.js
Normal 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
71
usage_guide.md
Normal 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.
|
||||
Reference in New Issue
Block a user