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>; 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(null); const [orders, setOrders] = useState([]); const [openDowntimes, setOpenDowntimes] = useState([]); const [scraps, setScraps] = useState([]); const [references, setReferences] = useState(null); const [dailyOee, setDailyOee] = useState(null); const [selectedMachineId, setSelectedMachineId] = useState(null); const [activeModule, setActiveModule] = useState("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({ events: [], cycles: [], downtimes: [] }); const [connectionStatus, setConnectionStatus] = useState("Connecting"); const [errorMessage, setErrorMessage] = useState(null); const [formErrors, setFormErrors] = useState(initialFormErrors); const [actionErrors, setActionErrors] = useState>({ config: null, order: null, downtime: null, scrap: null, }); const [toasts, setToasts] = useState>({ config: null, order: null, downtime: null, scrap: null, }); const refreshTimer = useRef(null); const toastTimers = useRef>({}); async function loadDashboard() { const snapshot = await fetchJson("/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("/api/production-orders"), fetchJson("/api/downtimes/open"), fetchJson("/api/scraps"), fetchJson("/api/reference-data"), fetchJson(`/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(`/api/machines/${machineId}/events`), fetchJson(`/api/machines/${machineId}/cycles`), fetchJson(`/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 = {}; 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 ? {message} : null; } function actionError(key: ToastKey) { return actionErrors[key] ?
{actionErrors[key]}
: null; } async function submitOrder(event: FormEvent) { 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) { 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) { 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) { 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] ?
{toasts[key]}
: null; } function renderModule(): ReactNode { if (activeModule === "atelier") { return ( } >
{visibleMachines.map((machine) => ( selectMachine(machine.id, "machine")} onCreateOrder={() => selectMachine(machine.id, "orders")} onDeclareScrap={() => selectMachine(machine.id, "scrap")} onQualifyDowntime={() => selectMachine(machine.id, "downtimes")} /> ))}
{visibleMachines.length === 0 ?
No machines match the current filter.
: null}
); } if (activeModule === "machine") { return ( {selectedMachine ? (
} />
Machine {selectedMachine.brand} {selectedMachine.model}
Signal setup {selectedMachine.config.cycle_signal_edge} edge / {selectedMachine.config.debounce_ms} ms /{" "} {selectedMachine.config.stop_detection_delay_sec} s
Good parts {selectedMachine.produced_qty - selectedMachine.scrap_qty}

Recent cycles

{detail.cycles.length} samples

Recent events

    {detail.events.slice(0, 8).map((item) => (
  • {renderEventLabel(item)} {new Date(item.timestamp).toLocaleString()}
  • ))}

Downtime history

    {detail.downtimes.slice(0, 8).map((item) => (
  • {item.reason_code ?? "Waiting qualification"} {new Date(item.start_time).toLocaleString()}
  • ))}
) : (

No machine selected.

)}
); } if (activeModule === "trs") { return ( {dailyOee ? (
{machines.map((machine) => ( ))}
) : null}
); } if (activeModule === "orders") { return (
handleFormBlur("order", event.currentTarget)} onInputCapture={(event) => validateForm(event.currentTarget, "order")} onChangeCapture={(event) => validateForm(event.currentTarget, "order")} noValidate > {fieldError("order", "order_number")} {fieldError("order", "machine_id")} {fieldError("order", "article_ref")} {fieldError("order", "article_name")} {fieldError("order", "planned_qty")} {fieldError("order", "cavities")} {fieldError("order", "theoretical_cycle_time_sec")} {actionError("order")} {toast("order")}
{orders.map((order) => ( ))}
OF Machine Article Status Actions
{order.order_number} {order.machine_id} {order.article_name} {order.status}
); } if (activeModule === "downtimes") { return (
handleFormBlur("downtime", event.currentTarget)} onInputCapture={(event) => validateForm(event.currentTarget, "downtime")} onChangeCapture={(event) => validateForm(event.currentTarget, "downtime")} noValidate > {fieldError("downtime", "downtime_id")} {fieldError("downtime", "reason_code")} {fieldError("downtime", "qualified_by")}