Compare commits

..

2 Commits

Author SHA1 Message Date
061057339b more 2026-03-18 23:42:30 -05:00
7b65fe06cf more workbench 2026-03-18 23:32:12 -05:00
3 changed files with 465 additions and 13 deletions

View File

@@ -37,6 +37,9 @@ This file is the running release and change log for CODEXIUM. Keep it updated wh
- Continued density standardization across project cockpit/detail internals, including tighter cockpit cards, denser purchasing and readiness panels, and compact milestone, manufacturing-link, and activity-timeline surfaces - Continued density standardization across project cockpit/detail internals, including tighter cockpit cards, denser purchasing and readiness panels, and compact milestone, manufacturing-link, and activity-timeline surfaces
- Continued density standardization across admin diagnostics, user management, and CRM contacts, including tighter filter/forms, denser summary cards, and compact contact/account management surfaces - Continued density standardization across admin diagnostics, user management, and CRM contacts, including tighter filter/forms, denser summary cards, and compact contact/account management surfaces
- Workbench usability pass with sticky planner controls, stronger selected-row and selected-day state, clearer heatmap/day context, and more explicit dispatch-oriented action affordances - Workbench usability pass with sticky planner controls, stronger selected-row and selected-day state, clearer heatmap/day context, and more explicit dispatch-oriented action affordances
- 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
- Project-side milestone and work-order rollups surfaced on project list and detail pages - Project-side milestone and work-order rollups surfaced on project list and detail pages
- Inventory SKU master builder with family-level sequence codes, branch-aware taxonomy management, and generated SKU previews on the item form - Inventory SKU master builder with family-level sequence codes, branch-aware taxonomy management, and generated SKU previews on the item form
- Thumbnail image attachment staging on inventory item create/edit pages, with upload-on-save and replacement/removal support - Thumbnail image attachment staging on inventory item create/edit pages, with upload-on-save and replacement/removal support

View File

@@ -112,6 +112,7 @@ This file tracks work that still needs to be completed. Shipped phase history an
### Planning and scheduling ### Planning and scheduling
- Standardize dense UI primitives and shared page shells so future Workbench, dashboard, and operational screens reuse the same cards, filter bars, empty states, and section wrappers instead of reintroducing ad hoc layout patterns
- Task dependencies, milestones, and progress updates - Task dependencies, milestones, and progress updates
- Manufacturing calendar views and deeper bottleneck visibility beyond the shipped station load and overload workbench summaries - Manufacturing calendar views and deeper bottleneck visibility beyond the shipped station load and overload workbench summaries
- Labor and machine scheduling support beyond the shipped station calendar/capacity foundation - Labor and machine scheduling support beyond the shipped station calendar/capacity foundation

View File

