Improve downtime qualification visibility

This commit is contained in:
masterdev
2026-06-17 02:21:47 +01:00
parent c7c11d5060
commit 4e6286b1a3
4 changed files with 138 additions and 28 deletions

View File

@@ -440,6 +440,9 @@ def get_machine_cycles(machine_id: int, limit: int = Query(default=100, le=500),
@app.get("/api/machines/{machine_id}/downtimes") @app.get("/api/machines/{machine_id}/downtimes")
def get_machine_downtimes(machine_id: int, limit: int = Query(default=100, le=500), db: Session = Depends(get_db)) -> list[dict]: def get_machine_downtimes(machine_id: int, limit: int = Query(default=100, le=500), db: Session = Depends(get_db)) -> list[dict]:
machine = db.get(Machine, machine_id)
if machine is None:
raise HTTPException(status_code=404, detail="Machine not found")
rows = db.execute( rows = db.execute(
select(Downtime).where(Downtime.machine_id == machine_id).order_by(Downtime.start_time.desc()).limit(limit) select(Downtime).where(Downtime.machine_id == machine_id).order_by(Downtime.start_time.desc()).limit(limit)
).scalars() ).scalars()
@@ -449,6 +452,8 @@ def get_machine_downtimes(machine_id: int, limit: int = Query(default=100, le=50
"start_time": row.start_time.isoformat(), "start_time": row.start_time.isoformat(),
"end_time": row.end_time.isoformat() if row.end_time else None, "end_time": row.end_time.isoformat() if row.end_time else None,
"duration_sec": row.duration_sec, "duration_sec": row.duration_sec,
"machine_id": row.machine_id,
"machine_code": machine.code,
"reason_code": row.reason_code, "reason_code": row.reason_code,
"comment": row.comment, "comment": row.comment,
"auto_detected": row.auto_detected, "auto_detected": row.auto_detected,
@@ -549,19 +554,27 @@ def close_production_order(order_id: int, db: Session = Depends(get_db)) -> dict
@app.get("/api/downtimes/open") @app.get("/api/downtimes/open")
def get_open_downtimes(db: Session = Depends(get_db)) -> list[dict]: def get_open_downtimes(db: Session = Depends(get_db)) -> list[dict]:
rows = db.execute(select(Downtime).where(Downtime.end_time.is_(None)).order_by(Downtime.start_time.asc())).scalars() rows = db.execute(select(Downtime).where(Downtime.end_time.is_(None)).order_by(Downtime.start_time.asc())).scalars()
return [ result = []
for row in rows:
machine = db.get(Machine, row.machine_id)
order = db.get(ProductionOrder, row.production_order_id) if row.production_order_id else None
result.append(
{ {
"id": row.id, "id": row.id,
"machine_id": row.machine_id, "machine_id": row.machine_id,
"machine_code": machine.code if machine else None,
"production_order_id": row.production_order_id, "production_order_id": row.production_order_id,
"order_number": order.order_number if order else None,
"start_time": row.start_time.isoformat(), "start_time": row.start_time.isoformat(),
"end_time": None,
"duration_sec": row.duration_sec,
"reason_code": row.reason_code, "reason_code": row.reason_code,
"comment": row.comment, "comment": row.comment,
"auto_detected": row.auto_detected, "auto_detected": row.auto_detected,
"qualified_by": row.qualified_by, "qualified_by": row.qualified_by,
} }
for row in rows )
] return result
@app.post("/api/downtimes/{downtime_id}/qualify") @app.post("/api/downtimes/{downtime_id}/qualify")

View File

@@ -192,6 +192,16 @@ export default function App() {
const selectedMachine = dashboard?.machines.find((machine) => machine.id === selectedMachineId) ?? null; const selectedMachine = dashboard?.machines.find((machine) => machine.id === selectedMachineId) ?? null;
const machines = dashboard?.machines ?? []; const machines = dashboard?.machines ?? [];
const selectedMachineOpenDowntimes =
selectedMachineId === null ? [] : openDowntimes.filter((downtime) => downtime.machine_id === selectedMachineId);
const downtimeOptions = [...openDowntimes].sort((left, right) => {
const leftSelected = selectedMachineId !== null && left.machine_id === selectedMachineId;
const rightSelected = selectedMachineId !== null && right.machine_id === selectedMachineId;
if (leftSelected !== rightSelected) {
return Number(rightSelected) - Number(leftSelected);
}
return new Date(left.start_time).getTime() - new Date(right.start_time).getTime();
});
const summaryMetrics = { const summaryMetrics = {
production: machines.filter((machine) => machine.status === "production").length, production: machines.filter((machine) => machine.status === "production").length,
openDowntimes: openDowntimes.length, openDowntimes: openDowntimes.length,
@@ -403,6 +413,22 @@ export default function App() {
return toasts[key] ? <div className="inline-toast">{toasts[key]}</div> : null; return toasts[key] ? <div className="inline-toast">{toasts[key]}</div> : null;
} }
function downtimeMachineLabel(downtime: Downtime) {
if (downtime.machine_code) {
return downtime.machine_code;
}
const machine = machines.find((item) => item.id === downtime.machine_id);
return machine?.code ?? `Machine ${downtime.machine_id ?? "-"}`;
}
function downtimeOptionLabel(downtime: Downtime) {
const parts = [`#${downtime.id}`, downtimeMachineLabel(downtime)];
if (downtime.order_number) {
parts.push(downtime.order_number);
}
return parts.join(" / ");
}
function renderModule(): ReactNode { function renderModule(): ReactNode {
if (activeModule === "atelier") { if (activeModule === "atelier") {
return ( return (
@@ -631,23 +657,31 @@ export default function App() {
if (activeModule === "downtimes") { if (activeModule === "downtimes") {
return ( return (
<Panel title="Qualification Arret" subtitle={`${openDowntimes.length} open downtime(s)`}> <Panel
title="Qualification Arret"
subtitle={
selectedMachine
? `${selectedMachineOpenDowntimes.length} open for ${selectedMachine.code} / ${openDowntimes.length} total`
: `${openDowntimes.length} open downtime(s)`
}
>
<div className="module-grid module-grid--form-table"> <div className="module-grid module-grid--form-table">
<form <form
className="form-grid module-form" className="form-grid module-form"
key={selectedMachine?.id ?? "all-downtimes"}
onSubmit={submitDowntimeQualification} onSubmit={submitDowntimeQualification}
onBlurCapture={(event) => handleFormBlur("downtime", event.currentTarget)} onBlurCapture={(event) => handleFormBlur("downtime", event.currentTarget)}
onInputCapture={(event) => validateForm(event.currentTarget, "downtime")} onInputCapture={(event) => validateForm(event.currentTarget, "downtime")}
onChangeCapture={(event) => validateForm(event.currentTarget, "downtime")} onChangeCapture={(event) => validateForm(event.currentTarget, "downtime")}
noValidate noValidate
> >
<select name="downtime_id" required defaultValue=""> <select name="downtime_id" required defaultValue={selectedMachineOpenDowntimes[0]?.id ?? ""}>
<option value="" disabled> <option value="" disabled>
Open downtime Open downtime
</option> </option>
{openDowntimes.map((downtime) => ( {downtimeOptions.map((downtime) => (
<option key={downtime.id} value={downtime.id}> <option key={downtime.id} value={downtime.id}>
#{downtime.id} machine {downtime.machine_id} {downtimeOptionLabel(downtime)}
</option> </option>
))} ))}
</select> </select>
@@ -673,17 +707,47 @@ export default function App() {
{toast("downtime")} {toast("downtime")}
</form> </form>
<ul className="timeline-list"> <div className="downtime-board">
{openDowntimes.map((item) => ( {selectedMachine ? (
<section className="downtime-focus">
<span className="meta-label">Selected machine</span>
<strong>{selectedMachine.code}</strong>
{selectedMachineOpenDowntimes.length > 0 ? (
<ul className="timeline-list timeline-list--compact">
{selectedMachineOpenDowntimes.map((item) => (
<li key={item.id}> <li key={item.id}>
<div> <div>
<strong>Machine {item.machine_id}</strong> <strong>{downtimeOptionLabel(item)}</strong>
<span>Waiting qualification</span> <span>{item.reason_code ?? "Waiting qualification"}</span>
</div> </div>
<time>{new Date(item.start_time).toLocaleString()}</time> <time>{new Date(item.start_time).toLocaleString()}</time>
</li> </li>
))} ))}
</ul> </ul>
) : (
<div className="empty-state">No open downtime for {selectedMachine.code}.</div>
)}
</section>
) : null}
<section>
<span className="meta-label">All open stops</span>
<ul className="timeline-list">
{downtimeOptions.map((item) => (
<li key={item.id} className={item.machine_id === selectedMachineId ? "is-current-machine" : ""}>
<div>
<strong>{downtimeMachineLabel(item)}</strong>
<span>
{item.order_number ? `${item.order_number} / ` : ""}
{item.reason_code ?? "Waiting qualification"}
</span>
</div>
<time>{new Date(item.start_time).toLocaleString()}</time>
</li>
))}
</ul>
</section>
</div>
{openDowntimes.length === 0 ? <div className="empty-state">No open downtime currently needs qualification.</div> : null} {openDowntimes.length === 0 ? <div className="empty-state">No open downtime currently needs qualification.</div> : null}
</div> </div>
</Panel> </Panel>

View File

@@ -83,7 +83,9 @@ export type Cycle = {
export type Downtime = { export type Downtime = {
id: number; id: number;
machine_id?: number; machine_id?: number;
machine_code?: string | null;
production_order_id: number | null; production_order_id: number | null;
order_number?: string | null;
start_time: string; start_time: string;
end_time: string | null; end_time: string | null;
duration_sec: number | null; duration_sec: number | null;

View File

@@ -708,6 +708,11 @@ th {
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
.timeline-list li.is-current-machine {
border-radius: 8px;
background: var(--accent-light);
}
.timeline-list li::before { .timeline-list li::before {
position: absolute; position: absolute;
top: 18px; top: 18px;
@@ -753,6 +758,32 @@ th {
text-align: right; text-align: right;
} }
.timeline-list--compact {
margin-top: 10px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-panel);
}
.downtime-board {
display: grid;
gap: 14px;
}
.downtime-focus {
padding: 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-panel-alt);
}
.downtime-focus > strong {
display: block;
margin-top: 4px;
font-family: "Space Grotesk", sans-serif;
font-size: var(--text-lg);
}
.oee-mini-list { .oee-mini-list {
display: grid; display: grid;
gap: 8px; gap: 8px;