Add order editing and qualified downtime history
This commit is contained in:
@@ -41,10 +41,12 @@ export default function App() {
|
||||
const [dashboard, setDashboard] = useState<DashboardSnapshot | null>(null);
|
||||
const [orders, setOrders] = useState<ProductionOrder[]>([]);
|
||||
const [unqualifiedDowntimes, setUnqualifiedDowntimes] = useState<Downtime[]>([]);
|
||||
const [qualifiedDowntimes, setQualifiedDowntimes] = useState<Downtime[]>([]);
|
||||
const [scraps, setScraps] = useState<Scrap[]>([]);
|
||||
const [references, setReferences] = useState<ReferenceData | null>(null);
|
||||
const [dailyOee, setDailyOee] = useState<DailyOee | null>(null);
|
||||
const [selectedMachineId, setSelectedMachineId] = useState<number | null>(null);
|
||||
const [selectedOrderId, setSelectedOrderId] = useState<number | null>(null);
|
||||
const [activeModule, setActiveModule] = useState<ModuleKey>("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<ProductionOrder[]>("/api/production-orders"),
|
||||
fetchJson<Downtime[]>("/api/downtimes/unqualified"),
|
||||
fetchJson<Downtime[]>("/api/downtimes/qualified"),
|
||||
fetchJson<Scrap[]>("/api/scraps"),
|
||||
fetchJson<ReferenceData>("/api/reference-data"),
|
||||
fetchJson<DailyOee>(`/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<HTMLFormElement>) {
|
||||
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<HTMLFormElement>) {
|
||||
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 (
|
||||
<Panel title="Ordres de Fabrication" subtitle="Create and manage production orders">
|
||||
<Panel title="Ordres de Fabrication" subtitle={selectedOrder ? `Selected ${selectedOrder.order_number}` : "Create and manage production orders"}>
|
||||
<div className="module-grid module-grid--form-table">
|
||||
<form
|
||||
className="form-grid module-form"
|
||||
@@ -639,16 +704,22 @@ export default function App() {
|
||||
<th>OF</th>
|
||||
<th>Machine</th>
|
||||
<th>Article</th>
|
||||
<th>Planned</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{orders.map((order) => (
|
||||
<tr key={order.id}>
|
||||
<td>{order.order_number}</td>
|
||||
<td>{order.machine_id}</td>
|
||||
<tr key={order.id} className={order.id === selectedOrderId ? "is-selected-row" : ""}>
|
||||
<td>
|
||||
<button type="button" className="link-button" onClick={() => setSelectedOrderId(order.id)}>
|
||||
{order.order_number}
|
||||
</button>
|
||||
</td>
|
||||
<td>{orderMachineLabel(order)}</td>
|
||||
<td>{order.article_name}</td>
|
||||
<td>{order.planned_qty}</td>
|
||||
<td>{order.status}</td>
|
||||
<td className="table-actions">
|
||||
<button type="button" onClick={() => changeOrderStatus(order.id, "start")}>
|
||||
@@ -666,6 +737,79 @@ export default function App() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{selectedOrder ? (
|
||||
<section className="order-detail">
|
||||
<div className="order-detail__header">
|
||||
<div>
|
||||
<span className="meta-label">OF detail</span>
|
||||
<h3>{selectedOrder.order_number}</h3>
|
||||
</div>
|
||||
<StatusBadge status={selectedOrder.status} />
|
||||
</div>
|
||||
<div className="order-detail__meta">
|
||||
<div>
|
||||
<span className="meta-label">Machine</span>
|
||||
<strong>{orderMachineLabel(selectedOrder)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className="meta-label">Article</span>
|
||||
<strong>{selectedOrder.article_ref} / {selectedOrder.article_name}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className="meta-label">Cycle / cavities</span>
|
||||
<strong>{selectedOrder.theoretical_cycle_time_sec} s / {selectedOrder.cavities}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className="meta-label">Dates</span>
|
||||
<strong>
|
||||
{selectedOrder.started_at ? new Date(selectedOrder.started_at).toLocaleString() : "Not started"}
|
||||
{selectedOrder.ended_at ? ` -> ${new Date(selectedOrder.ended_at).toLocaleString()}` : ""}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
<form
|
||||
className="form-grid config-form"
|
||||
key={selectedOrder.id}
|
||||
onSubmit={submitOrderUpdate}
|
||||
onBlurCapture={(event) => handleFormBlur("order", event.currentTarget)}
|
||||
onInputCapture={(event) => validateForm(event.currentTarget, "order")}
|
||||
onChangeCapture={(event) => validateForm(event.currentTarget, "order")}
|
||||
noValidate
|
||||
>
|
||||
<input name="order_number" defaultValue={selectedOrder.order_number} placeholder="OF number" required />
|
||||
<select name="machine_id" required defaultValue={selectedOrder.machine_id}>
|
||||
{machines.map((machine) => (
|
||||
<option key={machine.id} value={machine.id}>
|
||||
{machine.code}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input name="article_ref" defaultValue={selectedOrder.article_ref} placeholder="Article ref" required />
|
||||
<input name="article_name" defaultValue={selectedOrder.article_name} placeholder="Article name" required />
|
||||
<input name="mold_ref" defaultValue={selectedOrder.mold_ref ?? ""} placeholder="Mold ref" />
|
||||
<input name="material_ref" defaultValue={selectedOrder.material_ref ?? ""} placeholder="Material ref" />
|
||||
<input name="planned_qty" type="number" min="1" defaultValue={selectedOrder.planned_qty} placeholder="Planned qty" required />
|
||||
<input name="cavities" type="number" min="1" defaultValue={selectedOrder.cavities} placeholder="Cavities" required />
|
||||
<input
|
||||
name="theoretical_cycle_time_sec"
|
||||
type="number"
|
||||
min="1"
|
||||
step="0.1"
|
||||
defaultValue={selectedOrder.theoretical_cycle_time_sec}
|
||||
placeholder="Theoretical cycle"
|
||||
required
|
||||
/>
|
||||
<button type="submit" className="primary-button" disabled={Object.keys(formErrors.order).length > 0}>
|
||||
Update OF
|
||||
</button>
|
||||
{actionError("order")}
|
||||
{toast("order")}
|
||||
</form>
|
||||
</section>
|
||||
) : (
|
||||
<div className="empty-state">Select an OF to see and update details.</div>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
@@ -763,6 +907,32 @@ export default function App() {
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<span className="meta-label">Qualified stops history</span>
|
||||
<ul className="timeline-list timeline-list--qualified">
|
||||
{qualifiedDowntimes.map((item) => (
|
||||
<li key={item.id} className={item.machine_id === selectedMachineId ? "is-current-machine" : ""}>
|
||||
<div>
|
||||
<strong>
|
||||
{downtimeMachineLabel(item)} / {item.reason_code}
|
||||
</strong>
|
||||
<span>
|
||||
{item.order_number ? `${item.order_number} / ` : ""}
|
||||
{item.qualified_by ? `By ${item.qualified_by} / ` : ""}
|
||||
{formatDurationSeconds(item.duration_sec)}
|
||||
{item.comment ? ` / ${item.comment}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
<time>
|
||||
{new Date(item.start_time).toLocaleString()}
|
||||
{item.end_time ? ` -> ${new Date(item.end_time).toLocaleString()}` : ""}
|
||||
</time>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{qualifiedDowntimes.length === 0 ? <div className="empty-state">No qualified downtime history yet.</div> : null}
|
||||
</section>
|
||||
</div>
|
||||
{unqualifiedDowntimes.length === 0 ? <div className="empty-state">No downtime currently needs qualification.</div> : null}
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user