From 7dd7652ea7efd963996fc1c3deec61e84693dc8c Mon Sep 17 00:00:00 2001 From: masterdev Date: Wed, 17 Jun 2026 02:45:05 +0100 Subject: [PATCH] Add order editing and qualified downtime history --- backend/app/main.py | 78 ++++++++++++---- backend/app/schemas.py | 12 +++ frontend/src/App.tsx | 182 ++++++++++++++++++++++++++++++++++++-- frontend/src/lib/types.ts | 1 + frontend/src/styles.css | 72 +++++++++++++++ 5 files changed, 321 insertions(+), 24 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index f0ad8be..936fdfc 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -18,6 +18,7 @@ from .schemas import ( MachineConfigUpdate, MachineEventIn, ProductionOrderCreate, + ProductionOrderUpdate, ScrapCreate, SimulatorEventCreate, SimulatorScenarioCreate, @@ -466,24 +467,27 @@ def get_machine_downtimes(machine_id: int, limit: int = Query(default=100, le=50 @app.get("/api/production-orders") def list_production_orders(db: Session = Depends(get_db)) -> list[dict]: rows = db.execute(select(ProductionOrder).order_by(ProductionOrder.created_at.desc())).scalars() - return [ - { - "id": row.id, - "order_number": row.order_number, - "machine_id": row.machine_id, - "article_ref": row.article_ref, - "article_name": row.article_name, - "mold_ref": row.mold_ref, - "material_ref": row.material_ref, - "planned_qty": row.planned_qty, - "cavities": row.cavities, - "theoretical_cycle_time_sec": row.theoretical_cycle_time_sec, - "status": row.status, - "started_at": row.started_at.isoformat() if row.started_at else None, - "ended_at": row.ended_at.isoformat() if row.ended_at else None, - } - for row in rows - ] + return [_serialize_production_order(db, row) for row in rows] + + +def _serialize_production_order(db: Session, row: ProductionOrder) -> dict: + machine = db.get(Machine, row.machine_id) + return { + "id": row.id, + "order_number": row.order_number, + "machine_id": row.machine_id, + "machine_code": machine.code if machine else None, + "article_ref": row.article_ref, + "article_name": row.article_name, + "mold_ref": row.mold_ref, + "material_ref": row.material_ref, + "planned_qty": row.planned_qty, + "cavities": row.cavities, + "theoretical_cycle_time_sec": row.theoretical_cycle_time_sec, + "status": row.status, + "started_at": row.started_at.isoformat() if row.started_at else None, + "ended_at": row.ended_at.isoformat() if row.ended_at else None, + } @app.post("/api/production-orders") @@ -503,6 +507,36 @@ def _find_order_or_404(db: Session, order_id: int) -> ProductionOrder: return order +@app.patch("/api/production-orders/{order_id}") +def update_production_order(order_id: int, payload: ProductionOrderUpdate, db: Session = Depends(get_db)) -> dict: + order = _find_order_or_404(db, order_id) + updates = payload.model_dump(exclude_unset=True) + if not updates: + return _serialize_production_order(db, order) + + if "order_number" in updates and updates["order_number"] != order.order_number: + existing = db.execute(select(ProductionOrder).where(ProductionOrder.order_number == updates["order_number"])).scalar_one_or_none() + if existing is not None: + raise HTTPException(status_code=400, detail="Production order number already exists") + + if "machine_id" in updates and updates["machine_id"] != order.machine_id: + target_machine = db.get(Machine, updates["machine_id"]) + if target_machine is None: + raise HTTPException(status_code=404, detail="Machine not found") + if order.status == "running": + running_order = get_running_order(db, updates["machine_id"]) + if running_order and running_order.id != order.id: + raise HTTPException(status_code=400, detail="Another order is already running on the target machine") + + for field, value in updates.items(): + setattr(order, field, value) + + db.commit() + db.refresh(order) + hub.notify({"type": "refresh", "source": "api", "machine_id": order.machine_id, "event_type": "production_order_updated"}) + return _serialize_production_order(db, order) + + @app.post("/api/production-orders/{order_id}/start") def start_production_order(order_id: int, db: Session = Depends(get_db)) -> dict: order = _find_order_or_404(db, order_id) @@ -565,6 +599,14 @@ def get_unqualified_downtimes(db: Session = Depends(get_db)) -> list[dict]: return [_serialize_downtime_for_qualification(db, row) for row in rows] +@app.get("/api/downtimes/qualified") +def get_qualified_downtimes(limit: int = Query(default=200, le=500), db: Session = Depends(get_db)) -> list[dict]: + rows = db.execute( + select(Downtime).where(Downtime.reason_code.is_not(None)).order_by(Downtime.start_time.desc()).limit(limit) + ).scalars() + return [_serialize_downtime_for_qualification(db, row) for row in rows] + + def _serialize_downtime_for_qualification(db: Session, row: Downtime) -> dict: machine = db.get(Machine, row.machine_id) order = db.get(ProductionOrder, row.production_order_id) if row.production_order_id else None diff --git a/backend/app/schemas.py b/backend/app/schemas.py index bf0a667..0df0a90 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -27,6 +27,18 @@ class ProductionOrderCreate(BaseModel): theoretical_cycle_time_sec: float = Field(gt=0) +class ProductionOrderUpdate(BaseModel): + order_number: str | None = None + machine_id: int | None = None + article_ref: str | None = None + article_name: str | None = None + mold_ref: str | None = None + material_ref: str | None = None + planned_qty: int | None = Field(default=None, gt=0) + cavities: int | None = Field(default=None, gt=0) + theoretical_cycle_time_sec: float | None = Field(default=None, gt=0) + + class DowntimeQualify(BaseModel): reason_code: str comment: str | None = None diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8ab6ae2..3176a7c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -41,10 +41,12 @@ export default function App() { const [dashboard, setDashboard] = useState(null); const [orders, setOrders] = useState([]); const [unqualifiedDowntimes, setUnqualifiedDowntimes] = useState([]); + const [qualifiedDowntimes, setQualifiedDowntimes] = useState([]); const [scraps, setScraps] = useState([]); const [references, setReferences] = useState(null); const [dailyOee, setDailyOee] = useState(null); const [selectedMachineId, setSelectedMachineId] = useState(null); + const [selectedOrderId, setSelectedOrderId] = useState(null); const [activeModule, setActiveModule] = useState("atelier"); const [machineFilter, setMachineFilter] = useState("all"); const [machineSort, setMachineSort] = useState("code"); @@ -78,16 +80,19 @@ export default function App() { } async function loadCollections() { - const [loadedOrders, loadedUnqualifiedDowntimes, loadedScraps, loadedReferences, loadedDailyOee] = await Promise.all([ + const [loadedOrders, loadedUnqualifiedDowntimes, loadedQualifiedDowntimes, loadedScraps, loadedReferences, loadedDailyOee] = await Promise.all([ fetchJson("/api/production-orders"), fetchJson("/api/downtimes/unqualified"), + fetchJson("/api/downtimes/qualified"), fetchJson("/api/scraps"), fetchJson("/api/reference-data"), fetchJson(`/api/oee/daily?date=${today}`), ]); setOrders(loadedOrders); + setSelectedOrderId((current) => (current && loadedOrders.some((order) => order.id === current) ? current : loadedOrders[0]?.id ?? null)); setUnqualifiedDowntimes(loadedUnqualifiedDowntimes); + setQualifiedDowntimes(loadedQualifiedDowntimes); setScraps(loadedScraps); setReferences(loadedReferences); setDailyOee(loadedDailyOee); @@ -192,6 +197,7 @@ export default function App() { const selectedMachine = dashboard?.machines.find((machine) => machine.id === selectedMachineId) ?? null; const machines = dashboard?.machines ?? []; + const selectedOrder = orders.find((order) => order.id === selectedOrderId) ?? null; const openDowntimeCount = unqualifiedDowntimes.filter((downtime) => downtime.end_time === null).length; const selectedMachineUnqualifiedDowntimes = selectedMachineId === null ? [] : unqualifiedDowntimes.filter((downtime) => downtime.machine_id === selectedMachineId); @@ -270,7 +276,7 @@ export default function App() { try { setActionErrors((current) => ({ ...current, order: null })); - await postJson("/api/production-orders", { + const createdOrder = await postJson<{ id: number; status: string }>("/api/production-orders", { order_number: formData.get("order_number"), machine_id: Number(formData.get("machine_id")), article_ref: formData.get("article_ref"), @@ -283,6 +289,7 @@ export default function App() { }); form.reset(); + setSelectedOrderId(createdOrder.id); setFormErrors((current) => ({ ...current, order: {} })); showToast("order", "Production order created."); await loadAll(); @@ -291,6 +298,40 @@ export default function App() { } } + async function submitOrderUpdate(event: FormEvent) { + event.preventDefault(); + if (!selectedOrder) { + setActionErrors((current) => ({ ...current, order: "Select an OF before updating." })); + return; + } + const form = event.currentTarget; + if (!validateForm(form, "order")) { + return; + } + const formData = new FormData(form); + + try { + setActionErrors((current) => ({ ...current, order: null })); + await patchJson(`/api/production-orders/${selectedOrder.id}`, { + order_number: formData.get("order_number"), + machine_id: Number(formData.get("machine_id")), + article_ref: formData.get("article_ref"), + article_name: formData.get("article_name"), + mold_ref: formData.get("mold_ref"), + material_ref: formData.get("material_ref"), + planned_qty: Number(formData.get("planned_qty")), + cavities: Number(formData.get("cavities")), + theoretical_cycle_time_sec: Number(formData.get("theoretical_cycle_time_sec")), + }); + + setFormErrors((current) => ({ ...current, order: {} })); + showToast("order", "Production order updated."); + await loadAll(); + } catch (error) { + setActionErrors((current) => ({ ...current, order: error instanceof Error ? error.message : "Order update failed." })); + } + } + async function submitDowntimeQualification(event: FormEvent) { event.preventDefault(); const form = event.currentTarget; @@ -442,6 +483,30 @@ export default function App() { return downtime.end_time === null ? "Open - waiting qualification" : "Ended - waiting qualification"; } + function formatDurationSeconds(durationSec: number | null) { + if (durationSec === null) { + return "In progress"; + } + if (durationSec < 60) { + return `${durationSec} s`; + } + const minutes = Math.floor(durationSec / 60); + const seconds = durationSec % 60; + if (minutes < 60) { + return seconds > 0 ? `${minutes} min ${seconds} s` : `${minutes} min`; + } + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + return remainingMinutes > 0 ? `${hours} h ${remainingMinutes} min` : `${hours} h`; + } + + function orderMachineLabel(order: ProductionOrder) { + if (order.machine_code) { + return order.machine_code; + } + return machines.find((machine) => machine.id === order.machine_id)?.code ?? `Machine ${order.machine_id}`; + } + function renderModule(): ReactNode { if (activeModule === "atelier") { return ( @@ -590,7 +655,7 @@ export default function App() { if (activeModule === "orders") { return ( - +
OF Machine Article + Planned Status Actions {orders.map((order) => ( - - {order.order_number} - {order.machine_id} + + + + + {orderMachineLabel(order)} {order.article_name} + {order.planned_qty} {order.status}
+ + {selectedOrder ? ( +
+
+
+ OF detail +

{selectedOrder.order_number}

+
+ +
+
+
+ Machine + {orderMachineLabel(selectedOrder)} +
+
+ Article + {selectedOrder.article_ref} / {selectedOrder.article_name} +
+
+ Cycle / cavities + {selectedOrder.theoretical_cycle_time_sec} s / {selectedOrder.cavities} +
+
+ Dates + + {selectedOrder.started_at ? new Date(selectedOrder.started_at).toLocaleString() : "Not started"} + {selectedOrder.ended_at ? ` -> ${new Date(selectedOrder.ended_at).toLocaleString()}` : ""} + +
+
+ handleFormBlur("order", event.currentTarget)} + onInputCapture={(event) => validateForm(event.currentTarget, "order")} + onChangeCapture={(event) => validateForm(event.currentTarget, "order")} + noValidate + > + + + + + + + + + + + {actionError("order")} + {toast("order")} + +
+ ) : ( +
Select an OF to see and update details.
+ )}
); @@ -763,6 +907,32 @@ export default function App() { ))} + +
+ Qualified stops history +
    + {qualifiedDowntimes.map((item) => ( +
  • +
    + + {downtimeMachineLabel(item)} / {item.reason_code} + + + {item.order_number ? `${item.order_number} / ` : ""} + {item.qualified_by ? `By ${item.qualified_by} / ` : ""} + {formatDurationSeconds(item.duration_sec)} + {item.comment ? ` / ${item.comment}` : ""} + +
    + +
  • + ))} +
+ {qualifiedDowntimes.length === 0 ?
No qualified downtime history yet.
: null} +
{unqualifiedDowntimes.length === 0 ?
No downtime currently needs qualification.
: null} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index f02ebe3..bbdd3c6 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -99,6 +99,7 @@ export type ProductionOrder = { id: number; order_number: string; machine_id: number; + machine_code?: string | null; article_ref: string; article_name: string; mold_ref: string | null; diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 42305c3..060aa39 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -562,6 +562,22 @@ th { background: var(--status-off-plan); } +.status-badge--planned { + background: var(--status-off-plan); +} + +.status-badge--running { + background: var(--status-production); +} + +.status-badge--paused { + background: var(--status-unqualified); +} + +.status-badge--closed { + background: var(--status-qualified); +} + .detail-stack { display: grid; gap: 14px; @@ -765,6 +781,10 @@ th { background: var(--bg-panel); } +.timeline-list--qualified li::before { + background: var(--status-production); +} + .downtime-board { display: grid; gap: 14px; @@ -907,6 +927,41 @@ table button { overflow-x: auto; } +.order-detail { + display: grid; + grid-column: 1 / -1; + gap: 14px; + padding: 14px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg-panel-alt); +} + +.order-detail__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.order-detail__header h3 { + margin: 4px 0 0; + font-family: "Space Grotesk", sans-serif; + font-size: var(--text-xl); +} + +.order-detail__meta { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; +} + +.order-detail__meta strong { + display: block; + margin-top: 4px; + overflow-wrap: anywhere; +} + table { width: 100%; border-collapse: collapse; @@ -923,6 +978,22 @@ td { font-size: var(--text-sm); } +tr.is-selected-row td { + background: var(--accent-light); +} + +.link-button { + min-height: 0; + border: 0; + background: transparent; + color: var(--teal-900); + padding: 0; + font-weight: 700; + text-align: left; + text-decoration: underline; + text-underline-offset: 3px; +} + .table-actions { display: flex; gap: 8px; @@ -981,6 +1052,7 @@ td { .oee-grid, .machine-context, .detail-columns, + .order-detail__meta, .config-form { grid-template-columns: 1fr; }