Files
plast-track/frontend/src/App.tsx
2026-06-15 13:35:22 +01:00

858 lines
34 KiB
TypeScript

import { FormEvent, ReactNode, useEffect, useRef, useState } from "react";
import CycleBarChart from "./components/CycleBarChart";
import KpiBlock from "./components/KpiBlock";
import MachineCard from "./components/MachineCard";
import Panel from "./components/Panel";
import StatusBadge from "./components/StatusBadge";
import { fetchJson, patchJson, postJson, WS_URL } from "./lib/api";
import { Cycle, DailyOee, DashboardSnapshot, Downtime, MachineEvent, ProductionOrder, ReferenceData, Scrap } from "./lib/types";
type DetailState = {
events: MachineEvent[];
cycles: Cycle[];
downtimes: Downtime[];
};
type ToastKey = "config" | "order" | "downtime" | "scrap";
type ModuleKey = "atelier" | "machine" | "trs" | "orders" | "downtimes" | "scrap" | "config";
type FormErrors = Record<ToastKey, Record<string, string>>;
type FormControl = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
const today = new Date().toISOString().slice(0, 10);
const initialFormErrors: FormErrors = {
config: {},
order: {},
downtime: {},
scrap: {},
};
const moduleTabs: Array<{ key: ModuleKey; label: string; description: string }> = [
{ key: "atelier", label: "Atelier", description: "Vue machines" },
{ key: "machine", label: "Machine", description: "Cycles et evenements" },
{ key: "trs", label: "TRS", description: "Performance jour" },
{ key: "orders", label: "OF", description: "Ordres fabrication" },
{ key: "downtimes", label: "Arrets", description: "Qualification" },
{ key: "scrap", label: "Rebuts", description: "Declaration" },
{ key: "config", label: "Config", description: "Parametrage signal" },
];
export default function App() {
const [dashboard, setDashboard] = useState<DashboardSnapshot | null>(null);
const [orders, setOrders] = useState<ProductionOrder[]>([]);
const [openDowntimes, setOpenDowntimes] = 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 [activeModule, setActiveModule] = useState<ModuleKey>("atelier");
const [machineFilter, setMachineFilter] = useState("all");
const [machineSort, setMachineSort] = useState("code");
const [isWorkshopMode, setIsWorkshopMode] = useState(false);
const [nowMs, setNowMs] = useState(Date.now());
const [detail, setDetail] = useState<DetailState>({ events: [], cycles: [], downtimes: [] });
const [connectionStatus, setConnectionStatus] = useState("Connecting");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [formErrors, setFormErrors] = useState<FormErrors>(initialFormErrors);
const [actionErrors, setActionErrors] = useState<Record<ToastKey, string | null>>({
config: null,
order: null,
downtime: null,
scrap: null,
});
const [toasts, setToasts] = useState<Record<ToastKey, string | null>>({
config: null,
order: null,
downtime: null,
scrap: null,
});
const refreshTimer = useRef<number | null>(null);
const toastTimers = useRef<Record<string, number>>({});
async function loadDashboard() {
const snapshot = await fetchJson<DashboardSnapshot>("/api/dashboard");
setDashboard(snapshot);
if (selectedMachineId === null && snapshot.machines.length > 0) {
setSelectedMachineId(snapshot.machines[0].id);
}
}
async function loadCollections() {
const [loadedOrders, loadedOpenDowntimes, loadedScraps, loadedReferences, loadedDailyOee] = await Promise.all([
fetchJson<ProductionOrder[]>("/api/production-orders"),
fetchJson<Downtime[]>("/api/downtimes/open"),
fetchJson<Scrap[]>("/api/scraps"),
fetchJson<ReferenceData>("/api/reference-data"),
fetchJson<DailyOee>(`/api/oee/daily?date=${today}`),
]);
setOrders(loadedOrders);
setOpenDowntimes(loadedOpenDowntimes);
setScraps(loadedScraps);
setReferences(loadedReferences);
setDailyOee(loadedDailyOee);
}
async function loadDetail(machineId: number) {
const [events, cycles, downtimes] = await Promise.all([
fetchJson<MachineEvent[]>(`/api/machines/${machineId}/events`),
fetchJson<Cycle[]>(`/api/machines/${machineId}/cycles`),
fetchJson<Downtime[]>(`/api/machines/${machineId}/downtimes`),
]);
setDetail({ events, cycles, downtimes });
}
async function loadAll() {
setErrorMessage(null);
await Promise.all([loadDashboard(), loadCollections()]);
}
function showToast(key: ToastKey, message: string) {
window.clearTimeout(toastTimers.current[key]);
setToasts((current) => ({ ...current, [key]: message }));
toastTimers.current[key] = window.setTimeout(() => {
setToasts((current) => ({ ...current, [key]: null }));
}, 3000);
}
useEffect(() => {
loadAll().catch((error: Error) => {
setErrorMessage(error.message);
setConnectionStatus("Load failed");
});
}, []);
useEffect(() => {
const timer = window.setInterval(() => setNowMs(Date.now()), 30000);
return () => window.clearInterval(timer);
}, []);
useEffect(() => {
if (selectedMachineId === null) {
return;
}
loadDetail(selectedMachineId).catch((error: Error) => setErrorMessage(error.message));
}, [selectedMachineId]);
useEffect(() => {
let socket: WebSocket | null = null;
let reconnectTimer: number | null = null;
let attempt = 0;
let isDisposed = false;
function connect() {
socket = new WebSocket(WS_URL);
socket.onopen = () => {
attempt = 0;
setConnectionStatus("Live");
};
socket.onclose = () => {
if (isDisposed) {
return;
}
setConnectionStatus("Reconnecting");
const delayMs = Math.min(30000, 1000 * 2 ** attempt);
attempt += 1;
reconnectTimer = window.setTimeout(connect, delayMs);
};
socket.onerror = () => setConnectionStatus("Reconnecting");
socket.onmessage = (event) => {
const message = JSON.parse(event.data) as { type: string; data?: DashboardSnapshot };
if (message.type === "snapshot" && message.data) {
setDashboard(message.data);
return;
}
if (refreshTimer.current) {
window.clearTimeout(refreshTimer.current);
}
refreshTimer.current = window.setTimeout(() => {
loadAll().catch((error: Error) => setErrorMessage(error.message));
if (selectedMachineId !== null) {
loadDetail(selectedMachineId).catch((error: Error) => setErrorMessage(error.message));
}
}, 300);
};
}
connect();
return () => {
isDisposed = true;
if (refreshTimer.current) {
window.clearTimeout(refreshTimer.current);
}
if (reconnectTimer) {
window.clearTimeout(reconnectTimer);
}
socket?.close();
};
}, [selectedMachineId]);
const selectedMachine = dashboard?.machines.find((machine) => machine.id === selectedMachineId) ?? null;
const machines = dashboard?.machines ?? [];
const summaryMetrics = {
production: machines.filter((machine) => machine.status === "production").length,
openDowntimes: openDowntimes.length,
producedQty: machines.reduce((total, machine) => total + machine.produced_qty, 0),
scrapQty: machines.reduce((total, machine) => total + machine.scrap_qty, 0),
oee: Math.round((dailyOee?.overall.oee ?? 0) * 100),
};
const machineStatuses = Array.from(new Set(machines.map((machine) => machine.status))).sort();
const visibleMachines = [...machines]
.filter((machine) => machineFilter === "all" || machine.status === machineFilter)
.sort((left, right) => {
if (machineSort === "oee") {
return right.today_oee.oee - left.today_oee.oee;
}
if (machineSort === "stops") {
return Number(Boolean(right.open_downtime)) - Number(Boolean(left.open_downtime));
}
return left.code.localeCompare(right.code);
});
function getFormControls(form: HTMLFormElement) {
return Array.from(form.elements).filter((element): element is FormControl => {
return element instanceof HTMLInputElement || element instanceof HTMLSelectElement || element instanceof HTMLTextAreaElement;
});
}
function validateForm(form: HTMLFormElement, key: ToastKey) {
const nextErrors: Record<string, string> = {};
getFormControls(form).forEach((control) => {
if (!control.name || control.disabled || control.type === "submit" || control.type === "button") {
return;
}
if (!control.checkValidity()) {
nextErrors[control.name] = control.validationMessage || "Invalid value.";
}
});
setFormErrors((current) => ({ ...current, [key]: nextErrors }));
return Object.keys(nextErrors).length === 0;
}
function handleFormBlur(key: ToastKey, form: HTMLFormElement) {
validateForm(form, key);
}
function fieldError(key: ToastKey, name: string) {
const message = formErrors[key][name];
return message ? <span className="field-error">{message}</span> : null;
}
function actionError(key: ToastKey) {
return actionErrors[key] ? <div className="inline-error">{actionErrors[key]}</div> : null;
}
async function submitOrder(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = event.currentTarget;
if (!validateForm(form, "order")) {
return;
}
const formData = new FormData(form);
try {
setActionErrors((current) => ({ ...current, order: null }));
await postJson("/api/production-orders", {
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")),
});
form.reset();
setFormErrors((current) => ({ ...current, order: {} }));
showToast("order", "Production order created.");
await loadAll();
} catch (error) {
setActionErrors((current) => ({ ...current, order: error instanceof Error ? error.message : "Order creation failed." }));
}
}
async function submitDowntimeQualification(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = event.currentTarget;
if (!validateForm(form, "downtime")) {
return;
}
const formData = new FormData(form);
try {
setActionErrors((current) => ({ ...current, downtime: null }));
await postJson(`/api/downtimes/${formData.get("downtime_id")}/qualify`, {
reason_code: formData.get("reason_code"),
comment: formData.get("comment"),
qualified_by: formData.get("qualified_by"),
});
form.reset();
setFormErrors((current) => ({ ...current, downtime: {} }));
showToast("downtime", "Downtime qualified.");
await loadAll();
if (selectedMachineId !== null) {
await loadDetail(selectedMachineId);
}
} catch (error) {
setActionErrors((current) => ({ ...current, downtime: error instanceof Error ? error.message : "Downtime qualification failed." }));
}
}
async function submitScrap(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!selectedMachine) {
setActionErrors((current) => ({ ...current, scrap: "Select a machine before declaring scrap." }));
return;
}
const form = event.currentTarget;
if (!validateForm(form, "scrap")) {
return;
}
const formData = new FormData(form);
try {
setActionErrors((current) => ({ ...current, scrap: null }));
await postJson("/api/scraps", {
machine_id: selectedMachine.code,
production_order_id: selectedMachine.active_order?.id ?? null,
quantity: Number(formData.get("quantity")),
reason_code: formData.get("reason_code"),
comment: formData.get("comment"),
operator_name: formData.get("operator_name"),
});
form.reset();
setFormErrors((current) => ({ ...current, scrap: {} }));
showToast("scrap", "Scrap declaration recorded.");
await loadAll();
} catch (error) {
setActionErrors((current) => ({ ...current, scrap: error instanceof Error ? error.message : "Scrap declaration failed." }));
}
}
async function changeOrderStatus(orderId: number, action: "start" | "pause" | "close") {
try {
setActionErrors((current) => ({ ...current, order: null }));
await postJson(`/api/production-orders/${orderId}/${action}`);
await loadAll();
} catch (error) {
setActionErrors((current) => ({ ...current, order: error instanceof Error ? error.message : "Order status change failed." }));
}
}
async function submitMachineConfig(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!selectedMachine) {
setActionErrors((current) => ({ ...current, config: "Select a machine before saving configuration." }));
return;
}
const form = event.currentTarget;
if (!validateForm(form, "config")) {
return;
}
const formData = new FormData(form);
try {
setActionErrors((current) => ({ ...current, config: null }));
await patchJson(`/api/machines/${selectedMachine.id}/config`, {
name: formData.get("name"),
brand: formData.get("brand"),
model: formData.get("model"),
is_active: formData.get("is_active") === "on",
cycle_signal_edge: formData.get("cycle_signal_edge"),
debounce_ms: Number(formData.get("debounce_ms")),
min_cycle_time_sec: Number(formData.get("min_cycle_time_sec")),
max_cycle_time_sec: Number(formData.get("max_cycle_time_sec")),
stop_detection_delay_sec: Number(formData.get("stop_detection_delay_sec")),
});
setFormErrors((current) => ({ ...current, config: {} }));
showToast("config", "Machine configuration saved.");
await loadAll();
await loadDetail(selectedMachine.id);
} catch (error) {
setActionErrors((current) => ({ ...current, config: error instanceof Error ? error.message : "Machine configuration failed." }));
}
}
function selectMachine(machineId: number, module: ModuleKey = activeModule) {
setSelectedMachineId(machineId);
setActiveModule(module);
if (module !== "atelier") {
setIsWorkshopMode(false);
}
}
function renderEventLabel(item: MachineEvent) {
if (item.event_type !== "digital_input_changed") {
return item.event_type;
}
const inputName = String(item.payload.input_name ?? "input");
const inputValue = String(item.payload.input_value ?? "");
return `${inputName} -> ${inputValue}`;
}
function toast(key: ToastKey) {
return toasts[key] ? <div className="inline-toast">{toasts[key]}</div> : null;
}
function renderModule(): ReactNode {
if (activeModule === "atelier") {
return (
<Panel
title="Dashboard Atelier"
subtitle="Machine status"
actions={
<div className="panel-actions">
<select value={machineFilter} onChange={(event) => setMachineFilter(event.target.value)} aria-label="Filter machines by status">
<option value="all">All statuses</option>
{machineStatuses.map((status) => (
<option key={status} value={status}>
{status}
</option>
))}
</select>
<select value={machineSort} onChange={(event) => setMachineSort(event.target.value)} aria-label="Sort machines">
<option value="code">Sort by code</option>
<option value="oee">Sort by OEE</option>
<option value="stops">Stops first</option>
</select>
<button type="button" onClick={() => setIsWorkshopMode((current) => !current)}>
{isWorkshopMode ? "Exit fullscreen" : "Fullscreen atelier"}
</button>
</div>
}
>
<div className="machine-grid">
{visibleMachines.map((machine) => (
<MachineCard
key={machine.id}
machine={machine}
selected={machine.id === selectedMachineId}
nowMs={nowMs}
onSelect={() => selectMachine(machine.id, "machine")}
onCreateOrder={() => selectMachine(machine.id, "orders")}
onDeclareScrap={() => selectMachine(machine.id, "scrap")}
onQualifyDowntime={() => selectMachine(machine.id, "downtimes")}
/>
))}
</div>
{visibleMachines.length === 0 ? <div className="empty-state">No machines match the current filter.</div> : null}
</Panel>
);
}
if (activeModule === "machine") {
return (
<Panel title="Detail Machine" subtitle={selectedMachine ? selectedMachine.code : "Select a machine"}>
{selectedMachine ? (
<div className="detail-stack">
<div className="metric-row">
<KpiBlock label="Status" value={<StatusBadge status={selectedMachine.status} />} />
<KpiBlock label="Current order" value={selectedMachine.active_order?.order_number ?? "None"} />
<KpiBlock label="Avg. cycle" value={`${selectedMachine.cycle_real_avg_sec.toFixed(1)} s`} />
<KpiBlock label="Daily OEE" value={`${Math.round(selectedMachine.today_oee.oee * 100)}%`} />
</div>
<div className="machine-context">
<div>
<span className="meta-label">Machine</span>
<strong>
{selectedMachine.brand} {selectedMachine.model}
</strong>
</div>
<div>
<span className="meta-label">Signal setup</span>
<strong>
{selectedMachine.config.cycle_signal_edge} edge / {selectedMachine.config.debounce_ms} ms /{" "}
{selectedMachine.config.stop_detection_delay_sec} s
</strong>
</div>
<div>
<span className="meta-label">Good parts</span>
<strong>{selectedMachine.produced_qty - selectedMachine.scrap_qty}</strong>
</div>
</div>
<section className="cycle-panel">
<div className="cycle-panel__header">
<h3>Recent cycles</h3>
<span>{detail.cycles.length} samples</span>
</div>
<CycleBarChart cycles={detail.cycles} theoreticalCycleTimeSec={selectedMachine.active_order?.theoretical_cycle_time_sec ?? 0} />
</section>
<div className="detail-columns">
<div>
<h3>Recent events</h3>
<ul className="list">
{detail.events.slice(0, 8).map((item) => (
<li key={item.id}>
<strong>{renderEventLabel(item)}</strong>
<span>{new Date(item.timestamp).toLocaleString()}</span>
</li>
))}
</ul>
</div>
<div>
<h3>Downtime history</h3>
<ul className="list">
{detail.downtimes.slice(0, 8).map((item) => (
<li key={item.id}>
<strong>{item.reason_code ?? "Waiting qualification"}</strong>
<span>{new Date(item.start_time).toLocaleString()}</span>
</li>
))}
</ul>
</div>
</div>
</div>
) : (
<p>No machine selected.</p>
)}
</Panel>
);
}
if (activeModule === "trs") {
return (
<Panel title="Vue TRS Journalier" subtitle={today}>
{dailyOee ? (
<div className="module-grid module-grid--split">
<div className="oee-grid">
<KpiBlock label="OEE" value={`${Math.round(dailyOee.overall.oee * 100)}%`} tone="dark" />
<KpiBlock label="Availability" value={`${Math.round(dailyOee.overall.availability * 100)}%`} />
<KpiBlock label="Performance" value={`${Math.round(dailyOee.overall.performance * 100)}%`} />
<KpiBlock label="Quality" value={`${Math.round(dailyOee.overall.quality * 100)}%`} />
</div>
<div className="oee-mini-list">
{machines.map((machine) => (
<button key={machine.id} type="button" className="oee-mini-list__item" onClick={() => selectMachine(machine.id, "machine")}>
<span>{machine.code}</span>
<strong>{Math.round(machine.today_oee.oee * 100)}%</strong>
</button>
))}
</div>
</div>
) : null}
</Panel>
);
}
if (activeModule === "orders") {
return (
<Panel title="Ordres de Fabrication" subtitle="Create and manage production orders">
<div className="module-grid module-grid--form-table">
<form
className="form-grid module-form"
onSubmit={submitOrder}
onBlurCapture={(event) => handleFormBlur("order", event.currentTarget)}
onInputCapture={(event) => validateForm(event.currentTarget, "order")}
onChangeCapture={(event) => validateForm(event.currentTarget, "order")}
noValidate
>
<input name="order_number" placeholder="OF number" required />
{fieldError("order", "order_number")}
<select name="machine_id" required defaultValue={selectedMachine?.id ?? ""}>
<option value="" disabled>
Machine
</option>
{machines.map((machine) => (
<option key={machine.id} value={machine.id}>
{machine.code}
</option>
))}
</select>
{fieldError("order", "machine_id")}
<input name="article_ref" placeholder="Article ref" required />
{fieldError("order", "article_ref")}
<input name="article_name" placeholder="Article name" required />
{fieldError("order", "article_name")}
<input name="mold_ref" placeholder="Mold ref" />
<input name="material_ref" placeholder="Material ref" />
<input name="planned_qty" type="number" min="1" placeholder="Planned qty" required />
{fieldError("order", "planned_qty")}
<input name="cavities" type="number" min="1" defaultValue="1" placeholder="Cavities" required />
{fieldError("order", "cavities")}
<input name="theoretical_cycle_time_sec" type="number" min="1" step="0.1" placeholder="Theoretical cycle" required />
{fieldError("order", "theoretical_cycle_time_sec")}
<button type="submit" className="primary-button" disabled={Object.keys(formErrors.order).length > 0}>
Create
</button>
{actionError("order")}
{toast("order")}
</form>
<div className="table-wrap">
<table>
<thead>
<tr>
<th>OF</th>
<th>Machine</th>
<th>Article</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>
<td>{order.article_name}</td>
<td>{order.status}</td>
<td className="table-actions">
<button type="button" onClick={() => changeOrderStatus(order.id, "start")}>
Start
</button>
<button type="button" onClick={() => changeOrderStatus(order.id, "pause")}>
Pause
</button>
<button type="button" onClick={() => changeOrderStatus(order.id, "close")}>
Close
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</Panel>
);
}
if (activeModule === "downtimes") {
return (
<Panel title="Qualification Arret" subtitle={`${openDowntimes.length} open downtime(s)`}>
<div className="module-grid module-grid--form-table">
<form
className="form-grid module-form"
onSubmit={submitDowntimeQualification}
onBlurCapture={(event) => handleFormBlur("downtime", event.currentTarget)}
onInputCapture={(event) => validateForm(event.currentTarget, "downtime")}
onChangeCapture={(event) => validateForm(event.currentTarget, "downtime")}
noValidate
>
<select name="downtime_id" required defaultValue="">
<option value="" disabled>
Open downtime
</option>
{openDowntimes.map((downtime) => (
<option key={downtime.id} value={downtime.id}>
#{downtime.id} machine {downtime.machine_id}
</option>
))}
</select>
{fieldError("downtime", "downtime_id")}
<select name="reason_code" required defaultValue="">
<option value="" disabled>
Reason
</option>
{references?.downtime_reasons.map((reason) => (
<option key={reason.code} value={reason.code}>
{reason.label}
</option>
))}
</select>
{fieldError("downtime", "reason_code")}
<input name="qualified_by" placeholder="Operator" required />
{fieldError("downtime", "qualified_by")}
<textarea name="comment" placeholder="Comment" rows={3} />
<button type="submit" className="primary-button" disabled={Object.keys(formErrors.downtime).length > 0}>
Qualify
</button>
{actionError("downtime")}
{toast("downtime")}
</form>
<ul className="timeline-list">
{openDowntimes.map((item) => (
<li key={item.id}>
<div>
<strong>Machine {item.machine_id}</strong>
<span>Waiting qualification</span>
</div>
<time>{new Date(item.start_time).toLocaleString()}</time>
</li>
))}
</ul>
{openDowntimes.length === 0 ? <div className="empty-state">No open downtime currently needs qualification.</div> : null}
</div>
</Panel>
);
}
if (activeModule === "scrap") {
return (
<Panel title="Saisie Rebut" subtitle={selectedMachine?.code ?? "Select machine"}>
<div className="module-grid module-grid--form-table">
<form
className="form-grid module-form"
onSubmit={submitScrap}
onBlurCapture={(event) => handleFormBlur("scrap", event.currentTarget)}
onInputCapture={(event) => validateForm(event.currentTarget, "scrap")}
onChangeCapture={(event) => validateForm(event.currentTarget, "scrap")}
noValidate
>
<input name="operator_name" placeholder="Operator" required />
{fieldError("scrap", "operator_name")}
<input name="quantity" type="number" min="1" placeholder="Quantity" required />
{fieldError("scrap", "quantity")}
<select name="reason_code" required defaultValue="">
<option value="" disabled>
Scrap reason
</option>
{references?.scrap_reasons.map((reason) => (
<option key={reason.code} value={reason.code}>
{reason.label}
</option>
))}
</select>
{fieldError("scrap", "reason_code")}
<textarea name="comment" placeholder="Comment" rows={3} />
<button type="submit" className="primary-button" disabled={!selectedMachine || Object.keys(formErrors.scrap).length > 0}>
Record
</button>
{actionError("scrap")}
{toast("scrap")}
</form>
<ul className="list">
{scraps.slice(0, 12).map((scrap) => (
<li key={scrap.id}>
<strong>
{scrap.quantity} pcs / {scrap.reason_code}
</strong>
<span>{new Date(scrap.timestamp).toLocaleString()}</span>
</li>
))}
</ul>
{scraps.length === 0 ? <div className="empty-state">No scrap has been declared yet.</div> : null}
</div>
</Panel>
);
}
return (
<Panel title="Config Machine" subtitle={selectedMachine?.code ?? "No machine selected"}>
{selectedMachine ? (
<form
className="form-grid config-form"
onSubmit={submitMachineConfig}
onBlurCapture={(event) => handleFormBlur("config", event.currentTarget)}
onInputCapture={(event) => validateForm(event.currentTarget, "config")}
onChangeCapture={(event) => validateForm(event.currentTarget, "config")}
key={selectedMachine.id}
noValidate
>
<input name="name" placeholder="Display name" defaultValue={selectedMachine.config.name} required />
{fieldError("config", "name")}
<input name="brand" placeholder="Brand" defaultValue={selectedMachine.config.brand ?? ""} />
<input name="model" placeholder="Model" defaultValue={selectedMachine.config.model ?? ""} />
<label className="checkbox-field">
<input name="is_active" type="checkbox" defaultChecked={selectedMachine.config.is_active} />
<span>Active on dashboard</span>
</label>
<select name="cycle_signal_edge" defaultValue={selectedMachine.config.cycle_signal_edge}>
<option value="rising">Rising edge</option>
<option value="falling">Falling edge</option>
</select>
<input name="debounce_ms" type="number" min="0" defaultValue={selectedMachine.config.debounce_ms} placeholder="Debounce ms" required />
{fieldError("config", "debounce_ms")}
<input
name="min_cycle_time_sec"
type="number"
min="1"
defaultValue={selectedMachine.config.min_cycle_time_sec}
placeholder="Min cycle sec"
required
/>
{fieldError("config", "min_cycle_time_sec")}
<input
name="max_cycle_time_sec"
type="number"
min="1"
defaultValue={selectedMachine.config.max_cycle_time_sec}
placeholder="Max cycle sec"
required
/>
{fieldError("config", "max_cycle_time_sec")}
<input
name="stop_detection_delay_sec"
type="number"
min="1"
defaultValue={selectedMachine.config.stop_detection_delay_sec}
placeholder="Stop delay sec"
required
/>
{fieldError("config", "stop_detection_delay_sec")}
<button type="submit" className="primary-button" disabled={Object.keys(formErrors.config).length > 0}>
Save
</button>
{actionError("config")}
{toast("config")}
</form>
) : (
<p>No machine selected.</p>
)}
</Panel>
);
}
return (
<div className={`app-shell ${isWorkshopMode && activeModule === "atelier" ? "app-shell--workshop" : ""}`}>
<main className="workspace">
<header className="hero">
<div>
<p className="eyebrow">Plast Track MVP</p>
<h1>Workshop supervision</h1>
</div>
<div className="hero__status">
<span className="hero__dot" aria-hidden="true" />
<strong>{connectionStatus}</strong>
{errorMessage ? <p>{errorMessage}</p> : null}
</div>
</header>
{connectionStatus === "Reconnecting" ? <div className="connection-banner">Reconnecting...</div> : null}
<section className="summary-ribbon" aria-label="Workshop summary">
<KpiBlock label="Global OEE" value={`${summaryMetrics.oee}%`} subLabel="Daily aggregate" tone="dark" />
<KpiBlock label="Production" value={summaryMetrics.production} subLabel={`${machines.length} machines monitored`} />
<KpiBlock label="Open stops" value={summaryMetrics.openDowntimes} subLabel="Awaiting qualification" />
<KpiBlock label="Output / scrap" value={`${summaryMetrics.producedQty} / ${summaryMetrics.scrapQty}`} subLabel="Pieces today" />
</section>
<nav className="module-tabs" aria-label="Modules">
{moduleTabs.map((tab) => (
<button
key={tab.key}
type="button"
className={`module-tabs__button ${activeModule === tab.key ? "is-active" : ""}`}
onClick={() => {
setActiveModule(tab.key);
if (tab.key !== "atelier") {
setIsWorkshopMode(false);
}
}}
>
<span>{tab.label}</span>
<small>{tab.description}</small>
</button>
))}
</nav>
<section className="module-view">{renderModule()}</section>
</main>
</div>
);
}