drag scheduling
This commit is contained in:
@@ -10,8 +10,10 @@ This file is the running release and change log for CODEXIUM. Keep it updated wh
|
||||
- Planning workbench replacing the old one-note planning screen with mode switching, dense exception rail, heatmap load view, agenda view, and focus drawer
|
||||
- Planning workbench dispatch upgrade with station load summaries, readiness scoring, release-ready and blocker filters, richer planner rows, and inline release/build/buy actions
|
||||
- Manufacturing finite-capacity slice with station daily capacity, parallel capacity, working-day calendars, calendar-aware operation scheduling, and operation-level rescheduling from the work-order detail page
|
||||
- Manufacturing station edit support for working days, active state, queue, and capacity settings directly from the manufacturing screen
|
||||
- Workbench rebalance controls for operation rows, including planner-side datetime rescheduling, quick shift moves, and heatmap-day targeting without leaving the dispatch surface
|
||||
- Workbench station-to-station rebalance so planners can move an operation onto another active work center and rebuild the downstream chain from the same dispatch surface
|
||||
- Workbench drag scheduling in station grouping mode, with draggable operation cards, station drop targets, heatmap-day-aware drop timing, and projected post-drop load cues before the move is committed
|
||||
- Project milestones with status, due dates, notes, and edit-time sequencing inside the project workflow
|
||||
- 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
|
||||
|
||||
@@ -27,8 +27,8 @@ Current foundation scope includes:
|
||||
- purchase-order supporting documents for vendor invoices, acknowledgements, certifications, and backup files
|
||||
- shipping shipments linked to sales orders with packing slips, shipping labels, bills of lading, and logistics attachments
|
||||
- projects with customer/commercial/shipment linkage, owners, due dates, milestones, rollups, notes, and attachments
|
||||
- manufacturing work orders with project linkage, station-based operation templates, station calendars/capacity settings, calendar-aware operation scheduling, material issue posting, completion posting, operation rescheduling, and work-order attachments
|
||||
- planning workbench with live project/manufacturing schedule data, exception rail, heatmap load view, agenda view, focus drawer, station load grouping, readiness filters, overload visibility, inline dispatch actions, and planner-side operation rebalance controls including station-to-station moves
|
||||
- manufacturing work orders with project linkage, station-based operation templates, editable station calendars/capacity settings, calendar-aware operation scheduling, material issue posting, completion posting, operation rescheduling, and work-order attachments
|
||||
- planning workbench with live project/manufacturing schedule data, exception rail, heatmap load view, agenda view, focus drawer, station load grouping, readiness filters, overload visibility, inline dispatch actions, planner-side operation rebalance controls including station-to-station moves, and station-lane drag scheduling
|
||||
- sales-order demand planning with multi-level BOM explosion, stock/open-supply netting, and build/buy recommendations
|
||||
- planner-assisted conversion of demand-planning recommendations into prefilled work-order and purchase-order drafts
|
||||
- pegged WO/PO supply tracking back to sales demand with preferred-vendor sourcing on inventory items
|
||||
@@ -110,7 +110,7 @@ Next expansion areas:
|
||||
|
||||
## Manufacturing Direction
|
||||
|
||||
Manufacturing is now a separate execution subsystem rather than being collapsed into Projects. The current slice ships work-order records with build-item linkage, optional project linkage, warehouse/location output posting, BOM-based material requirement visibility, station master data, item-level operation templates, station calendars and capacity settings, automatic work-order operation plans, operation-level rescheduling, material issue posting, completion posting, work-order attachments, and dashboard visibility.
|
||||
Manufacturing is now a separate execution subsystem rather than being collapsed into Projects. The current slice ships work-order records with build-item linkage, optional project linkage, warehouse/location output posting, BOM-based material requirement visibility, station master data, item-level operation templates, editable station calendars and capacity settings, automatic work-order operation plans, operation-level rescheduling, material issue posting, completion posting, work-order attachments, and dashboard visibility.
|
||||
|
||||
Current interactions:
|
||||
|
||||
@@ -126,7 +126,7 @@ Next expansion areas:
|
||||
|
||||
## Planning Direction
|
||||
|
||||
Planning is now the live scheduling and visibility layer over projects and manufacturing instead of a placeholder wrapper. The current slice ships a planning workbench backed by active projects, due-date milestones, linked work orders, standalone manufacturing queue visibility, exception rails, dense load heatmaps, station load summaries, readiness scoring, overload visibility, focus-drawer inspection, planner-side operation rebalance controls including station reassignment, inline release/build/buy follow-through, and agenda sequencing.
|
||||
Planning is now the live scheduling and visibility layer over projects and manufacturing instead of a placeholder wrapper. The current slice ships a planning workbench backed by active projects, due-date milestones, linked work orders, standalone manufacturing queue visibility, exception rails, dense load heatmaps, station load summaries, readiness scoring, overload visibility, focus-drawer inspection, planner-side operation rebalance controls including station reassignment, station-lane drag scheduling with projected load cues, inline release/build/buy follow-through, and agenda sequencing.
|
||||
|
||||
Current interactions:
|
||||
|
||||
|
||||
@@ -106,7 +106,7 @@ This file tracks work that still needs to be completed. Shipped phase history an
|
||||
- Labor and machine scheduling support beyond the shipped station calendar/capacity foundation
|
||||
- Theme-compliant workbench scheduling surfaces for light/dark mode
|
||||
- Collapsible schedule groupings and saved planner views
|
||||
- Conflict-aware drag-and-drop rescheduling improvements beyond the shipped planner-side station reassignment controls
|
||||
- Richer conflict handling, queue-slot suggestions, and auto-rebalance logic beyond the shipped station-lane drag scheduling
|
||||
- Critical-path and overdue highlighting
|
||||
- Richer finite-capacity warnings, automated rebalance logic, and station drag-rescheduling beyond the shipped overload indicators and workbench rebalance controls
|
||||
- Better mobile and tablet behavior for shop-floor lookups
|
||||
|
||||
@@ -37,7 +37,7 @@ This file tracks roadmap phases, slices, and major foundations that have already
|
||||
- Project cockpit section on detail pages for commercial, supply, execution, delivery, purchasing, readiness-risk, and cost-snapshot visibility, with direct launch paths into prefilled project work orders and demand-linked purchase orders plus a project activity timeline
|
||||
- Project list/detail/create/edit workflows and dashboard program widgets
|
||||
- Manufacturing foundation with work orders, project linkage, material issue posting, completion posting, and work-order attachments
|
||||
- Manufacturing stations, item routing templates, station calendars/capacity settings, automatic work-order operation planning, and operation-level rescheduling for the workbench schedule
|
||||
- Manufacturing stations, item routing templates, editable station calendars/capacity settings, automatic work-order operation planning, and operation-level rescheduling for the workbench schedule
|
||||
- Vendor invoice/supporting-document attachments directly on purchase orders
|
||||
- Vendor-detail purchasing visibility with recent purchase-order activity
|
||||
- Revision comparison UX for changed sales and purchasing documents, including purchase-order revision persistence
|
||||
@@ -57,7 +57,7 @@ This file tracks roadmap phases, slices, and major foundations that have already
|
||||
- Live planning workbench timelines driven by project and manufacturing data
|
||||
- Planning workbench with heatmap, overview, and agenda modes plus exception rail, focus drawer, station load grouping, readiness scoring, and inline dispatch actions
|
||||
- Finite-capacity foundation with station working-day calendars, daily/parallel capacity settings, and calendar-aware operation scheduling
|
||||
- Planner-side workbench rebalance controls for operation scheduling, with quick shift moves, heatmap-day targeting, and station-to-station reassignment
|
||||
- Planner-side workbench rebalance controls for operation scheduling, with quick shift moves, heatmap-day targeting, station-to-station reassignment, and station-lane drag scheduling
|
||||
- Sales-order demand planning with multi-level BOM explosion, stock/open-supply netting, and build/buy recommendations
|
||||
- Multi-stage Docker packaging and migration-aware entrypoint
|
||||
- Docker image validated locally with successful app startup and login flow
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
listManufacturingStations,
|
||||
listWorkOrders,
|
||||
recordWorkOrderCompletion,
|
||||
updateManufacturingStation,
|
||||
updateWorkOrder,
|
||||
updateWorkOrderOperationSchedule,
|
||||
updateWorkOrderStatus,
|
||||
@@ -100,6 +101,25 @@ manufacturingRouter.post("/stations", requirePermissions([permissions.manufactur
|
||||
return ok(response, await createManufacturingStation(parsed.data, request.authUser?.id), 201);
|
||||
});
|
||||
|
||||
manufacturingRouter.put("/stations/:stationId", requirePermissions([permissions.manufacturingWrite]), async (request, response) => {
|
||||
const stationId = getRouteParam(request.params.stationId);
|
||||
if (!stationId) {
|
||||
return fail(response, 400, "INVALID_INPUT", "Manufacturing station id is invalid.");
|
||||
}
|
||||
|
||||
const parsed = stationSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return fail(response, 400, "INVALID_INPUT", "Manufacturing station payload is invalid.");
|
||||
}
|
||||
|
||||
const result = await updateManufacturingStation(stationId, parsed.data, request.authUser?.id);
|
||||
if (!result.ok) {
|
||||
return fail(response, 404, "STATION_NOT_FOUND", result.reason);
|
||||
}
|
||||
|
||||
return ok(response, result.station);
|
||||
});
|
||||
|
||||
manufacturingRouter.get("/work-orders", requirePermissions([permissions.manufacturingRead]), async (request, response) => {
|
||||
const parsed = workOrderFiltersSchema.safeParse(request.query);
|
||||
if (!parsed.success) {
|
||||
|
||||
@@ -927,6 +927,57 @@ export async function createManufacturingStation(payload: ManufacturingStationIn
|
||||
return mapStation(station);
|
||||
}
|
||||
|
||||
export async function updateManufacturingStation(stationId: string, payload: ManufacturingStationInput, actorId?: string | null) {
|
||||
const existing = await prisma.manufacturingStation.findUnique({
|
||||
where: { id: stationId },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
return { ok: false as const, reason: "Manufacturing station was not found." };
|
||||
}
|
||||
|
||||
const workingDays = normalizeStationWorkingDays(payload.workingDays);
|
||||
const station = await prisma.manufacturingStation.update({
|
||||
where: { id: stationId },
|
||||
data: {
|
||||
code: payload.code.trim(),
|
||||
name: payload.name.trim(),
|
||||
description: payload.description,
|
||||
queueDays: payload.queueDays,
|
||||
dailyCapacityMinutes: payload.dailyCapacityMinutes,
|
||||
parallelCapacity: payload.parallelCapacity,
|
||||
workingDays: workingDays.join(","),
|
||||
isActive: payload.isActive,
|
||||
},
|
||||
});
|
||||
|
||||
await logAuditEvent({
|
||||
actorId,
|
||||
entityType: "manufacturing-station",
|
||||
entityId: station.id,
|
||||
action: "updated",
|
||||
summary: `Updated manufacturing station ${station.code}.`,
|
||||
metadata: {
|
||||
previousCode: existing.code,
|
||||
previousName: existing.name,
|
||||
previousQueueDays: existing.queueDays,
|
||||
previousDailyCapacityMinutes: existing.dailyCapacityMinutes,
|
||||
previousParallelCapacity: existing.parallelCapacity,
|
||||
previousWorkingDays: parseWorkingDays(existing.workingDays),
|
||||
previousIsActive: existing.isActive,
|
||||
code: station.code,
|
||||
name: station.name,
|
||||
queueDays: station.queueDays,
|
||||
dailyCapacityMinutes: station.dailyCapacityMinutes,
|
||||
parallelCapacity: station.parallelCapacity,
|
||||
workingDays,
|
||||
isActive: station.isActive,
|
||||
},
|
||||
});
|
||||
|
||||
return { ok: true as const, station: mapStation(station) };
|
||||
}
|
||||
|
||||
export async function listManufacturingProjectOptions(): Promise<ManufacturingProjectOptionDto[]> {
|
||||
const projects = await prisma.project.findMany({
|
||||
where: {
|
||||
|
||||
Reference in New Issue
Block a user