drag scheduling

This commit is contained in:
2026-03-18 00:18:30 -05:00
parent abc795b4a7
commit 6eaf084fcd
9 changed files with 309 additions and 26 deletions

View File

@@ -612,6 +612,9 @@ export const api = {
createManufacturingStation(token: string, payload: ManufacturingStationInput) {
return request<ManufacturingStationDto>("/api/v1/manufacturing/stations", { method: "POST", body: JSON.stringify(payload) }, token);
},
updateManufacturingStation(token: string, stationId: string, payload: ManufacturingStationInput) {
return request<ManufacturingStationDto>(`/api/v1/manufacturing/stations/${stationId}`, { method: "PUT", body: JSON.stringify(payload) }, token);
},
getWorkOrders(token: string, filters?: { q?: string; status?: WorkOrderStatus; projectId?: string; itemId?: string }) {
return request<WorkOrderSummaryDto[]>(
`/api/v1/manufacturing/work-orders${buildQueryString({

View File

@@ -20,6 +20,7 @@ export function ManufacturingPage() {
const { token, user } = useAuth();
const [stations, setStations] = useState<ManufacturingStationDto[]>([]);
const [form, setForm] = useState<ManufacturingStationInput>(emptyStationInput);
const [editingStationId, setEditingStationId] = useState<string | null>(null);
const [status, setStatus] = useState("Define manufacturing stations once so routings and work orders can schedule automatically.");
const [isSaving, setIsSaving] = useState(false);
const canManage = user?.permissions.includes(permissions.manufacturingWrite) ?? false;
@@ -32,6 +33,27 @@ export function ManufacturingPage() {
api.getManufacturingStations(token).then(setStations).catch(() => setStations([]));
}, [token]);
function resetForm(nextStatus = "Define manufacturing stations once so routings and work orders can schedule automatically.") {
setForm(emptyStationInput);
setEditingStationId(null);
setStatus(nextStatus);
}
function startEditing(station: ManufacturingStationDto) {
setEditingStationId(station.id);
setForm({
code: station.code,
name: station.name,
description: station.description,
queueDays: station.queueDays,
dailyCapacityMinutes: station.dailyCapacityMinutes,
parallelCapacity: station.parallelCapacity,
workingDays: station.workingDays,
isActive: station.isActive,
});
setStatus(`Editing station ${station.code}.`);
}
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!token) {
@@ -39,12 +61,15 @@ export function ManufacturingPage() {
}
setIsSaving(true);
setStatus("Saving station...");
setStatus(editingStationId ? "Updating station..." : "Saving station...");
try {
const station = await api.createManufacturingStation(token, form);
setStations((current) => [...current, station].sort((left, right) => left.code.localeCompare(right.code)));
setForm(emptyStationInput);
setStatus("Station saved.");
const station = editingStationId
? await api.updateManufacturingStation(token, editingStationId, form)
: await api.createManufacturingStation(token, form);
setStations((current) =>
(editingStationId ? current.map((entry) => (entry.id === station.id ? station : entry)) : [...current, station]).sort((left, right) => left.code.localeCompare(right.code))
);
resetForm(editingStationId ? "Station updated." : "Station saved.");
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to save station.";
setStatus(message);
@@ -72,6 +97,11 @@ export function ManufacturingPage() {
<div>
<div className="font-semibold text-text">{station.code} - {station.name}</div>
<div className="mt-1 text-xs text-muted">{station.description || "No description"}</div>
{canManage ? (
<button type="button" onClick={() => startEditing(station)} className="mt-3 rounded-2xl border border-line/70 px-2 py-1 text-xs font-semibold text-text">
Edit station
</button>
) : null}
</div>
<div className="text-right text-xs text-muted">
<div>{station.queueDays} expected wait day(s)</div>
@@ -87,7 +117,7 @@ export function ManufacturingPage() {
</article>
{canManage ? (
<form onSubmit={handleSubmit} className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">New Station</p>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">{editingStationId ? "Edit Station" : "New Station"}</p>
<div className="mt-4 grid gap-3">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Code</span>
@@ -151,9 +181,16 @@ export function ManufacturingPage() {
</label>
<div className="flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2">
<span className="text-sm text-muted">{status}</span>
<button type="submit" disabled={isSaving} className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60">
{isSaving ? "Saving..." : "Create station"}
</button>
<div className="flex flex-wrap gap-2">
<button type="submit" disabled={isSaving} className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60">
{isSaving ? (editingStationId ? "Updating..." : "Saving...") : editingStationId ? "Update station" : "Create station"}
</button>
{editingStationId ? (
<button type="button" onClick={() => resetForm("Edit cancelled.")} disabled={isSaving} className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60">
Cancel
</button>
) : null}
</div>
</div>
</div>
</form>

View File

@@ -41,6 +41,7 @@ type FocusRecord = {
overdue: boolean;
blockedReason: string | null;
utilizationPercent: number | null;
loadMinutes: number;
actions: PlanningTaskActionDto[];
};
@@ -54,6 +55,13 @@ type HeatmapCell = {
type WorkbenchGroup = "projects" | "stations" | "exceptions";
type WorkbenchFilter = "all" | "release-ready" | "blocked" | "shortage" | "overdue";
type DraggingOperation = {
id: string;
title: string;
stationId: string | null;
start: string;
loadMinutes: number;
};
const DAY_MS = 24 * 60 * 60 * 1000;
@@ -137,6 +145,7 @@ function buildFocusRecords(tasks: GanttTaskDto[]) {
overdue: task.overdue ?? false,
blockedReason: task.blockedReason ?? null,
utilizationPercent: task.utilizationPercent ?? null,
loadMinutes: task.loadMinutes ?? 0,
actions: task.actions ?? [],
}));
}
@@ -171,6 +180,8 @@ export function WorkbenchPage() {
const [rescheduleStationId, setRescheduleStationId] = useState("");
const [isRescheduling, setIsRescheduling] = useState(false);
const [stations, setStations] = useState<ManufacturingStationDto[]>([]);
const [draggingOperation, setDraggingOperation] = useState<DraggingOperation | null>(null);
const [dropStationId, setDropStationId] = useState<string | null>(null);
useEffect(() => {
if (!token) {
@@ -298,32 +309,46 @@ export function WorkbenchPage() {
}
}
async function handleRescheduleOperation(nextStartIso?: string, nextStationId?: string | null) {
if (!token || !selectedFocus || selectedFocus.kind !== "OPERATION" || !selectedFocus.workOrderId || !selectedFocus.entityId) {
async function rebalanceOperation(record: FocusRecord, nextStartIso?: string, nextStationId?: string | null) {
if (!token || record.kind !== "OPERATION" || !record.workOrderId || !record.entityId) {
return;
}
const plannedStart = nextStartIso ?? (rescheduleStart ? new Date(rescheduleStart).toISOString() : "");
const plannedStart = nextStartIso ?? new Date(record.start).toISOString();
if (!plannedStart) {
return;
}
setIsRescheduling(true);
try {
await api.updateWorkOrderOperationSchedule(token, selectedFocus.workOrderId, selectedFocus.entityId, {
await api.updateWorkOrderOperationSchedule(token, record.workOrderId, record.entityId, {
plannedStart,
stationId: (nextStationId ?? rescheduleStationId) || null,
stationId: nextStationId ?? record.stationId ?? null,
});
await refreshWorkbench("Workbench refreshed after operation rebalance.");
setSelectedFocusId(record.id);
setRescheduleStart(plannedStart.slice(0, 16));
if (nextStationId) {
setRescheduleStationId(nextStationId);
}
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to rebalance operation from Workbench.";
setStatus(message);
} finally {
setIsRescheduling(false);
setDraggingOperation(null);
setDropStationId(null);
}
}
async function handleRescheduleOperation(nextStartIso?: string, nextStationId?: string | null) {
if (!selectedFocus || selectedFocus.kind !== "OPERATION") {
return;
}
const plannedStart = nextStartIso ?? (rescheduleStart ? new Date(rescheduleStart).toISOString() : "");
await rebalanceOperation(selectedFocus, plannedStart, (nextStationId ?? rescheduleStationId) || null);
}
function shiftRescheduleDraft(hours: number) {
if (!rescheduleStart) {
return;
@@ -342,6 +367,23 @@ export function WorkbenchPage() {
setRescheduleStart(target.toISOString().slice(0, 16));
}
async function handleStationDrop(targetStationId: string) {
if (!draggingOperation) {
return;
}
const record = focusById.get(draggingOperation.id);
if (!record || record.kind !== "OPERATION") {
setDraggingOperation(null);
setDropStationId(null);
return;
}
const plannedStart = selectedHeatmapDate
? new Date(`${selectedHeatmapDate}T${new Date(record.start).toTimeString().slice(0, 5)}:00`).toISOString()
: new Date(record.start).toISOString();
await rebalanceOperation(record, plannedStart, targetStationId);
}
return (
<section className="space-y-4">
<div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
@@ -440,7 +482,20 @@ export function WorkbenchPage() {
</aside>
<div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel">
{workbenchMode === "overview" ? <OverviewBoard focusRecords={filteredFocusRecords} stationLoads={stationLoads} groupMode={workbenchGroup} onSelect={setSelectedFocusId} /> : null}
{workbenchMode === "overview" ? (
<OverviewBoard
focusRecords={filteredFocusRecords}
stationLoads={stationLoads}
groupMode={workbenchGroup}
onSelect={setSelectedFocusId}
draggingOperation={draggingOperation}
dropStationId={dropStationId}
selectedHeatmapDate={selectedHeatmapDate}
onDragOperation={setDraggingOperation}
onDropStation={handleStationDrop}
onDropStationChange={setDropStationId}
/>
) : null}
{workbenchMode === "heatmap" ? <HeatmapBoard heatmap={heatmap} selectedDate={selectedHeatmapDate} onSelectDate={setSelectedHeatmapDate} /> : null}
{workbenchMode === "agenda" ? <AgendaBoard records={agendaItems} onSelect={setSelectedFocusId} /> : null}
</div>
@@ -597,18 +652,42 @@ function OverviewBoard({
stationLoads,
groupMode,
onSelect,
draggingOperation,
dropStationId,
selectedHeatmapDate,
onDragOperation,
onDropStation,
onDropStationChange,
}: {
focusRecords: FocusRecord[];
stationLoads: PlanningStationLoadDto[];
groupMode: WorkbenchGroup;
onSelect: (id: string) => void;
draggingOperation: DraggingOperation | null;
dropStationId: string | null;
selectedHeatmapDate: string | null;
onDragOperation: (operation: DraggingOperation | null) => void;
onDropStation: (stationId: string) => void | Promise<void>;
onDropStationChange: (stationId: string | null) => void;
}) {
const projects = focusRecords.filter((record) => record.kind === "PROJECT").slice(0, 6);
const operations = focusRecords.filter((record) => record.kind === "OPERATION").slice(0, 10);
const operations = focusRecords.filter((record) => record.kind === "OPERATION");
const workOrders = focusRecords.filter((record) => record.kind === "WORK_ORDER").slice(0, 10);
const exceptionRows = focusRecords
.filter((record) => record.kind !== "PROJECT" && (record.overdue || record.totalShortageQuantity > 0 || record.readinessState === "BLOCKED" || record.readinessState === "PENDING_SUPPLY"))
.slice(0, 10);
const stationOperations = new Map<string, FocusRecord[]>();
for (const operation of operations) {
if (!operation.stationId) {
continue;
}
const bucket = stationOperations.get(operation.stationId) ?? [];
bucket.push(operation);
stationOperations.set(operation.stationId, bucket);
}
for (const bucket of stationOperations.values()) {
bucket.sort((left, right) => new Date(left.start).getTime() - new Date(right.start).getTime());
}
return (
<div className="space-y-4">
@@ -642,7 +721,7 @@ function OverviewBoard({
<section className="rounded-[18px] border border-line/70 bg-page/60 p-3">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Operation Load</p>
<div className="mt-3 space-y-2">
{operations.map((record) => (
{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-3 py-2 text-left transition hover:bg-surface">
<div>
<div className="font-semibold text-text">{record.title}</div>
@@ -660,10 +739,45 @@ function OverviewBoard({
) : null}
{groupMode === "stations" ? (
<section className="rounded-[18px] border border-line/70 bg-page/60 p-3">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Work Center Load</p>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Work Center Load</p>
<p className="mt-2 text-sm text-muted">Drag operations between stations to rebalance capacity. If a heatmap day is selected, drops target that date on the new station.</p>
</div>
{draggingOperation ? (
<div className="rounded-2xl border border-brand/40 bg-brand/10 px-3 py-2 text-xs font-semibold text-text">
Dragging {draggingOperation.title}
</div>
) : null}
</div>
<div className="mt-3 grid gap-3 xl:grid-cols-2">
{stationLoads.slice(0, 10).map((station) => (
<div key={station.stationId} className="rounded-[16px] border border-line/70 bg-surface/80 p-3">
<div
key={station.stationId}
onDragOver={(event) => {
if (!draggingOperation) {
return;
}
event.preventDefault();
onDropStationChange(station.stationId);
}}
onDragLeave={() => {
if (dropStationId === station.stationId) {
onDropStationChange(null);
}
}}
onDrop={(event) => {
event.preventDefault();
void onDropStation(station.stationId);
}}
className={`rounded-[16px] border bg-surface/80 p-3 transition ${
dropStationId === station.stationId
? "border-brand bg-brand/10"
: station.overloaded
? "border-amber-300/60"
: "border-line/70"
}`}
>
<div className="flex items-center justify-between gap-3">
<div>
<div className="font-semibold text-text">{station.stationCode} - {station.stationName}</div>
@@ -679,6 +793,62 @@ function OverviewBoard({
<div>Blocked {station.blockedCount}</div>
<div>Late {station.lateCount}</div>
</div>
{draggingOperation ? (
<div className="mt-3 rounded-[14px] border border-line/70 bg-page/60 p-2 text-xs text-muted">
<div className="flex items-center justify-between gap-3">
<span>Projected util after drop</span>
<span className="font-semibold text-text">
{Math.round(((station.totalPlannedMinutes + draggingOperation.loadMinutes) / Math.max(station.capacityMinutes, 1)) * 100)}%
</span>
</div>
<div className="mt-1">
{station.stationId === draggingOperation.stationId
? "Same station move."
: Math.round(((station.totalPlannedMinutes + draggingOperation.loadMinutes) / Math.max(station.capacityMinutes, 1)) * 100) > 100
? "Drop will overload this station."
: "Drop stays within current summarized load."}
</div>
{selectedHeatmapDate ? <div className="mt-1">Target day: {formatDate(selectedHeatmapDate)}</div> : null}
</div>
) : null}
<div className="mt-3 space-y-2">
{(stationOperations.get(station.stationId) ?? []).slice(0, 5).map((record) => (
<div
key={record.id}
draggable
onDragStart={() =>
onDragOperation({
id: record.id,
title: record.title,
stationId: record.stationId,
start: record.start,
loadMinutes: Math.max(record.loadMinutes, 1),
})
}
onDragEnd={() => {
onDragOperation(null);
onDropStationChange(null);
}}
className="cursor-grab rounded-[14px] border border-line/70 bg-page/60 p-2 active:cursor-grabbing"
>
<button type="button" onClick={() => onSelect(record.id)} className="block w-full text-left">
<div className="flex items-center justify-between gap-3">
<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>
<div className="text-right text-xs text-muted">
<div>{record.readinessState}</div>
<div>{record.utilizationPercent ?? station.utilizationPercent}% util</div>
</div>
</div>
</button>
</div>
))}
{(stationOperations.get(station.stationId)?.length ?? 0) > 5 ? (
<div className="text-xs text-muted">+{(stationOperations.get(station.stationId)?.length ?? 0) - 5} more operations</div>
) : null}
</div>
</div>
))}
</div>