workbench

This commit is contained in:
2026-03-18 23:28:27 -05:00
parent 5fdd366bc3
commit d22e715f00
3 changed files with 56 additions and 16 deletions

View File

@@ -36,6 +36,7 @@ This file is the running release and change log for CODEXIUM. Keep it updated wh
- Continued density standardization across company settings and deeper manufacturing detail surfaces, including tighter admin/profile/theme sections, denser work-order execution panels, and compact issue/completion history cards
- 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
- 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

@@ -28,6 +28,10 @@
.module-title {
@apply mt-1 text-xl font-bold uppercase tracking-[0.08em] text-text;
}
.planner-sticky-bar {
@apply sticky top-3 z-20 rounded-[18px] border border-line/70 bg-surface/90 p-3 shadow-panel backdrop-blur;
}
}
:root {

View File

@@ -165,6 +165,10 @@ function matchesWorkbenchFilter(record: FocusRecord, filter: WorkbenchFilter) {
}
}
function exceptionTargetId(exceptionId: string) {
return exceptionId.startsWith("project-") ? exceptionId : exceptionId.replace("work-order-unscheduled-", "work-order-");
}
export function WorkbenchPage() {
const navigate = useNavigate();
const { token } = useAuth();
@@ -386,7 +390,7 @@ export function WorkbenchPage() {
return (
<section className="page-stack">
<div className="surface-panel">
<div className="planner-sticky-bar">
<div className="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
<div>
<p className="section-kicker">PLANNING</p>
@@ -419,6 +423,19 @@ export function WorkbenchPage() {
</button>
))}
</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>
{selectedFocus ? (
<span className="rounded-full border border-brand/40 bg-brand/10 px-2 py-1 text-text">
Selected {selectedFocus.kind.toLowerCase()}: {selectedFocus.title}
</span>
) : null}
{selectedHeatmapDate ? (
<button type="button" onClick={() => setSelectedHeatmapDate(null)} className="rounded-full border border-line/70 bg-page/60 px-2 py-1 text-text">
Day {formatDate(selectedHeatmapDate)} selected - clear
</button>
) : null}
</div>
</div>
<section className="grid gap-3 xl:grid-cols-10">
@@ -449,7 +466,7 @@ export function WorkbenchPage() {
) : (
<div className="mt-3 space-y-2">
{exceptions.map((exception: PlanningExceptionDto) => (
<button key={exception.id} type="button" onClick={() => setSelectedFocusId(exception.id.startsWith("project-") ? exception.id : exception.id.replace("work-order-unscheduled-", "work-order-"))} className="block w-full rounded-[18px] border border-line/70 bg-page/60 px-2 py-2 text-left transition hover:bg-page/80">
<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>
@@ -473,19 +490,28 @@ export function WorkbenchPage() {
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Link to="/projects" className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Open projects</Link>
<Link to="/manufacturing/work-orders" className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Open work orders</Link>
<Link to="/purchasing/orders" className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Open purchasing</Link>
<Link to="/manufacturing/work-orders" className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Dispatch build</Link>
<Link to="/purchasing/orders" className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Dispatch buy</Link>
</div>
</section>
</aside>
<div className="surface-panel">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2 rounded-[16px] border border-line/70 bg-page/60 px-2 py-2 text-xs text-muted">
<div className="flex flex-wrap items-center gap-2">
<span className="rounded-full border border-line/70 px-2 py-1">{workbenchMode.toUpperCase()}</span>
<span className="rounded-full border border-line/70 px-2 py-1">{workbenchGroup.toUpperCase()}</span>
<span className="rounded-full border border-line/70 px-2 py-1">{workbenchFilter.replace("-", " ").toUpperCase()}</span>
</div>
{selectedFocus ? <div className="font-semibold text-text">{selectedFocus.title}</div> : <div>Select a record to inspect and act.</div>}
</div>
{workbenchMode === "overview" ? (
<OverviewBoard
focusRecords={filteredFocusRecords}
stationLoads={stationLoads}
groupMode={workbenchGroup}
onSelect={setSelectedFocusId}
selectedId={selectedFocus?.id ?? null}
draggingOperation={draggingOperation}
dropStationId={dropStationId}
selectedHeatmapDate={selectedHeatmapDate}
@@ -495,7 +521,7 @@ export function WorkbenchPage() {
/>
) : null}
{workbenchMode === "heatmap" ? <HeatmapBoard heatmap={heatmap} selectedDate={selectedHeatmapDate} onSelectDate={setSelectedHeatmapDate} /> : null}
{workbenchMode === "agenda" ? <AgendaBoard records={agendaItems} onSelect={setSelectedFocusId} /> : null}
{workbenchMode === "agenda" ? <AgendaBoard records={agendaItems} onSelect={setSelectedFocusId} selectedId={selectedFocus?.id ?? null} /> : null}
</div>
<aside className="space-y-3">
@@ -617,6 +643,7 @@ export function WorkbenchPage() {
{action.label}
</button>
))}
{selectedFocus.detailHref ? <button type="button" onClick={() => navigate(selectedFocus.detailHref!)} className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Open record</button> : null}
<button type="button" onClick={() => setWorkbenchMode("heatmap")} className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">View load</button>
</div>
</div>
@@ -627,8 +654,8 @@ 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} /> : <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} compact />}
? (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>)
: <AgendaBoard records={agendaItems.slice(0, 8)} onSelect={setSelectedFocusId} selectedId={selectedFocus?.id ?? null} compact />}
</section>
</aside>
</div>
@@ -650,6 +677,7 @@ function OverviewBoard({
stationLoads,
groupMode,
onSelect,
selectedId,
draggingOperation,
dropStationId,
selectedHeatmapDate,
@@ -661,6 +689,7 @@ function OverviewBoard({
stationLoads: PlanningStationLoadDto[];
groupMode: WorkbenchGroup;
onSelect: (id: string) => void;
selectedId: string | null;
draggingOperation: DraggingOperation | null;
dropStationId: string | null;
selectedHeatmapDate: string | null;
@@ -700,7 +729,7 @@ function OverviewBoard({
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Program Queue</p>
<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 border-line/70 bg-surface/80 px-2 py-2 text-left transition hover:bg-surface">
<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"}`}>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="font-semibold text-text">{record.title}</div>
@@ -719,7 +748,7 @@ function OverviewBoard({
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Operation Load</p>
<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 border-line/70 bg-surface/80 px-2 py-2 text-left transition hover:bg-surface">
<button key={record.id} type="button" onClick={() => onSelect(record.id)} className={`flex w-full items-center justify-between gap-3 rounded-[16px] border 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.ownerLabel ?? "No parent work order"}</div>
@@ -829,7 +858,7 @@ function OverviewBoard({
onDragOperation(null);
onDropStationChange(null);
}}
className="cursor-grab rounded-[14px] border border-line/70 bg-page/60 px-2 py-2 active:cursor-grabbing"
className={`cursor-grab rounded-[14px] border px-2 py-2 active:cursor-grabbing ${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-center justify-between gap-3">
@@ -859,7 +888,7 @@ function OverviewBoard({
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Dispatch Exceptions</p>
<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 border-line/70 bg-surface/80 px-2 py-2 text-left transition hover:bg-surface">
<button key={record.id} type="button" onClick={() => onSelect(record.id)} className={`flex w-full items-center justify-between gap-3 rounded-[16px] border 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>
@@ -877,7 +906,7 @@ function OverviewBoard({
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Active Work Orders</p>
<div className="mt-3 grid gap-3 xl:grid-cols-2">
{workOrders.map((record) => (
<button key={record.id} type="button" onClick={() => onSelect(record.id)} className="rounded-[16px] border border-line/70 bg-surface/80 px-2 py-2 text-left transition hover:bg-surface">
<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 items-center justify-between gap-3 text-xs text-muted">
<span>{record.readinessState}</span>
@@ -902,6 +931,11 @@ function HeatmapBoard({ heatmap, selectedDate, onSelectDate }: { heatmap: Heatma
<div>
<p className="section-kicker">LOAD HEATMAP</p>
</div>
<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>
{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">
<div className="flex gap-2">
<div className="flex flex-col gap-2 pt-7">
@@ -929,7 +963,7 @@ function HeatmapBoard({ heatmap, selectedDate, onSelectDate }: { heatmap: Heatma
);
}
function AgendaBoard({ records, onSelect, compact = false }: { records: FocusRecord[]; onSelect: (id: string) => void; compact?: boolean }) {
function AgendaBoard({ records, onSelect, selectedId, compact = false }: { records: FocusRecord[]; onSelect: (id: string) => void; selectedId: string | null; compact?: boolean }) {
return (
<div className={compact ? "mt-3 space-y-2" : "space-y-3"}>
{!compact ? (
@@ -939,7 +973,7 @@ function AgendaBoard({ records, onSelect, compact = false }: { records: FocusRec
) : null}
<div className="space-y-2">
{records.map((record) => (
<button key={record.id} type="button" onClick={() => onSelect(record.id)} className="flex w-full items-center justify-between gap-3 rounded-[16px] border border-line/70 bg-page/60 px-2 py-2 text-left transition hover:bg-page/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-page/80 ${selectedId === record.id ? "border-brand bg-brand/10" : "border-line/70 bg-page/60"}`}>
<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>
@@ -955,7 +989,7 @@ function AgendaBoard({ records, onSelect, compact = false }: { records: FocusRec
);
}
function SelectedDayPanel({ cell, onSelect }: { cell: HeatmapCell; onSelect: (id: string) => void }) {
function SelectedDayPanel({ cell, onSelect, selectedId }: { cell: HeatmapCell; onSelect: (id: string) => void; 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">
@@ -964,10 +998,11 @@ function SelectedDayPanel({ cell, onSelect }: { cell: HeatmapCell; onSelect: (id
<span>{cell.count} scheduled</span>
<span>{cell.lateCount} late</span>
</div>
<div className="mt-1 text-xs text-muted">{cell.blockedCount} blocked</div>
</div>
<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 border-line/70 bg-page/60 px-2 py-2 text-left transition hover:bg-page/80">
<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>
</button>