@@ -62,8 +62,16 @@ type DraggingOperation = {
start: string; start: string;
loadMinutes: number; loadMinutes: number;
}; };
type SavedWorkbenchView = {
id: string;
name: string;
mode: WorkbenchMode;
group: WorkbenchGroup;
filter: WorkbenchFilter;
};
const DAY_MS = 24 * 60 * 60 * 1000; const DAY_MS = 24 * 60 * 60 * 1000;
const WORKBENCH_VIEW_STORAGE_KEY = "codexium.workbench.savedViews";
function formatDate(value: string | null, options?: Intl.DateTimeFormatOptions) { function formatDate(value: string | null, options?: Intl.DateTimeFormatOptions) {
if (!value) { if (!value) {
@@ -116,6 +124,38 @@ function densityTone(cell: HeatmapCell) {
return "border-line/60 bg-surface/70"; return "border-line/60 bg-surface/70";
} }
function readinessTone(record: FocusRecord) {
if (record.overdue) {
return "border-rose-300/60 bg-rose-500/10 text-rose-200 dark:text-rose-200";
}
if (record.readinessState === "BLOCKED" || record.blockedReason) {
return "border-amber-300/60 bg-amber-400/10 text-amber-300";
}
if (record.totalShortageQuantity > 0 || record.readinessState === "SHORTAGE" || record.readinessState === "PENDING_SUPPLY") {
return "border-orange-300/60 bg-orange-400/10 text-orange-300";
}
if (record.releaseReady || record.readinessState === "READY") {
return "border-emerald-300/60 bg-emerald-500/10 text-emerald-300";
}
return "border-line/70 bg-page/60 text-muted";
}
function readinessLabel(record: FocusRecord) {
if (record.overdue) {
return "OVERDUE";
}
if (record.readinessState === "BLOCKED" || record.blockedReason) {
return "BLOCKED";
}
if (record.totalShortageQuantity > 0 || record.readinessState === "SHORTAGE" || record.readinessState === "PENDING_SUPPLY") {
return "SHORTAGE";
}
if (record.releaseReady || record.readinessState === "READY") {
return "READY";
}
return record.readinessState.replaceAll("_", " ");
}
function buildFocusRecords(tasks: GanttTaskDto[]) { function buildFocusRecords(tasks: GanttTaskDto[]) {
return tasks.map((task) => ({ return tasks.map((task) => ({
id: task.id, id: task.id,
@@ -169,6 +209,10 @@ function exceptionTargetId(exceptionId: string) {
return exceptionId.startsWith("project-") ? exceptionId : exceptionId.replace("work-order-unscheduled-", "work-order-"); return exceptionId.startsWith("project-") ? exceptionId : exceptionId.replace("work-order-unscheduled-", "work-order-");
} }
function canQueueRelease(record: FocusRecord) {
return record.kind === "WORK_ORDER" && record.releaseReady && record.actions.some((action) => action.kind === "RELEASE_WORK_ORDER" && action.workOrderId);
}
export function WorkbenchPage() { export function WorkbenchPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { token } = useAuth(); const { token } = useAuth();
@@ -186,6 +230,36 @@ export function WorkbenchPage() {
const [stations, setStations] = useState<ManufacturingStationDto[]>([]); const [stations, setStations] = useState<ManufacturingStationDto[]>([]);
const [draggingOperation, setDraggingOperation] = useState<DraggingOperation | null>(null); const [draggingOperation, setDraggingOperation] = useState<DraggingOperation | null>(null);
const [dropStationId, setDropStationId] = useState<string | null>(null); const [dropStationId, setDropStationId] = useState<string | null>(null);
const [queuedWorkOrderIds, setQueuedWorkOrderIds] = useState<string[]>([]);
const [savedViews, setSavedViews] = useState<SavedWorkbenchView[]>([]);
const [isBatchReleasing, setIsBatchReleasing] = useState(false);
const [selectedOperationIds, setSelectedOperationIds] = useState<string[]>([]);
const [batchRescheduleStart, setBatchRescheduleStart] = useState("");
const [batchStationId, setBatchStationId] = useState("");
const [isBatchRescheduling, setIsBatchRescheduling] = useState(false);
useEffect(() => {
if (typeof window === "undefined") {
return;
}
const stored = window.localStorage.getItem(WORKBENCH_VIEW_STORAGE_KEY);
if (!stored) {
return;
}
try {
const parsed = JSON.parse(stored) as SavedWorkbenchView[];
setSavedViews(Array.isArray(parsed) ? parsed : []);
} catch {
setSavedViews([]);
}
}, []);
useEffect(() => {
if (typeof window === "undefined") {
return;
}
window.localStorage.setItem(WORKBENCH_VIEW_STORAGE_KEY, JSON.stringify(savedViews));
}, [savedViews]);
useEffect(() => { useEffect(() => {
if (!token) { if (!token) {
@@ -213,6 +287,33 @@ export function WorkbenchPage() {
const filteredFocusRecords = useMemo(() => focusRecords.filter((record) => matchesWorkbenchFilter(record, workbenchFilter)), [focusRecords, workbenchFilter]); const filteredFocusRecords = useMemo(() => focusRecords.filter((record) => matchesWorkbenchFilter(record, workbenchFilter)), [focusRecords, workbenchFilter]);
const focusById = useMemo(() => new Map(focusRecords.map((record) => [record.id, record])), [focusRecords]); const focusById = useMemo(() => new Map(focusRecords.map((record) => [record.id, record])), [focusRecords]);
const selectedFocus = selectedFocusId ? focusById.get(selectedFocusId) ?? null : filteredFocusRecords[0] ?? focusRecords[0] ?? null; const selectedFocus = selectedFocusId ? focusById.get(selectedFocusId) ?? null : filteredFocusRecords[0] ?? focusRecords[0] ?? null;
const queuedRecords = useMemo(() => queuedWorkOrderIds.reduce<FocusRecord[]>((records, id) => {
const match = focusRecords.find((record) => (record.workOrderId ?? record.id) === id);
if (match) {
records.push(match);
}
return records;
}, []), [focusRecords, queuedWorkOrderIds]);
const releasableQueuedRecords = useMemo(() => queuedRecords.filter((record) => canQueueRelease(record)), [queuedRecords]);
const visibleReleasableRecords = useMemo(() => filteredFocusRecords.filter((record) => canQueueRelease(record)), [filteredFocusRecords]);
const selectedOperations = useMemo(() => selectedOperationIds.reduce<FocusRecord[]>((records, id) => {
const match = focusRecords.find((record) => record.id === id && record.kind === "OPERATION");
if (match) {
records.push(match);
}
return records;
}, []), [focusRecords, selectedOperationIds]);
useEffect(() => {
setQueuedWorkOrderIds((current) => current.filter((id) => {
const record = focusRecords.find((candidate) => (candidate.workOrderId ?? candidate.id) === id);
return record ? canQueueRelease(record) : false;
}));
}, [focusRecords]);
useEffect(() => {
setSelectedOperationIds((current) => current.filter((id) => focusRecords.some((record) => record.id === id && record.kind === "OPERATION")));
}, [focusRecords]);
useEffect(() => { useEffect(() => {
if (selectedFocus?.kind === "OPERATION") { if (selectedFocus?.kind === "OPERATION") {
@@ -224,6 +325,20 @@ export function WorkbenchPage() {
} }
}, [selectedFocus?.id, selectedFocus?.kind, selectedFocus?.start, selectedFocus?.stationId]); }, [selectedFocus?.id, selectedFocus?.kind, selectedFocus?.start, selectedFocus?.stationId]);
useEffect(() => {
if (selectedOperations.length === 0) {
setBatchRescheduleStart("");
setBatchStationId("");
return;
}
const first = selectedOperations[0];
if (!first) {
return;
}
setBatchRescheduleStart((current) => current || first.start.slice(0, 16));
setBatchStationId((current) => current || first.stationId || "");
}, [selectedOperations]);
const heatmap = useMemo(() => { const heatmap = useMemo(() => {
const start = summary ? startOfDay(new Date(summary.horizonStart)) : startOfDay(new Date()); const start = summary ? startOfDay(new Date(summary.horizonStart)) : startOfDay(new Date());
const cells = new Map<string, HeatmapCell>(); const cells = new Map<string, HeatmapCell>();
@@ -270,6 +385,91 @@ export function WorkbenchPage() {
.slice(0, 18), .slice(0, 18),
[focusRecords, workbenchFilter] [focusRecords, workbenchFilter]
); );
const keyboardRecords = useMemo(() => {
if (workbenchMode === "agenda") {
return agendaItems;
}
if (workbenchMode === "heatmap") {
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 exceptionRows = filteredFocusRecords
.filter((record) => record.kind !== "PROJECT" && (record.overdue || record.totalShortageQuantity > 0 || record.readinessState === "BLOCKED" || record.readinessState === "PENDING_SUPPLY"))
.slice(0, 10);
if (workbenchGroup === "projects") {
return [...projects, ...operations.slice(0, 10), ...workOrders].filter((record, index, array) => array.findIndex((candidate) => candidate.id === record.id) === index);
}
if (workbenchGroup === "exceptions") {
return [...exceptionRows, ...workOrders].filter((record, index, array) => array.findIndex((candidate) => candidate.id === record.id) === index);
}
const stationBuckets = new Map<string, FocusRecord[]>();
for (const record of operations) {
if (!record.stationId) {
continue;
}
const bucket = stationBuckets.get(record.stationId) ?? [];
bucket.push(record);
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());
}
return stationLoads
.slice(0, 10)
.flatMap((station) => (stationBuckets.get(station.stationId) ?? []).slice(0, 5));
}, [agendaItems, filteredFocusRecords, selectedHeatmapCell?.dateKey, selectedHeatmapCell?.tasks, stationLoads, workbenchGroup, workbenchMode]);
const visibleOperations = useMemo(() => keyboardRecords.filter((record) => record.kind === "OPERATION"), [keyboardRecords]);
useEffect(() => {
function handleKeydown(event: KeyboardEvent) {
const target = event.target;
if (target instanceof HTMLElement) {
const tagName = target.tagName;
if (target.isContentEditable || tagName === "INPUT" || tagName === "TEXTAREA" || tagName === "SELECT" || tagName === "BUTTON") {
return;
}
}
if (event.altKey || event.ctrlKey || event.metaKey || keyboardRecords.length === 0) {
return;
}
if (event.key === "ArrowDown" || event.key === "j") {
event.preventDefault();
const currentIndex = selectedFocus ? keyboardRecords.findIndex((record) => record.id === selectedFocus.id) : -1;
const nextIndex = currentIndex >= 0 ? Math.min(currentIndex + 1, keyboardRecords.length - 1) : 0;
setSelectedFocusId(keyboardRecords[nextIndex]?.id ?? null);
}
if (event.key === "ArrowUp" || event.key === "k") {
event.preventDefault();
const currentIndex = selectedFocus ? keyboardRecords.findIndex((record) => record.id === selectedFocus.id) : -1;
const nextIndex = currentIndex >= 0 ? Math.max(currentIndex - 1, 0) : 0;
setSelectedFocusId(keyboardRecords[nextIndex]?.id ?? null);
}
if (event.key === "Enter" && selectedFocus?.detailHref) {
event.preventDefault();
navigate(selectedFocus.detailHref);
}
if (event.key === "Escape") {
event.preventDefault();
if (selectedHeatmapDate) {
setSelectedHeatmapDate(null);
return;
}
setSelectedFocusId(null);
}
}
window.addEventListener("keydown", handleKeydown);
return () => window.removeEventListener("keydown", handleKeydown);
}, [keyboardRecords, navigate, selectedFocus, selectedHeatmapDate]);
const modeOptions: Array<{ value: WorkbenchMode; label: string; detail: string }> = [ const modeOptions: Array<{ value: WorkbenchMode; label: string; detail: string }> = [
{ value: "overview", label: "Overview", detail: "Dense planner board" }, { value: "overview", label: "Overview", detail: "Dense planner board" },
@@ -313,6 +513,110 @@ export function WorkbenchPage() {
} }
} }
function addRecordToQueue(record: FocusRecord) {
if (!canQueueRelease(record)) {
return;
}
const queueId = record.workOrderId ?? record.id;
setQueuedWorkOrderIds((current) => (current.includes(queueId) ? current : [...current, queueId]));
setStatus(`Queued ${record.title} for batch release.`);
}
function queueVisibleReady() {
const nextIds = visibleReleasableRecords.map((record) => record.workOrderId ?? record.id);
if (nextIds.length === 0) {
return;
}
setQueuedWorkOrderIds((current) => [...new Set([...current, ...nextIds])]);
setStatus(`Queued ${nextIds.length} visible release-ready work orders.`);
}
async function releaseQueuedWorkOrders() {
if (!token || releasableQueuedRecords.length === 0) {
return;
}
setIsBatchReleasing(true);
try {
await Promise.all(releasableQueuedRecords.map((record) => api.updateWorkOrderStatus(token, record.workOrderId!, { status: "RELEASED" })));
setQueuedWorkOrderIds([]);
await refreshWorkbench(`Released ${releasableQueuedRecords.length} queued work orders from Workbench.`);
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to release queued work orders.";
setStatus(message);
} finally {
setIsBatchReleasing(false);
}
}
function saveCurrentView() {
if (typeof window === "undefined") {
return;
}
const suggested = `${workbenchMode.toUpperCase()} ${workbenchGroup.toUpperCase()}`;
const name = window.prompt("Save workbench view as:", suggested)?.trim();
if (!name) {
return;
}
const nextView: SavedWorkbenchView = {
id: `${Date.now()}`,
name,
mode: workbenchMode,
group: workbenchGroup,
filter: workbenchFilter,
};
setSavedViews((current) => [nextView, ...current].slice(0, 8));
setStatus(`Saved workbench view: ${name}.`);
}
function applySavedView(view: SavedWorkbenchView) {
setWorkbenchMode(view.mode);
setWorkbenchGroup(view.group);
setWorkbenchFilter(view.filter);
setStatus(`Loaded saved view: ${view.name}.`);
}
function toggleOperationSelection(record: FocusRecord) {
if (record.kind !== "OPERATION") {
return;
}
setSelectedOperationIds((current) => current.includes(record.id) ? current.filter((id) => id !== record.id) : [...current, record.id]);
}
function selectVisibleOperations() {
const nextIds = visibleOperations.map((record) => record.id);
if (nextIds.length === 0) {
return;
}
setSelectedOperationIds((current) => [...new Set([...current, ...nextIds])]);
setStatus(`Selected ${nextIds.length} visible operations for batch rebalance.`);
}
async function applyBatchReschedule() {
if (!token || selectedOperations.length === 0 || !batchRescheduleStart) {
return;
}
setIsBatchRescheduling(true);
try {
const plannedStart = new Date(batchRescheduleStart).toISOString();
await Promise.all(selectedOperations.map((record) => {
if (!record.workOrderId || !record.entityId) {
return Promise.resolve();
}
return api.updateWorkOrderOperationSchedule(token, record.workOrderId, record.entityId, {
plannedStart,
stationId: batchStationId || record.stationId || null,
});
}));
setSelectedOperationIds([]);
await refreshWorkbench(`Rebalanced ${selectedOperations.length} operations from Workbench.`);
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to rebalance selected operations.";
setStatus(message);
} finally {
setIsBatchRescheduling(false);
}
}
async function rebalanceOperation(record: FocusRecord, nextStartIso?: string, nextStationId?: string | null) { async function rebalanceOperation(record: FocusRecord, nextStartIso?: string, nextStationId?: string | null) {
if (!token || record.kind !== "OPERATION" || !record.workOrderId || !record.entityId) { if (!token || record.kind !== "OPERATION" || !record.workOrderId || !record.entityId) {
return; return;
@@ -425,6 +729,11 @@ export function WorkbenchPage() {
</div> </div>
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-muted"> <div className="mt-3 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">{filteredFocusRecords.length} visible rows</span> <span className="rounded-full border border-line/70 bg-page/60 px-2 py-1">{filteredFocusRecords.length} visible rows</span>
<span className="rounded-full border border-line/70 bg-page/60 px-2 py-1">Up/Down navigate</span>
<span className="rounded-full border border-line/70 bg-page/60 px-2 py-1">Enter open</span>
<span className="rounded-full border border-line/70 bg-page/60 px-2 py-1">Esc clear</span>
<span className="rounded-full border border-line/70 bg-page/60 px-2 py-1">{releasableQueuedRecords.length} queued</span>
<span className="rounded-full border border-line/70 bg-page/60 px-2 py-1">{selectedOperations.length} ops selected</span>
{selectedFocus ? ( {selectedFocus ? (
<span className="rounded-full border border-brand/40 bg-brand/10 px-2 py-1 text-text"> <span className="rounded-full border border-brand/40 bg-brand/10 px-2 py-1 text-text">
Selected {selectedFocus.kind.toLowerCase()}: {selectedFocus.title} Selected {selectedFocus.kind.toLowerCase()}: {selectedFocus.title}
@@ -436,6 +745,90 @@ export function WorkbenchPage() {
</button> </button>
) : null} ) : null}
</div> </div>
<div className="mt-3 flex flex-wrap items-center gap-2">
<button type="button" onClick={saveCurrentView} className="rounded-2xl border border-line/70 bg-page/60 px-3 py-2 text-sm font-semibold text-text">
Save view
</button>
<button type="button" onClick={queueVisibleReady} disabled={visibleReleasableRecords.length === 0} className="rounded-2xl border border-line/70 bg-page/60 px-3 py-2 text-sm font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60">
Queue visible ready
</button>
<button type="button" onClick={() => void releaseQueuedWorkOrders()} disabled={isBatchReleasing || releasableQueuedRecords.length === 0} className="rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60">
{isBatchReleasing ? "Releasing..." : "Release queued"}
</button>
{queuedWorkOrderIds.length > 0 ? (
<button type="button" onClick={() => setQueuedWorkOrderIds([])} className="rounded-2xl border border-line/70 bg-page/60 px-3 py-2 text-sm font-semibold text-text">
Clear queue
</button>
) : null}
<button type="button" onClick={selectVisibleOperations} disabled={visibleOperations.length === 0} className="rounded-2xl border border-line/70 bg-page/60 px-3 py-2 text-sm font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60">
Select visible ops
</button>
{selectedOperationIds.length > 0 ? (
<button type="button" onClick={() => setSelectedOperationIds([])} className="rounded-2xl border border-line/70 bg-page/60 px-3 py-2 text-sm font-semibold text-text">
Clear op selection
</button>
) : null}
</div>
{savedViews.length > 0 ? (
<div className="mt-3 flex flex-wrap items-center gap-2">
{savedViews.map((view) => (
<div key={view.id} className="flex items-center overflow-hidden rounded-2xl border border-line/70 bg-page/60">
<button type="button" onClick={() => applySavedView(view)} className="px-3 py-2 text-sm font-semibold text-text">
{view.name}
</button>
<button type="button" onClick={() => setSavedViews((current) => current.filter((candidate) => candidate.id !== view.id))} className="border-l border-line/70 px-2 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-muted">
X
</button>
</div>
))}
</div>
) : null}
{queuedRecords.length > 0 ? (
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-muted">
{queuedRecords.slice(0, 6).map((record) => (
<span key={record.workOrderId ?? record.id} className="rounded-full border border-brand/40 bg-brand/10 px-2 py-1 text-text">
{record.title}
</span>
))}
{queuedRecords.length > 6 ? <span className="rounded-full border border-line/70 bg-page/60 px-2 py-1">+{queuedRecords.length - 6} more</span> : null}
</div>
) : null}
{selectedOperations.length > 0 ? (
<div className="mt-3 rounded-[18px] border border-line/70 bg-page/60 px-3 py-3">
<div className="section-kicker">BATCH REBALANCE</div>
<div className="mt-3 grid gap-2 xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto]">
<input
type="datetime-local"
value={batchRescheduleStart}
onChange={(event) => setBatchRescheduleStart(event.target.value)}
className="rounded-2xl border border-line/70 bg-surface px-2 py-2 text-sm text-text outline-none transition focus:border-brand"
/>
<select
value={batchStationId}
onChange={(event) => setBatchStationId(event.target.value)}
className="rounded-2xl border border-line/70 bg-surface px-2 py-2 text-sm text-text outline-none transition focus:border-brand"
>
<option value="">Keep current station</option>
{stations.filter((station) => station.isActive).map((station) => (
<option key={station.id} value={station.id}>
{station.code} - {station.name}
</option>
))}
</select>
<button type="button" onClick={() => void applyBatchReschedule()} disabled={isBatchRescheduling || !batchRescheduleStart} className="rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60">
{isBatchRescheduling ? "Applying..." : "Apply batch"}
</button>
</div>
<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">
{record.title}
</span>
))}
{selectedOperations.length > 6 ? <span className="rounded-full border border-line/70 bg-surface px-2 py-1">+{selectedOperations.length - 6} more</span> : null}
</div>
</div>
) : null}
</div> </div>
<section className="grid gap-3 xl:grid-cols-10"> <section className="grid gap-3 xl:grid-cols-10">
@@ -469,9 +862,9 @@ export function WorkbenchPage() {
<button key={exception.id} type="button" onClick={() => setSelectedFocusId(exceptionTargetId(exception.id))} className={`block w-full rounded-[18px] border px-2 py-2 text-left transition hover:bg-page/80 ${selectedFocusId === exceptionTargetId(exception.id) ? "border-brand bg-brand/10" : "border-line/70 bg-page/60"}`}> <button key={exception.id} type="button" onClick={() => setSelectedFocusId(exceptionTargetId(exception.id))} className={`block w-full rounded-[18px] border px-2 py-2 text-left transition hover:bg-page/80 ${selectedFocusId === exceptionTargetId(exception.id) ? "border-brand bg-brand/10" : "border-line/70 bg-page/60"}`}>
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div> <div>
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">{exception.kind === "PROJECT" ? "Project" : "Work Order"}</div> <div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">{exception.kind === "PROJECT" ? "Project" : "Work Order"}</div>
<div className="mt-1 font-semibold text-text">{exception.title}</div> <div className="mt-1 font-semibold text-text">{exception.title}</div>
<div className="mt-2 text-xs text-muted">{exception.ownerLabel ?? "No owner context"}</div> <div className="mt-2 text-xs text-muted">{exception.ownerLabel ?? "No owner context"}</div>
</div> </div>
<span className="rounded-full border border-line/70 px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-muted">{exception.status.replaceAll("_", " ")}</span> <span className="rounded-full border border-line/70 px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-muted">{exception.status.replaceAll("_", " ")}</span>
</div> </div>
@@ -511,6 +904,7 @@ export function WorkbenchPage() {
stationLoads={stationLoads} stationLoads={stationLoads}
groupMode={workbenchGroup} groupMode={workbenchGroup}
onSelect={setSelectedFocusId} onSelect={setSelectedFocusId}
selectedOperationIds={selectedOperationIds}
selectedId={selectedFocus?.id ?? null} selectedId={selectedFocus?.id ?? null}
draggingOperation={draggingOperation} draggingOperation={draggingOperation}
dropStationId={dropStationId} dropStationId={dropStationId}
@@ -521,7 +915,7 @@ export function WorkbenchPage() {
/> />
) : null} ) : null}
{workbenchMode === "heatmap" ? <HeatmapBoard heatmap={heatmap} selectedDate={selectedHeatmapDate} onSelectDate={setSelectedHeatmapDate} /> : null} {workbenchMode === "heatmap" ? <HeatmapBoard heatmap={heatmap} selectedDate={selectedHeatmapDate} onSelectDate={setSelectedHeatmapDate} /> : null}
{workbenchMode === "agenda" ? <AgendaBoard records={agendaItems} onSelect={setSelectedFocusId} selectedId={selectedFocus?.id ?? null} /> : null} {workbenchMode === "agenda" ? <AgendaBoard records={agendaItems} onSelect={setSelectedFocusId} selectedOperationIds={selectedOperationIds} selectedId={selectedFocus?.id ?? null} /> : null}
</div> </div>
<aside className="space-y-3"> <aside className="space-y-3">
@@ -533,6 +927,9 @@ export function WorkbenchPage() {
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">{selectedFocus.kind}</div> <div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">{selectedFocus.kind}</div>
<div className="mt-2 text-base font-bold text-text">{selectedFocus.title}</div> <div className="mt-2 text-base font-bold text-text">{selectedFocus.title}</div>
<div className="mt-2 text-xs text-muted">{selectedFocus.ownerLabel ?? "No context label"}</div> <div className="mt-2 text-xs text-muted">{selectedFocus.ownerLabel ?? "No context label"}</div>
<div className="mt-3 flex flex-wrap gap-2">
<RecordSignals record={selectedFocus} queued={queuedWorkOrderIds.includes(selectedFocus.workOrderId ?? selectedFocus.id)} selected={selectedOperationIds.includes(selectedFocus.id)} />
</div>
</div> </div>
<div className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2 text-sm"> <div className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2 text-sm">
<div className="flex items-center justify-between gap-3"><span className="text-muted">Status</span><span className="font-semibold text-text">{selectedFocus.status.replaceAll("_", " ")}</span></div> <div className="flex items-center justify-between gap-3"><span className="text-muted">Status</span><span className="font-semibold text-text">{selectedFocus.status.replaceAll("_", " ")}</span></div>
@@ -633,6 +1030,21 @@ export function WorkbenchPage() {
</div> </div>
) : null} ) : null}
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{canQueueRelease(selectedFocus) ? (
<button
type="button"
onClick={() => addRecordToQueue(selectedFocus)}
disabled={queuedWorkOrderIds.includes(selectedFocus.workOrderId ?? selectedFocus.id)}
className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60"
>
{queuedWorkOrderIds.includes(selectedFocus.workOrderId ?? selectedFocus.id) ? "Queued" : "Queue release"}
</button>
) : null}
{selectedFocus.kind === "OPERATION" ? (
<button type="button" onClick={() => toggleOperationSelection(selectedFocus)} className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
{selectedOperationIds.includes(selectedFocus.id) ? "Deselect op" : "Select op"}
</button>
) : null}
{selectedFocus.actions.map((action, index) => ( {selectedFocus.actions.map((action, index) => (
<button <button
key={`${action.kind}-${index}`} key={`${action.kind}-${index}`}
@@ -654,8 +1066,8 @@ export function WorkbenchPage() {
<section className="surface-panel"> <section className="surface-panel">
<p className="section-kicker">{workbenchMode === "heatmap" ? "SELECTED DAY" : "UPCOMING AGENDA"}</p> <p className="section-kicker">{workbenchMode === "heatmap" ? "SELECTED DAY" : "UPCOMING AGENDA"}</p>
{workbenchMode === "heatmap" {workbenchMode === "heatmap"
? (selectedHeatmapCell ? <SelectedDayPanel cell={selectedHeatmapCell} onSelect={setSelectedFocusId} 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} 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} selectedId={selectedFocus?.id ?? null} compact />} : <AgendaBoard records={agendaItems.slice(0, 8)} onSelect={setSelectedFocusId} selectedOperationIds={selectedOperationIds} selectedId={selectedFocus?.id ?? null} compact />}
</section> </section>
</aside> </aside>
</div> </div>
@@ -672,11 +1084,27 @@ function MetricCard({ label, value }: { label: string; value: string | number })
); );
} }
function RecordSignals({ record, queued = false, selected = false }: { record: FocusRecord; queued?: boolean; selected?: boolean }) {
return (
<>
<span className={`rounded-full border px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] ${readinessTone(record)}`}>
{readinessLabel(record)}
</span>
{selected ? <span className="rounded-full border border-cyan-300/60 bg-cyan-400/10 px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-cyan-300">SELECTED</span> : null}
{queued ? <span className="rounded-full border border-brand/40 bg-brand/10 px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-text">QUEUED</span> : null}
{record.releaseReady ? <span className="rounded-full border border-emerald-300/60 bg-emerald-500/10 px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-emerald-300">RELEASE</span> : null}
{record.totalShortageQuantity > 0 ? <span className="rounded-full border border-orange-300/60 bg-orange-400/10 px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-orange-300">SHORT {record.totalShortageQuantity}</span> : null}
{record.blockedReason ? <span className="rounded-full border border-amber-300/60 bg-amber-400/10 px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-amber-300">HOLD</span> : null}
</>
);
}
function OverviewBoard({ function OverviewBoard({
focusRecords, focusRecords,
stationLoads, stationLoads,
groupMode, groupMode,
onSelect, onSelect,
selectedOperationIds,
selectedId, selectedId,
draggingOperation, draggingOperation,
dropStationId, dropStationId,
@@ -689,6 +1117,7 @@ function OverviewBoard({
stationLoads: PlanningStationLoadDto[]; stationLoads: PlanningStationLoadDto[];
groupMode: WorkbenchGroup; groupMode: WorkbenchGroup;
onSelect: (id: string) => void; onSelect: (id: string) => void;
selectedOperationIds: string[];
selectedId: string | null; selectedId: string | null;
draggingOperation: DraggingOperation | null; draggingOperation: DraggingOperation | null;
dropStationId: string | null; dropStationId: string | null;
@@ -734,10 +1163,13 @@ function OverviewBoard({
<div> <div>
<div className="font-semibold text-text">{record.title}</div> <div className="font-semibold text-text">{record.title}</div>
<div className="mt-1 text-xs text-muted">{record.ownerLabel ?? "No owner context"}</div> <div className="mt-1 text-xs text-muted">{record.ownerLabel ?? "No owner context"}</div>
<div className="mt-2 flex flex-wrap gap-2">
<RecordSignals record={record} selected={selectedOperationIds.includes(record.id)} />
</div>
</div> </div>
<div className="text-right text-xs text-muted"> <div className="text-right text-xs text-muted">
<div>{record.readinessState}</div>
<div>{record.progress}% progress</div> <div>{record.progress}% progress</div>
<div>{formatDate(record.end)}</div>
</div> </div>
</div> </div>
</button> </button>
@@ -752,6 +1184,9 @@ function OverviewBoard({
<div> <div>
<div className="font-semibold text-text">{record.title}</div> <div className="font-semibold text-text">{record.title}</div>
<div className="mt-1 text-xs text-muted">{record.ownerLabel ?? "No parent work order"}</div> <div className="mt-1 text-xs text-muted">{record.ownerLabel ?? "No parent work order"}</div>
<div className="mt-2 flex flex-wrap gap-2">
<RecordSignals record={record} selected={selectedOperationIds.includes(record.id)} />
</div>
</div> </div>
<div className="text-right text-xs text-muted"> <div className="text-right text-xs text-muted">
<div>{record.stationCode ?? "No station"}</div> <div>{record.stationCode ?? "No station"}</div>
@@ -865,9 +1300,11 @@ function OverviewBoard({
<div> <div>
<div className="font-semibold text-text">{record.ownerLabel ?? record.title}</div> <div className="font-semibold text-text">{record.ownerLabel ?? record.title}</div>
<div className="mt-1 text-xs text-muted">{formatDate(record.start, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })}</div> <div className="mt-1 text-xs text-muted">{formatDate(record.start, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })}</div>
<div className="mt-2 flex flex-wrap gap-2">
<RecordSignals record={record} selected={selectedOperationIds.includes(record.id)} />
</div>
</div> </div>
<div className="text-right text-xs text-muted"> <div className="text-right text-xs text-muted">
<div>{record.readinessState}</div>
<div>{record.utilizationPercent ?? station.utilizationPercent}% util</div> <div>{record.utilizationPercent ?? station.utilizationPercent}% util</div>
</div> </div>
</div> </div>
@@ -891,10 +1328,12 @@ function OverviewBoard({
<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"}`}> <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"}`}>
<div> <div>
<div className="font-semibold text-text">{record.title}</div> <div className="font-semibold text-text">{record.title}</div>
<div className="mt-1 text-xs text-muted">{record.readinessState} - shortage {record.totalShortageQuantity}</div> <div className="mt-1 text-xs text-muted">{record.ownerLabel ?? "No context"}</div>
<div className="mt-2 flex flex-wrap gap-2">
<RecordSignals record={record} />
</div>
</div> </div>
<div className="text-right text-xs text-muted"> <div className="text-right text-xs text-muted">
<div>{record.ownerLabel ?? "No context"}</div>
<div>{record.overdue ? "Overdue" : "Open"}</div> <div>{record.overdue ? "Overdue" : "Open"}</div>
</div> </div>
</button> </button>
@@ -908,8 +1347,11 @@ function OverviewBoard({
{workOrders.map((record) => ( {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"}`}> <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"}`}>
<div className="font-semibold text-text">{record.title}</div> <div className="font-semibold text-text">{record.title}</div>
<div className="mt-2 flex flex-wrap gap-2">
<RecordSignals record={record} />
</div>
<div className="mt-2 flex items-center justify-between gap-3 text-xs text-muted"> <div className="mt-2 flex items-center justify-between gap-3 text-xs text-muted">
<span>{record.readinessState}</span> <span>{record.ownerLabel ?? "No context"}</span>
<span>{record.progress}%</span> <span>{record.progress}%</span>
</div> </div>
</button> </button>
@@ -963,7 +1405,7 @@ function HeatmapBoard({ heatmap, selectedDate, onSelectDate }: { heatmap: Heatma
); );
} }
function AgendaBoard({ records, onSelect, selectedId, compact = false }: { records: FocusRecord[]; onSelect: (id: string) => void; selectedId: string | null; compact?: boolean }) { function AgendaBoard({ records, onSelect, selectedOperationIds, selectedId, compact = false }: { records: FocusRecord[]; onSelect: (id: string) => void; selectedOperationIds: string[]; selectedId: string | null; compact?: boolean }) {
return ( return (
<div className={compact ? "mt-3 space-y-2" : "space-y-3"}> <div className={compact ? "mt-3 space-y-2" : "space-y-3"}>
{!compact ? ( {!compact ? (
@@ -977,6 +1419,9 @@ function AgendaBoard({ records, onSelect, selectedId, compact = false }: { recor
<div> <div>
<div className="font-semibold text-text">{record.title}</div> <div className="font-semibold text-text">{record.title}</div>
<div className="mt-1 text-xs text-muted">{record.kind} - {record.ownerLabel ?? "No context"}</div> <div className="mt-1 text-xs text-muted">{record.kind} - {record.ownerLabel ?? "No context"}</div>
<div className="mt-2 flex flex-wrap gap-2">
<RecordSignals record={record} selected={selectedOperationIds.includes(record.id)} />
</div>
</div> </div>
<div className="text-right text-xs text-muted"> <div className="text-right text-xs text-muted">
<div>{formatDate(record.end)}</div> <div>{formatDate(record.end)}</div>
@@ -989,7 +1434,7 @@ function AgendaBoard({ records, onSelect, selectedId, compact = false }: { recor
); );
} }
function SelectedDayPanel({ cell, onSelect, selectedId }: { cell: HeatmapCell; onSelect: (id: string) => void; selectedId: string | null }) { function SelectedDayPanel({ cell, onSelect, selectedOperationIds, selectedId }: { cell: HeatmapCell; onSelect: (id: string) => void; selectedOperationIds: string[]; selectedId: string | null }) {
return ( return (
<div className="mt-3 space-y-2"> <div className="mt-3 space-y-2">
<div className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2"> <div className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2">
@@ -1005,6 +1450,9 @@ function SelectedDayPanel({ cell, onSelect, selectedId }: { cell: HeatmapCell; o
<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"}`}> <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"}`}>
<div className="font-semibold text-text">{task.title}</div> <div className="font-semibold text-text">{task.title}</div>
<div className="mt-1 text-xs text-muted">{task.status.replaceAll("_", " ")} - {task.ownerLabel ?? "No context"}</div> <div className="mt-1 text-xs text-muted">{task.status.replaceAll("_", " ")} - {task.ownerLabel ?? "No context"}</div>
<div className="mt-2 flex flex-wrap gap-2">
<RecordSignals record={task} selected={selectedOperationIds.includes(task.id)} />
</div>
</button> </button>
))} ))}
</div> </div>