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; type Language = "en" | "fr"; const today = new Date().toISOString().slice(0, 10); const initialFormErrors: FormErrors = { config: {}, order: {}, downtime: {}, scrap: {}, }; function FieldLabel({ label, error, children }: { label: string; error?: ReactNode; children: ReactNode }) { return ( ); } export default function App() { const [showSplash, setShowSplash] = useState(true); const [dashboard, setDashboard] = useState(null); const [orders, setOrders] = useState([]); const [unqualifiedDowntimes, setUnqualifiedDowntimes] = useState([]); const [qualifiedDowntimes, setQualifiedDowntimes] = useState([]); const [scraps, setScraps] = useState([]); const [references, setReferences] = useState(null); const [dailyOee, setDailyOee] = useState(null); const [selectedMachineId, setSelectedMachineId] = useState(null); const [selectedOrderId, setSelectedOrderId] = useState(null); const [language, setLanguage] = useState(() => (localStorage.getItem("plast-track-language") as Language | null) ?? "en"); 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>({}); function t(en: string, fr: string) { return language === "fr" ? fr : en; } useEffect(() => { const timer = window.setTimeout(() => setShowSplash(false), 900); return () => window.clearTimeout(timer); }, []); const moduleTabs: Array<{ key: ModuleKey; label: string; description: string }> = [ { key: "atelier", label: t("Workshop", "Atelier"), description: t("Machines view", "Vue machines") }, { key: "machine", label: "Machine", description: t("Cycles and events", "Cycles et evenements") }, { key: "trs", label: "OEE/TRS", description: t("Daily performance", "Performance jour") }, { key: "orders", label: t("Orders", "OF"), description: t("Production orders", "Ordres fabrication") }, { key: "downtimes", label: t("Stops", "Arrets"), description: "Qualification" }, { key: "scrap", label: t("Scrap", "Rebuts"), description: t("Declaration", "Declaration") }, { key: "config", label: "Config", description: t("Signal setup", "Parametrage signal") }, ]; 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, loadedUnqualifiedDowntimes, loadedQualifiedDowntimes, loadedScraps, loadedReferences, loadedDailyOee] = await Promise.all([ fetchJson("/api/production-orders"), fetchJson("/api/downtimes/unqualified"), fetchJson("/api/downtimes/qualified"), fetchJson("/api/scraps"), fetchJson("/api/reference-data"), fetchJson(`/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); } 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(() => { localStorage.setItem("plast-track-language", language); }, [language]); 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 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); const downtimeOptions = [...unqualifiedDowntimes].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); } if ((left.end_time === null) !== (right.end_time === null)) { return Number(right.end_time === null) - Number(left.end_time === null); } return new Date(left.start_time).getTime() - new Date(right.start_time).getTime(); }); const summaryMetrics = { production: machines.filter((machine) => machine.status === "production").length, openDowntimes: openDowntimeCount, unqualifiedDowntimes: unqualifiedDowntimes.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 })); 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"), 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(); setSelectedOrderId(createdOrder.id); setFormErrors((current) => ({ ...current, order: {} })); showToast("order", t("Production order created.", "Ordre de fabrication cree.")); await loadAll(); } catch (error) { setActionErrors((current) => ({ ...current, order: error instanceof Error ? error.message : t("Order creation failed.", "Creation OF echouee.") })); } } async function submitOrderUpdate(event: FormEvent) { event.preventDefault(); if (!selectedOrder) { setActionErrors((current) => ({ ...current, order: t("Select an OF before updating.", "Selectionner un OF avant modification.") })); 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", t("Production order updated.", "Ordre de fabrication modifie.")); await loadAll(); } catch (error) { setActionErrors((current) => ({ ...current, order: error instanceof Error ? error.message : t("Order update failed.", "Modification OF echouee.") })); } } 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", t("Downtime qualified.", "Arret qualifie.")); await loadAll(); if (selectedMachineId !== null) { await loadDetail(selectedMachineId); } } catch (error) { setActionErrors((current) => ({ ...current, downtime: error instanceof Error ? error.message : t("Downtime qualification failed.", "Qualification arret echouee.") })); } } async function submitScrap(event: FormEvent) { event.preventDefault(); if (!selectedMachine) { setActionErrors((current) => ({ ...current, scrap: t("Select a machine before declaring scrap.", "Selectionner une machine avant de declarer le rebut.") })); 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", t("Scrap declaration recorded.", "Declaration rebut enregistree.")); await loadAll(); } catch (error) { setActionErrors((current) => ({ ...current, scrap: error instanceof Error ? error.message : t("Scrap declaration failed.", "Declaration rebut echouee.") })); } } 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 : t("Order status change failed.", "Changement statut OF echoue.") })); } } async function submitMachineConfig(event: FormEvent) { event.preventDefault(); if (!selectedMachine) { setActionErrors((current) => ({ ...current, config: t("Select a machine before saving configuration.", "Selectionner une machine avant d'enregistrer la 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", t("Machine configuration saved.", "Configuration machine enregistree.")); await loadAll(); await loadDetail(selectedMachine.id); } catch (error) { setActionErrors((current) => ({ ...current, config: error instanceof Error ? error.message : t("Machine configuration failed.", "Configuration machine echouee.") })); } } 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 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 state = downtime.end_time === null ? t("Open", "Ouvert") : t("Ended", "Termine"); const parts = [`#${downtime.id}`, state, downtimeMachineLabel(downtime)]; if (downtime.order_number) { parts.push(downtime.order_number); } return parts.join(" / "); } function downtimeStateLabel(downtime: Downtime) { if (downtime.reason_code) { return downtime.reason_code; } return downtime.end_time === null ? t("Open - waiting qualification", "Ouvert - en attente de qualification") : t("Ended - waiting qualification", "Termine - en attente de qualification"); } function formatDurationSeconds(durationSec: number | null) { if (durationSec === null) { return t("In progress", "En cours"); } 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 connectionLabel(status: string) { if (status === "Connecting") { return t("Connecting", "Connexion"); } if (status === "Load failed") { return t("Load failed", "Chargement echoue"); } if (status === "Live") { return t("Live", "Direct"); } if (status === "Reconnecting") { return t("Reconnecting", "Reconnexion"); } return status; } 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 ?
{t("No machines match the current filter.", "Aucune machine ne correspond au filtre.")}
: null}
); } if (activeModule === "machine") { return ( {selectedMachine ? (
} />
{t("Machine", "Machine")} {selectedMachine.brand} {selectedMachine.model}
{t("Signal setup", "Parametrage signal")} {selectedMachine.config.cycle_signal_edge} edge / {selectedMachine.config.debounce_ms} ms /{" "} {selectedMachine.config.stop_detection_delay_sec} s
{t("Good parts", "Pieces bonnes")} {selectedMachine.produced_qty - selectedMachine.scrap_qty}

{t("Recent cycles", "Cycles recents")}

{detail.cycles.length} {t("samples", "echantillons")}

{t("Recent events", "Evenements recents")}

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

{t("Downtime history", "Historique arrets")}

    {detail.downtimes.slice(0, 8).map((item) => (
  • {downtimeStateLabel(item)} {new Date(item.start_time).toLocaleString()} {item.end_time ? ` -> ${new Date(item.end_time).toLocaleString()}` : ""}
  • ))}
) : (

{t("No machine selected.", "Aucune machine selectionnee.")}

)}
); } 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 > {actionError("order")} {toast("order")}
{orders.map((order) => ( ))}
{t("OF", "OF")} {t("Machine", "Machine")} {t("Article", "Article")} {t("Planned", "Prevu")} {t("Status", "Etat")} {t("Actions", "Actions")}
{orderMachineLabel(order)} {order.article_name} {order.planned_qty} {order.status}
{selectedOrder ? (
{t("OF detail", "Detail OF")}

{selectedOrder.order_number}

{t("Machine", "Machine")} {orderMachineLabel(selectedOrder)}
{t("Article", "Article")} {selectedOrder.article_ref} / {selectedOrder.article_name}
{t("Cycle / cavities", "Cycle / empreintes")} {selectedOrder.theoretical_cycle_time_sec} s / {selectedOrder.cavities}
{t("Dates", "Dates")} {selectedOrder.started_at ? new Date(selectedOrder.started_at).toLocaleString() : t("Not started", "Non demarre")} {selectedOrder.ended_at ? ` -> ${new Date(selectedOrder.ended_at).toLocaleString()}` : ""}
handleFormBlur("order", event.currentTarget)} onInputCapture={(event) => validateForm(event.currentTarget, "order")} onChangeCapture={(event) => validateForm(event.currentTarget, "order")} noValidate > {actionError("order")} {toast("order")}
) : (
{t("Select an OF to see and update details.", "Selectionner un OF pour voir et modifier les details.")}
)}
); } if (activeModule === "downtimes") { return (
handleFormBlur("downtime", event.currentTarget)} onInputCapture={(event) => validateForm(event.currentTarget, "downtime")} onChangeCapture={(event) => validateForm(event.currentTarget, "downtime")} noValidate >