Files
plast-track/frontend/src/App.tsx
2026-06-17 14:08:14 +01:00

1199 lines
54 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;
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 (
<label className="form-field">
<span className="input-label">{label}</span>
{children}
{error}
</label>
);
}
export default function App() {
const [showSplash, setShowSplash] = useState(true);
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 [language, setLanguage] = useState<Language>(() => (localStorage.getItem("plast-track-language") as Language | null) ?? "en");
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>>({});
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<DashboardSnapshot>("/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<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);
}
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(() => {
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<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 }));
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<HTMLFormElement>) {
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<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", 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<HTMLFormElement>) {
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<HTMLFormElement>) {
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] ? <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 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 (
<Panel
title={t("Workshop dashboard", "Tableau atelier")}
subtitle={t("Machine status", "Etat machines")}
actions={
<div className="panel-actions">
<select value={machineFilter} onChange={(event) => setMachineFilter(event.target.value)} aria-label={t("Filter machines by status", "Filtrer les machines par etat")}>
<option value="all">{t("All statuses", "Tous les etats")}</option>
{machineStatuses.map((status) => (
<option key={status} value={status}>
{status}
</option>
))}
</select>
<select value={machineSort} onChange={(event) => setMachineSort(event.target.value)} aria-label={t("Sort machines", "Trier les machines")}>
<option value="code">{t("Sort by code", "Trier par code")}</option>
<option value="oee">{t("Sort by OEE", "Trier par TRS")}</option>
<option value="stops">{t("Stops first", "Arrets en premier")}</option>
</select>
<button type="button" onClick={() => setIsWorkshopMode((current) => !current)}>
{isWorkshopMode ? t("Exit fullscreen", "Quitter plein ecran") : t("Fullscreen workshop", "Atelier plein ecran")}
</button>
</div>
}
>
<div className="machine-grid">
{visibleMachines.map((machine) => (
<MachineCard
key={machine.id}
machine={machine}
selected={machine.id === selectedMachineId}
nowMs={nowMs}
language={language}
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">{t("No machines match the current filter.", "Aucune machine ne correspond au filtre.")}</div> : null}
</Panel>
);
}
if (activeModule === "machine") {
return (
<Panel title={t("Machine detail", "Detail machine")} subtitle={selectedMachine ? selectedMachine.code : t("Select a machine", "Selectionner une machine")}>
{selectedMachine ? (
<div className="detail-stack">
<div className="metric-row">
<KpiBlock label={t("Status", "Etat")} value={<StatusBadge status={selectedMachine.status} language={language} />} />
<KpiBlock label={t("Current order", "OF courant")} value={selectedMachine.active_order?.order_number ?? t("None", "Aucun")} />
<KpiBlock label={t("Avg. cycle", "Cycle moyen")} value={`${selectedMachine.cycle_real_avg_sec.toFixed(1)} s`} />
<KpiBlock label={t("Daily OEE", "TRS jour")} value={`${Math.round(selectedMachine.today_oee.oee * 100)}%`} />
</div>
<div className="machine-context">
<div>
<span className="meta-label">{t("Machine", "Machine")}</span>
<strong>
{selectedMachine.brand} {selectedMachine.model}
</strong>
</div>
<div>
<span className="meta-label">{t("Signal setup", "Parametrage signal")}</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">{t("Good parts", "Pieces bonnes")}</span>
<strong>{selectedMachine.produced_qty - selectedMachine.scrap_qty}</strong>
</div>
</div>
<section className="cycle-panel">
<div className="cycle-panel__header">
<h3>{t("Recent cycles", "Cycles recents")}</h3>
<span>{detail.cycles.length} {t("samples", "echantillons")}</span>
</div>
<CycleBarChart cycles={detail.cycles} theoreticalCycleTimeSec={selectedMachine.active_order?.theoretical_cycle_time_sec ?? 0} />
</section>
<div className="detail-columns">
<div>
<h3>{t("Recent events", "Evenements recents")}</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>{t("Downtime history", "Historique arrets")}</h3>
<ul className="list">
{detail.downtimes.slice(0, 8).map((item) => (
<li key={item.id}>
<strong>{downtimeStateLabel(item)}</strong>
<span>
{new Date(item.start_time).toLocaleString()}
{item.end_time ? ` -> ${new Date(item.end_time).toLocaleString()}` : ""}
</span>
</li>
))}
</ul>
</div>
</div>
</div>
) : (
<p>{t("No machine selected.", "Aucune machine selectionnee.")}</p>
)}
</Panel>
);
}
if (activeModule === "trs") {
return (
<Panel title={t("Daily OEE view", "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={t("Availability", "Disponibilite")} value={`${Math.round(dailyOee.overall.availability * 100)}%`} />
<KpiBlock label={t("Performance", "Performance")} value={`${Math.round(dailyOee.overall.performance * 100)}%`} />
<KpiBlock label={t("Quality", "Qualite")} 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={t("Production orders", "Ordres de fabrication")}
subtitle={selectedOrder ? `${t("Selected", "Selectionne")} ${selectedOrder.order_number}` : t("Create and manage production orders", "Creer et gerer les ordres de fabrication")}
>
<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
>
<FieldLabel label={t("OF number", "Numero OF")} error={fieldError("order", "order_number")}>
<input name="order_number" placeholder="OF-2001" required />
</FieldLabel>
<FieldLabel label={t("Machine", "Machine")} error={fieldError("order", "machine_id")}>
<select name="machine_id" required defaultValue={selectedMachine?.id ?? ""}>
<option value="" disabled>
{t("Select machine", "Selectionner machine")}
</option>
{machines.map((machine) => (
<option key={machine.id} value={machine.id}>
{machine.code}
</option>
))}
</select>
</FieldLabel>
<FieldLabel label={t("Article reference", "Reference article")} error={fieldError("order", "article_ref")}>
<input name="article_ref" placeholder="ART-001" required />
</FieldLabel>
<FieldLabel label={t("Article name", "Nom article")} error={fieldError("order", "article_name")}>
<input name="article_name" placeholder={t("Part name", "Nom piece")} required />
</FieldLabel>
<FieldLabel label={t("Mold reference", "Reference moule")}>
<input name="mold_ref" placeholder="M-001" />
</FieldLabel>
<FieldLabel label={t("Material reference", "Reference matiere")}>
<input name="material_ref" placeholder="PP" />
</FieldLabel>
<FieldLabel label={t("Planned quantity", "Quantite prevue")} error={fieldError("order", "planned_qty")}>
<input name="planned_qty" type="number" min="1" placeholder="1000" required />
</FieldLabel>
<FieldLabel label={t("Cavities", "Empreintes")} error={fieldError("order", "cavities")}>
<input name="cavities" type="number" min="1" defaultValue="1" required />
</FieldLabel>
<FieldLabel label={t("Theoretical cycle time", "Temps de cycle theorique")} error={fieldError("order", "theoretical_cycle_time_sec")}>
<input name="theoretical_cycle_time_sec" type="number" min="1" step="0.1" placeholder="20" required />
</FieldLabel>
<button type="submit" className="primary-button" disabled={Object.keys(formErrors.order).length > 0}>
{t("Create", "Creer")}
</button>
{actionError("order")}
{toast("order")}
</form>
<div className="table-wrap">
<table>
<thead>
<tr>
<th>{t("OF", "OF")}</th>
<th>{t("Machine", "Machine")}</th>
<th>{t("Article", "Article")}</th>
<th>{t("Planned", "Prevu")}</th>
<th>{t("Status", "Etat")}</th>
<th>{t("Actions", "Actions")}</th>
</tr>
</thead>
<tbody>
{orders.map((order) => (
<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")}>
{t("Start", "Demarrer")}
</button>
<button type="button" onClick={() => changeOrderStatus(order.id, "pause")}>
{t("Pause", "Pause")}
</button>
<button type="button" onClick={() => changeOrderStatus(order.id, "close")}>
{t("Close", "Cloturer")}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{selectedOrder ? (
<section className="order-detail">
<div className="order-detail__header">
<div>
<span className="meta-label">{t("OF detail", "Detail OF")}</span>
<h3>{selectedOrder.order_number}</h3>
</div>
<StatusBadge status={selectedOrder.status} language={language} />
</div>
<div className="order-detail__meta">
<div>
<span className="meta-label">{t("Machine", "Machine")}</span>
<strong>{orderMachineLabel(selectedOrder)}</strong>
</div>
<div>
<span className="meta-label">{t("Article", "Article")}</span>
<strong>{selectedOrder.article_ref} / {selectedOrder.article_name}</strong>
</div>
<div>
<span className="meta-label">{t("Cycle / cavities", "Cycle / empreintes")}</span>
<strong>{selectedOrder.theoretical_cycle_time_sec} s / {selectedOrder.cavities}</strong>
</div>
<div>
<span className="meta-label">{t("Dates", "Dates")}</span>
<strong>
{selectedOrder.started_at ? new Date(selectedOrder.started_at).toLocaleString() : t("Not started", "Non demarre")}
{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
>
<FieldLabel label={t("OF number", "Numero OF")}>
<input name="order_number" defaultValue={selectedOrder.order_number} required />
</FieldLabel>
<FieldLabel label={t("Machine", "Machine")}>
<select name="machine_id" required defaultValue={selectedOrder.machine_id}>
{machines.map((machine) => (
<option key={machine.id} value={machine.id}>
{machine.code}
</option>
))}
</select>
</FieldLabel>
<FieldLabel label={t("Article reference", "Reference article")}>
<input name="article_ref" defaultValue={selectedOrder.article_ref} required />
</FieldLabel>
<FieldLabel label={t("Article name", "Nom article")}>
<input name="article_name" defaultValue={selectedOrder.article_name} required />
</FieldLabel>
<FieldLabel label={t("Mold reference", "Reference moule")}>
<input name="mold_ref" defaultValue={selectedOrder.mold_ref ?? ""} />
</FieldLabel>
<FieldLabel label={t("Material reference", "Reference matiere")}>
<input name="material_ref" defaultValue={selectedOrder.material_ref ?? ""} />
</FieldLabel>
<FieldLabel label={t("Planned quantity", "Quantite prevue")}>
<input name="planned_qty" type="number" min="1" defaultValue={selectedOrder.planned_qty} required />
</FieldLabel>
<FieldLabel label={t("Cavities", "Empreintes")}>
<input name="cavities" type="number" min="1" defaultValue={selectedOrder.cavities} required />
</FieldLabel>
<FieldLabel label={t("Theoretical cycle time", "Temps de cycle theorique")}>
<input name="theoretical_cycle_time_sec" type="number" min="1" step="0.1" defaultValue={selectedOrder.theoretical_cycle_time_sec} required />
</FieldLabel>
<button type="submit" className="primary-button" disabled={Object.keys(formErrors.order).length > 0}>
{t("Update OF", "Modifier OF")}
</button>
{actionError("order")}
{toast("order")}
</form>
</section>
) : (
<div className="empty-state">{t("Select an OF to see and update details.", "Selectionner un OF pour voir et modifier les details.")}</div>
)}
</div>
</Panel>
);
}
if (activeModule === "downtimes") {
return (
<Panel
title={t("Stop qualification", "Qualification arret")}
subtitle={
selectedMachine
? `${selectedMachineUnqualifiedDowntimes.length} ${t("unqualified for", "non qualifies pour")} ${selectedMachine.code} / ${unqualifiedDowntimes.length} ${t("total", "total")}`
: `${unqualifiedDowntimes.length} ${t("unqualified downtime(s)", "arret(s) non qualifies")}`
}
>
<div className="module-grid module-grid--form-table">
<form
className="form-grid module-form"
key={selectedMachine?.id ?? "all-downtimes"}
onSubmit={submitDowntimeQualification}
onBlurCapture={(event) => handleFormBlur("downtime", event.currentTarget)}
onInputCapture={(event) => validateForm(event.currentTarget, "downtime")}
onChangeCapture={(event) => validateForm(event.currentTarget, "downtime")}
noValidate
>
<FieldLabel label={t("Downtime to qualify", "Arret a qualifier")} error={fieldError("downtime", "downtime_id")}>
<select name="downtime_id" required defaultValue={selectedMachineUnqualifiedDowntimes[0]?.id ?? ""}>
<option value="" disabled>
{t("Select downtime", "Selectionner arret")}
</option>
{downtimeOptions.map((downtime) => (
<option key={downtime.id} value={downtime.id}>
{downtimeOptionLabel(downtime)}
</option>
))}
</select>
</FieldLabel>
<FieldLabel label={t("Reason", "Cause")} error={fieldError("downtime", "reason_code")}>
<select name="reason_code" required defaultValue="">
<option value="" disabled>
{t("Select reason", "Selectionner cause")}
</option>
{references?.downtime_reasons.map((reason) => (
<option key={reason.code} value={reason.code}>
{reason.label}
</option>
))}
</select>
</FieldLabel>
<FieldLabel label={t("Qualified by", "Qualifie par")} error={fieldError("downtime", "qualified_by")}>
<input name="qualified_by" placeholder={t("Operator name", "Nom operateur")} required />
</FieldLabel>
<FieldLabel label={t("Comment", "Commentaire")}>
<textarea name="comment" placeholder={t("Optional comment", "Commentaire optionnel")} rows={3} />
</FieldLabel>
<button type="submit" className="primary-button" disabled={Object.keys(formErrors.downtime).length > 0}>
{t("Qualify", "Qualifier")}
</button>
{actionError("downtime")}
{toast("downtime")}
</form>
<div className="downtime-board">
{selectedMachine ? (
<section className="downtime-focus">
<span className="meta-label">{t("Selected machine", "Machine selectionnee")}</span>
<strong>{selectedMachine.code}</strong>
{selectedMachineUnqualifiedDowntimes.length > 0 ? (
<ul className="timeline-list timeline-list--compact">
{selectedMachineUnqualifiedDowntimes.map((item) => (
<li key={item.id}>
<div>
<strong>{downtimeOptionLabel(item)}</strong>
<span>{downtimeStateLabel(item)}</span>
</div>
<time>{new Date(item.start_time).toLocaleString()}</time>
</li>
))}
</ul>
) : (
<div className="empty-state">{t("No unqualified downtime for", "Aucun arret non qualifie pour")} {selectedMachine.code}.</div>
)}
</section>
) : null}
<section>
<span className="meta-label">{t("All stops waiting qualification", "Tous les arrets en attente de qualification")}</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} / ` : ""}
{downtimeStateLabel(item)}
</span>
</div>
<time>{new Date(item.start_time).toLocaleString()}</time>
</li>
))}
</ul>
</section>
<section>
<span className="meta-label">{t("Qualified stops history", "Historique arrets qualifies")}</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 ? `${t("By", "Par")} ${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">{t("No qualified downtime history yet.", "Aucun historique d'arret qualifie.")}</div> : null}
</section>
</div>
{unqualifiedDowntimes.length === 0 ? <div className="empty-state">{t("No downtime currently needs qualification.", "Aucun arret a qualifier actuellement.")}</div> : null}
</div>
</Panel>
);
}
if (activeModule === "scrap") {
return (
<Panel title={t("Scrap declaration", "Saisie rebut")} subtitle={selectedMachine?.code ?? t("Select machine", "Selectionner 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
>
<FieldLabel label={t("Operator", "Operateur")} error={fieldError("scrap", "operator_name")}>
<input name="operator_name" placeholder={t("Operator name", "Nom operateur")} required />
</FieldLabel>
<FieldLabel label={t("Quantity", "Quantite")} error={fieldError("scrap", "quantity")}>
<input name="quantity" type="number" min="1" placeholder="1" required />
</FieldLabel>
<FieldLabel label={t("Scrap reason", "Cause rebut")} error={fieldError("scrap", "reason_code")}>
<select name="reason_code" required defaultValue="">
<option value="" disabled>
{t("Select scrap reason", "Selectionner cause rebut")}
</option>
{references?.scrap_reasons.map((reason) => (
<option key={reason.code} value={reason.code}>
{reason.label}
</option>
))}
</select>
</FieldLabel>
<FieldLabel label={t("Comment", "Commentaire")}>
<textarea name="comment" placeholder={t("Optional comment", "Commentaire optionnel")} rows={3} />
</FieldLabel>
<button type="submit" className="primary-button" disabled={!selectedMachine || Object.keys(formErrors.scrap).length > 0}>
{t("Record", "Enregistrer")}
</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">{t("No scrap has been declared yet.", "Aucun rebut declare pour le moment.")}</div> : null}
</div>
</Panel>
);
}
return (
<Panel title={t("Machine config", "Config machine")} subtitle={selectedMachine?.code ?? t("No machine selected", "Aucune machine selectionnee")}>
{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
>
<FieldLabel label={t("Display name", "Nom affiche")} error={fieldError("config", "name")}>
<input name="name" defaultValue={selectedMachine.config.name} required />
</FieldLabel>
<FieldLabel label={t("Brand", "Marque")}>
<input name="brand" defaultValue={selectedMachine.config.brand ?? ""} />
</FieldLabel>
<FieldLabel label={t("Model", "Modele")}>
<input name="model" defaultValue={selectedMachine.config.model ?? ""} />
</FieldLabel>
<label className="checkbox-field">
<input name="is_active" type="checkbox" defaultChecked={selectedMachine.config.is_active} />
<span>{t("Active on dashboard", "Active sur tableau de bord")}</span>
</label>
<FieldLabel label={t("Cycle signal edge", "Front signal cycle")}>
<select name="cycle_signal_edge" defaultValue={selectedMachine.config.cycle_signal_edge}>
<option value="rising">{t("Rising edge", "Front montant")}</option>
<option value="falling">{t("Falling edge", "Front descendant")}</option>
</select>
</FieldLabel>
<FieldLabel label={t("Debounce", "Anti-rebond")} error={fieldError("config", "debounce_ms")}>
<input name="debounce_ms" type="number" min="0" defaultValue={selectedMachine.config.debounce_ms} required />
</FieldLabel>
<FieldLabel label={t("Minimum cycle time", "Temps cycle minimum")} error={fieldError("config", "min_cycle_time_sec")}>
<input name="min_cycle_time_sec" type="number" min="1" defaultValue={selectedMachine.config.min_cycle_time_sec} required />
</FieldLabel>
<FieldLabel label={t("Maximum cycle time", "Temps cycle maximum")} error={fieldError("config", "max_cycle_time_sec")}>
<input name="max_cycle_time_sec" type="number" min="1" defaultValue={selectedMachine.config.max_cycle_time_sec} required />
</FieldLabel>
<FieldLabel label={t("Stop detection delay", "Delai detection arret")} error={fieldError("config", "stop_detection_delay_sec")}>
<input name="stop_detection_delay_sec" type="number" min="1" defaultValue={selectedMachine.config.stop_detection_delay_sec} required />
</FieldLabel>
<button type="submit" className="primary-button" disabled={Object.keys(formErrors.config).length > 0}>
{t("Save", "Enregistrer")}
</button>
{actionError("config")}
{toast("config")}
</form>
) : (
<p>{t("No machine selected.", "Aucune machine selectionnee.")}</p>
)}
</Panel>
);
}
if (showSplash) {
return (
<div className="splash-screen" aria-label="Plast Track">
<img src="/KSS.png" alt="KSS" />
</div>
);
}
return (
<div className={`app-shell ${isWorkshopMode && activeModule === "atelier" ? "app-shell--workshop" : ""}`}>
<main className="workspace">
<aside className="demo-notice" role="note">
<strong>Simulation et demonstration uniquement</strong>
</aside>
<header className="hero">
<div className="brand-lockup">
<img className="brand-lockup__logo" src="/KSS.png" alt="KSS" />
<div>
<p className="eyebrow">Plast Track MVP</p>
<h1>{t("Workshop supervision", "Supervision atelier")}</h1>
</div>
</div>
<div className="hero__controls">
<label className="language-select">
<span>{t("Language", "Langue")}</span>
<select value={language} onChange={(event) => setLanguage(event.target.value as Language)}>
<option value="en">English</option>
<option value="fr">Francais</option>
</select>
</label>
<div className="hero__status">
<span className="hero__dot" aria-hidden="true" />
<strong>{connectionLabel(connectionStatus)}</strong>
{errorMessage ? <p>{errorMessage}</p> : null}
</div>
</div>
</header>
{connectionStatus === "Reconnecting" ? <div className="connection-banner">{t("Reconnecting...", "Reconnexion...")}</div> : null}
<section className="summary-ribbon" aria-label={t("Workshop summary", "Resume atelier")}>
<KpiBlock label={t("Global OEE", "TRS global")} value={`${summaryMetrics.oee}%`} subLabel={t("Daily aggregate", "Agregat journalier")} tone="dark" />
<KpiBlock label={t("Production", "Production")} value={summaryMetrics.production} subLabel={`${machines.length} ${t("machines monitored", "machines supervisees")}`} />
<KpiBlock label={t("Open stops", "Arrets ouverts")} value={summaryMetrics.openDowntimes} subLabel={`${summaryMetrics.unqualifiedDowntimes} ${t("unqualified", "non qualifies")}`} />
<KpiBlock label={t("Output / scrap", "Production / rebut")} value={`${summaryMetrics.producedQty} / ${summaryMetrics.scrapQty}`} subLabel={t("Pieces today", "Pieces du jour")} />
</section>
<nav className="module-tabs" aria-label={t("Modules", "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>
);
}