Improve downtime qualification visibility
This commit is contained in:
@@ -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")
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user