more workbench

This commit is contained in:
2026-03-18 23:32:12 -05:00
parent d22e715f00
commit 7b65fe06cf
2 changed files with 163 additions and 8 deletions

View File

@@ -37,6 +37,7 @@ 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 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 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
- 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

View File

@@ -116,6 +116,38 @@ function densityTone(cell: HeatmapCell) {
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[]) {
return tasks.map((task) => ({
id: task.id,
@@ -270,6 +302,90 @@ export function WorkbenchPage() {
.slice(0, 18),
[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]);
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 }> = [
{ value: "overview", label: "Overview", detail: "Dense planner board" },
@@ -425,6 +541,9 @@ export function WorkbenchPage() {
</div>
<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">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>
{selectedFocus ? (
<span className="rounded-full border border-brand/40 bg-brand/10 px-2 py-1 text-text">
Selected {selectedFocus.kind.toLowerCase()}: {selectedFocus.title}
@@ -469,9 +588,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"}`}>
<div className="flex items-start justify-between gap-3">
<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-2 text-xs text-muted">{exception.ownerLabel ?? "No owner context"}</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-2 text-xs text-muted">{exception.ownerLabel ?? "No owner context"}</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>
</div>
@@ -533,6 +652,9 @@ export function WorkbenchPage() {
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">{selectedFocus.kind}</div>
<div className="mt-2 text-base font-bold text-text">{selectedFocus.title}</div>
<div className="mt-2 text-xs text-muted">{selectedFocus.ownerLabel ?? "No context label"}</div>
<div className="mt-3 flex flex-wrap gap-2">
<RecordSignals record={selectedFocus} />
</div>
</div>
<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>
@@ -672,6 +794,19 @@ function MetricCard({ label, value }: { label: string; value: string | number })
);
}
function RecordSignals({ record }: { record: FocusRecord }) {
return (
<>
<span className={`rounded-full border px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] ${readinessTone(record)}`}>
{readinessLabel(record)}
</span>
{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({
focusRecords,
stationLoads,
@@ -734,10 +869,13 @@ function OverviewBoard({
<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-2 flex flex-wrap gap-2">
<RecordSignals record={record} />
</div>
</div>
<div className="text-right text-xs text-muted">
<div>{record.readinessState}</div>
<div>{record.progress}% progress</div>
<div>{formatDate(record.end)}</div>
</div>
</div>
</button>
@@ -752,6 +890,9 @@ function OverviewBoard({
<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-2 flex flex-wrap gap-2">
<RecordSignals record={record} />
</div>
</div>
<div className="text-right text-xs text-muted">
<div>{record.stationCode ?? "No station"}</div>
@@ -865,9 +1006,11 @@ function OverviewBoard({
<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-2 flex flex-wrap gap-2">
<RecordSignals record={record} />
</div>
</div>
<div className="text-right text-xs text-muted">
<div>{record.readinessState}</div>
<div>{record.utilizationPercent ?? station.utilizationPercent}% util</div>
</div>
</div>
@@ -891,10 +1034,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"}`}>
<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 className="text-right text-xs text-muted">
<div>{record.ownerLabel ?? "No context"}</div>
<div>{record.overdue ? "Overdue" : "Open"}</div>
</div>
</button>
@@ -908,8 +1053,11 @@ function OverviewBoard({
{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"}`}>
<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">
<span>{record.readinessState}</span>
<span>{record.ownerLabel ?? "No context"}</span>
<span>{record.progress}%</span>
</div>
</button>
@@ -977,6 +1125,9 @@ function AgendaBoard({ records, onSelect, selectedId, compact = false }: { recor
<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-2 flex flex-wrap gap-2">
<RecordSignals record={record} />
</div>
</div>
<div className="text-right text-xs text-muted">
<div>{formatDate(record.end)}</div>
@@ -1005,6 +1156,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"}`}>
<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-2 flex flex-wrap gap-2">
<RecordSignals record={task} />
</div>
</button>
))}
</div>