Initial Plast Track MVP
This commit is contained in:
857
frontend/src/App.tsx
Normal file
857
frontend/src/App.tsx
Normal file
@@ -0,0 +1,857 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
51
frontend/src/components/CycleBarChart.tsx
Normal file
51
frontend/src/components/CycleBarChart.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Cycle } from "../lib/types";
|
||||
|
||||
type CycleBarChartProps = {
|
||||
cycles: Cycle[];
|
||||
theoreticalCycleTimeSec?: number;
|
||||
};
|
||||
|
||||
export default function CycleBarChart({ cycles, theoreticalCycleTimeSec = 0 }: CycleBarChartProps) {
|
||||
const visibleCycles = cycles.slice(0, 24).reverse();
|
||||
const maxCycle = Math.max(theoreticalCycleTimeSec, ...visibleCycles.map((cycle) => cycle.cycle_time_sec), 1);
|
||||
const chartWidth = 480;
|
||||
const chartHeight = 150;
|
||||
const padding = 18;
|
||||
const gap = 4;
|
||||
const usableHeight = chartHeight - padding * 2;
|
||||
const barWidth = visibleCycles.length > 0 ? (chartWidth - padding * 2 - gap * (visibleCycles.length - 1)) / visibleCycles.length : 0;
|
||||
const referenceY = chartHeight - padding - (Math.min(theoreticalCycleTimeSec, maxCycle) / maxCycle) * usableHeight;
|
||||
|
||||
if (visibleCycles.length === 0) {
|
||||
return <div className="cycle-chart cycle-chart--empty">No cycle data yet</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<svg className="cycle-chart" viewBox={`0 0 ${chartWidth} ${chartHeight}`} role="img" aria-label="Recent cycle time trend">
|
||||
{theoreticalCycleTimeSec > 0 ? (
|
||||
<line className="cycle-chart__reference" x1={padding} y1={referenceY} x2={chartWidth - padding} y2={referenceY} />
|
||||
) : null}
|
||||
{visibleCycles.map((cycle, index) => {
|
||||
const barHeight = Math.max((cycle.cycle_time_sec / maxCycle) * usableHeight, 6);
|
||||
const x = padding + index * (barWidth + gap);
|
||||
const y = chartHeight - padding - barHeight;
|
||||
const isSlow = theoreticalCycleTimeSec > 0 && cycle.cycle_time_sec > theoreticalCycleTimeSec * 1.1;
|
||||
const className = isSlow ? "cycle-chart__bar cycle-chart__bar--slow" : "cycle-chart__bar";
|
||||
|
||||
return (
|
||||
<rect
|
||||
key={cycle.id}
|
||||
className={className}
|
||||
x={x}
|
||||
y={y}
|
||||
width={Math.max(barWidth, 2)}
|
||||
height={barHeight}
|
||||
rx="2"
|
||||
>
|
||||
<title>{`${cycle.cycle_time_sec.toFixed(1)} s`}</title>
|
||||
</rect>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
18
frontend/src/components/KpiBlock.tsx
Normal file
18
frontend/src/components/KpiBlock.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
type KpiBlockProps = {
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
subLabel?: string;
|
||||
tone?: "default" | "dark";
|
||||
};
|
||||
|
||||
export default function KpiBlock({ label, value, subLabel, tone = "default" }: KpiBlockProps) {
|
||||
return (
|
||||
<article className={`kpi-block kpi-block--${tone}`}>
|
||||
<span className="kpi-block__label">{label}</span>
|
||||
<strong className="kpi-block__value">{value}</strong>
|
||||
{subLabel ? <span className="kpi-block__sub-label">{subLabel}</span> : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
138
frontend/src/components/MachineCard.tsx
Normal file
138
frontend/src/components/MachineCard.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { MachineCardData } from "../lib/types";
|
||||
import StatusBadge from "./StatusBadge";
|
||||
|
||||
type MachineCardProps = {
|
||||
machine: MachineCardData;
|
||||
selected: boolean;
|
||||
nowMs: number;
|
||||
onSelect: () => void;
|
||||
onCreateOrder: () => void;
|
||||
onDeclareScrap: () => void;
|
||||
onQualifyDowntime: () => void;
|
||||
};
|
||||
|
||||
function formatDurationSince(timestamp: string | null, nowMs: number) {
|
||||
if (!timestamp) {
|
||||
return "No timestamp";
|
||||
}
|
||||
|
||||
const elapsedMs = Math.max(nowMs - new Date(timestamp).getTime(), 0);
|
||||
const elapsedMinutes = Math.floor(elapsedMs / 60000);
|
||||
if (elapsedMinutes < 1) {
|
||||
return "< 1 min";
|
||||
}
|
||||
if (elapsedMinutes < 60) {
|
||||
return `${elapsedMinutes} min`;
|
||||
}
|
||||
|
||||
const hours = Math.floor(elapsedMinutes / 60);
|
||||
const minutes = elapsedMinutes % 60;
|
||||
return minutes > 0 ? `${hours} h ${minutes} min` : `${hours} h`;
|
||||
}
|
||||
|
||||
export default function MachineCard({
|
||||
machine,
|
||||
selected,
|
||||
nowMs,
|
||||
onSelect,
|
||||
onCreateOrder,
|
||||
onDeclareScrap,
|
||||
onQualifyDowntime,
|
||||
}: MachineCardProps) {
|
||||
const progressPercent = Math.round(machine.order_progress * 100);
|
||||
const hasActiveOrder = Boolean(machine.active_order);
|
||||
|
||||
return (
|
||||
<article className={`machine-card ${selected ? "is-selected" : ""}`}>
|
||||
<button type="button" className="machine-card__select" onClick={onSelect}>
|
||||
<div className="machine-card__top">
|
||||
<div>
|
||||
<p className="machine-card__code">{machine.code}</p>
|
||||
<p className="machine-card__meta">{[machine.brand, machine.model].filter(Boolean).join(" / ")}</p>
|
||||
</div>
|
||||
<div className="machine-card__status-stack">
|
||||
<StatusBadge status={machine.status} />
|
||||
<span className="duration-badge">{formatDurationSince(machine.status_since, nowMs)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="machine-card__hero">
|
||||
<div>
|
||||
<span className="machine-card__label">Current order</span>
|
||||
<strong>{machine.active_order?.order_number ?? "No active order"}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className="machine-card__label">OEE today</span>
|
||||
<strong>{Math.round(machine.today_oee.oee * 100)}%</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!hasActiveOrder ? (
|
||||
<div className="machine-card__empty">
|
||||
<strong>No active OF</strong>
|
||||
<span>Create or start a production order before counting production for this press.</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="machine-card__stats">
|
||||
<div>
|
||||
<span>Article</span>
|
||||
<strong>{machine.active_order?.article_name ?? "-"}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Mold</span>
|
||||
<strong>{machine.active_order?.mold_ref ?? "-"}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Real cycle</span>
|
||||
<strong>{machine.cycle_real_avg_sec.toFixed(1)} s</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Theoretical</span>
|
||||
<strong>{machine.active_order?.theoretical_cycle_time_sec ?? 0} s</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Produced</span>
|
||||
<strong>{machine.produced_qty}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Scrap</span>
|
||||
<strong>{machine.scrap_qty}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Last stop</span>
|
||||
<strong>{machine.last_downtime?.reason_code ?? "None"}</strong>
|
||||
</div>
|
||||
<div className="machine-card__compact-kpi">
|
||||
<span>Quality</span>
|
||||
<strong>{Math.round(machine.today_oee.quality * 100)}%</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="machine-card__progress">
|
||||
<div className="machine-card__progress-head">
|
||||
<span>Order progress</span>
|
||||
<strong>{progressPercent}%</strong>
|
||||
</div>
|
||||
<div className="progress-track">
|
||||
<progress value={progressPercent} max={100} aria-label={`Order progress ${progressPercent}%`} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="machine-card__actions" aria-label={`${machine.code} quick actions`}>
|
||||
<button type="button" onClick={onCreateOrder}>
|
||||
OF
|
||||
</button>
|
||||
<button type="button" onClick={onDeclareScrap} disabled={!hasActiveOrder}>
|
||||
Rebut
|
||||
</button>
|
||||
<button type="button" onClick={onQualifyDowntime} disabled={!machine.open_downtime}>
|
||||
Arret
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
22
frontend/src/components/Panel.tsx
Normal file
22
frontend/src/components/Panel.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { PropsWithChildren, ReactNode } from "react";
|
||||
|
||||
type PanelProps = PropsWithChildren<{
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
actions?: ReactNode;
|
||||
}>;
|
||||
|
||||
export default function Panel({ title, subtitle, actions, children }: PanelProps) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<header className="panel__header">
|
||||
<div>
|
||||
<p className="eyebrow">{title}</p>
|
||||
{subtitle ? <h2>{subtitle}</h2> : null}
|
||||
</div>
|
||||
{actions}
|
||||
</header>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
15
frontend/src/components/StatusBadge.tsx
Normal file
15
frontend/src/components/StatusBadge.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
type StatusBadgeProps = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
const labels: Record<string, string> = {
|
||||
production: "Production",
|
||||
arret_non_qualifie: "Arret non qualifie",
|
||||
arret_qualifie: "Arret qualifie",
|
||||
reglage: "Reglage",
|
||||
hors_planning: "Hors planning",
|
||||
};
|
||||
|
||||
export default function StatusBadge({ status }: StatusBadgeProps) {
|
||||
return <span className={`status-badge status-badge--${status}`}>{(labels[status] ?? status).toUpperCase()}</span>;
|
||||
}
|
||||
33
frontend/src/lib/api.ts
Normal file
33
frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
|
||||
export const WS_URL = import.meta.env.VITE_WS_URL ?? "ws://localhost:8000/ws/dashboard";
|
||||
|
||||
export async function fetchJson<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
...init,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.text();
|
||||
throw new Error(detail || `Request failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export async function postJson<T>(path: string, payload?: unknown): Promise<T> {
|
||||
return fetchJson<T>(path, {
|
||||
method: "POST",
|
||||
body: payload ? JSON.stringify(payload) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export async function patchJson<T>(path: string, payload: unknown): Promise<T> {
|
||||
return fetchJson<T>(path, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
154
frontend/src/lib/types.ts
Normal file
154
frontend/src/lib/types.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
export type OrderSummary = {
|
||||
id: number;
|
||||
order_number: string;
|
||||
article_ref: string;
|
||||
article_name: string;
|
||||
mold_ref: string | null;
|
||||
material_ref: string | null;
|
||||
planned_qty: number;
|
||||
cavities: number;
|
||||
theoretical_cycle_time_sec: number;
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type MachineConfig = {
|
||||
name: string;
|
||||
brand: string | null;
|
||||
model: string | null;
|
||||
is_active: boolean;
|
||||
cycle_signal_edge: string;
|
||||
debounce_ms: number;
|
||||
min_cycle_time_sec: number;
|
||||
max_cycle_time_sec: number;
|
||||
stop_detection_delay_sec: number;
|
||||
};
|
||||
|
||||
export type MachineCardData = {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
brand: string | null;
|
||||
model: string | null;
|
||||
status: string;
|
||||
status_since: string | null;
|
||||
powered_on: boolean;
|
||||
config: MachineConfig;
|
||||
active_order: OrderSummary | null;
|
||||
cycle_real_avg_sec: number;
|
||||
produced_qty: number;
|
||||
scrap_qty: number;
|
||||
order_progress: number;
|
||||
today_oee: {
|
||||
oee: number;
|
||||
availability: number;
|
||||
performance: number;
|
||||
quality: number;
|
||||
total_cycles: number;
|
||||
};
|
||||
open_downtime: {
|
||||
id: number;
|
||||
reason_code: string | null;
|
||||
start_time: string;
|
||||
comment: string | null;
|
||||
} | null;
|
||||
last_downtime: {
|
||||
id: number;
|
||||
reason_code: string | null;
|
||||
start_time: string;
|
||||
end_time: string | null;
|
||||
duration_sec: number | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type DashboardSnapshot = {
|
||||
timestamp: string;
|
||||
machines: MachineCardData[];
|
||||
};
|
||||
|
||||
export type MachineEvent = {
|
||||
id: number;
|
||||
event_type: string;
|
||||
timestamp: string;
|
||||
payload: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type Cycle = {
|
||||
id: number;
|
||||
timestamp: string;
|
||||
cycle_time_sec: number;
|
||||
produced_qty: number;
|
||||
is_valid: boolean;
|
||||
};
|
||||
|
||||
export type Downtime = {
|
||||
id: number;
|
||||
machine_id?: number;
|
||||
production_order_id: number | null;
|
||||
start_time: string;
|
||||
end_time: string | null;
|
||||
duration_sec: number | null;
|
||||
reason_code: string | null;
|
||||
comment: string | null;
|
||||
auto_detected: boolean;
|
||||
qualified_by: string | null;
|
||||
};
|
||||
|
||||
export type ProductionOrder = {
|
||||
id: number;
|
||||
order_number: string;
|
||||
machine_id: number;
|
||||
article_ref: string;
|
||||
article_name: string;
|
||||
mold_ref: string | null;
|
||||
material_ref: string | null;
|
||||
planned_qty: number;
|
||||
cavities: number;
|
||||
theoretical_cycle_time_sec: number;
|
||||
status: string;
|
||||
started_at: string | null;
|
||||
ended_at: string | null;
|
||||
};
|
||||
|
||||
export type Scrap = {
|
||||
id: number;
|
||||
machine_id: number;
|
||||
production_order_id: number | null;
|
||||
quantity: number;
|
||||
reason_code: string;
|
||||
comment: string | null;
|
||||
operator_name: string;
|
||||
timestamp: string;
|
||||
};
|
||||
|
||||
export type ReferenceOption = {
|
||||
code: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type ReferenceData = {
|
||||
downtime_reasons: ReferenceOption[];
|
||||
scrap_reasons: ReferenceOption[];
|
||||
};
|
||||
|
||||
export type DailyOee = {
|
||||
date: string;
|
||||
machines: Array<{
|
||||
machine_id: number;
|
||||
availability: number;
|
||||
performance: number;
|
||||
quality: number;
|
||||
oee: number;
|
||||
total_cycles: number;
|
||||
total_produced_qty: number;
|
||||
scrap_qty: number;
|
||||
}>;
|
||||
overall: {
|
||||
availability: number;
|
||||
performance: number;
|
||||
quality: number;
|
||||
oee: number;
|
||||
total_cycles: number;
|
||||
total_produced_qty: number;
|
||||
scrap_qty: number;
|
||||
};
|
||||
};
|
||||
11
frontend/src/main.tsx
Normal file
11
frontend/src/main.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
|
||||
import App from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
969
frontend/src/styles.css
Normal file
969
frontend/src/styles.css
Normal file
@@ -0,0 +1,969 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Instrument+Sans:wght@400;600&family=Space+Grotesk:wght@600&display=swap");
|
||||
|
||||
:root {
|
||||
--bg-base: #F5F7FA;
|
||||
--bg-panel: #FFFFFF;
|
||||
--bg-panel-alt: color-mix(in srgb, #F5F7FA 78%, #FFFFFF);
|
||||
--border: color-mix(in srgb, #2B2B2B 14%, #FFFFFF);
|
||||
--teal-900: #0A2F5A;
|
||||
--teal-700: color-mix(in srgb, #0A2F5A 78%, #2B2B2B);
|
||||
--teal-100: color-mix(in srgb, #0A2F5A 12%, #FFFFFF);
|
||||
--accent: #F28C28;
|
||||
--accent-light: color-mix(in srgb, #F28C28 14%, #FFFFFF);
|
||||
--text-primary: #2B2B2B;
|
||||
--text-secondary: color-mix(in srgb, #2B2B2B 72%, #FFFFFF);
|
||||
--text-on-dark: #FFFFFF;
|
||||
--status-production: #0A2F5A;
|
||||
--status-unqualified: #F28C28;
|
||||
--status-qualified: #2B2B2B;
|
||||
--status-setup: color-mix(in srgb, #0A2F5A 72%, #F28C28);
|
||||
--status-off-plan: color-mix(in srgb, #2B2B2B 48%, #FFFFFF);
|
||||
|
||||
--text-xs: 0.75rem;
|
||||
--text-sm: 0.875rem;
|
||||
--text-base: 1rem;
|
||||
--text-lg: 1.125rem;
|
||||
--text-xl: 1.25rem;
|
||||
--text-2xl: 1.5rem;
|
||||
--text-3xl: 2rem;
|
||||
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-base);
|
||||
font-family: "Instrument Sans", sans-serif;
|
||||
font-size: var(--text-base);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
background: var(--bg-base);
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.app-shell--workshop {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.app-shell--workshop .hero,
|
||||
.app-shell--workshop .summary-ribbon,
|
||||
.app-shell--workshop .module-tabs {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-shell--workshop .workspace {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-shell--workshop .panel {
|
||||
min-height: calc(100vh - 24px);
|
||||
}
|
||||
|
||||
.app-shell--workshop .machine-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
}
|
||||
|
||||
.workspace {
|
||||
display: grid;
|
||||
width: min(1480px, 100%);
|
||||
margin: 0 auto;
|
||||
gap: 18px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.hero,
|
||||
.panel,
|
||||
.machine-card,
|
||||
.kpi-block {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: var(--bg-panel);
|
||||
box-shadow: 0 1px 3px rgba(10, 47, 90, 0.06), 0 4px 12px rgba(10, 47, 90, 0.04);
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 0;
|
||||
font-family: "Space Grotesk", sans-serif;
|
||||
font-size: var(--text-3xl);
|
||||
font-weight: 600;
|
||||
line-height: 1.1;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.hero__status {
|
||||
display: inline-flex;
|
||||
min-height: 44px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
background: var(--teal-900);
|
||||
color: var(--text-on-dark);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hero__status p {
|
||||
margin: 0;
|
||||
color: var(--text-on-dark);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.hero__dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 99px;
|
||||
background: var(--status-production);
|
||||
}
|
||||
|
||||
.connection-banner {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--accent-light);
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.eyebrow,
|
||||
.meta-label,
|
||||
.kpi-block__label,
|
||||
.machine-card__label,
|
||||
th {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
display: block;
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.panel__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.panel__header h2 {
|
||||
margin: 0;
|
||||
font-family: "Space Grotesk", sans-serif;
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.panel-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.panel-actions select,
|
||||
.panel-actions button {
|
||||
min-height: 44px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-panel);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.panel-actions button {
|
||||
background: var(--teal-900);
|
||||
color: var(--text-on-dark);
|
||||
font-family: "Space Grotesk", sans-serif;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.summary-ribbon {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.module-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: var(--bg-panel-alt);
|
||||
}
|
||||
|
||||
.module-tabs__button {
|
||||
display: grid;
|
||||
min-height: 66px;
|
||||
align-content: center;
|
||||
gap: 3px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.module-tabs__button span {
|
||||
font-family: "Space Grotesk", sans-serif;
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.module-tabs__button small {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.module-tabs__button:hover,
|
||||
.module-tabs__button.is-active {
|
||||
border-color: var(--border);
|
||||
background: var(--bg-panel);
|
||||
box-shadow: 0 1px 3px rgba(10, 47, 90, 0.06), 0 4px 12px rgba(10, 47, 90, 0.04);
|
||||
}
|
||||
|
||||
.module-tabs__button.is-active {
|
||||
box-shadow: inset 0 -3px 0 var(--accent), 0 1px 3px rgba(10, 47, 90, 0.06), 0 4px 12px rgba(10, 47, 90, 0.04);
|
||||
}
|
||||
|
||||
.module-view {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.module-grid {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.module-grid--split {
|
||||
grid-template-columns: 1.4fr 0.8fr;
|
||||
}
|
||||
|
||||
.module-grid--form-table {
|
||||
grid-template-columns: minmax(280px, 380px) minmax(0, 1fr);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.module-form,
|
||||
.config-form {
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-panel-alt);
|
||||
}
|
||||
|
||||
.config-form {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.config-form .checkbox-field,
|
||||
.config-form .primary-button,
|
||||
.config-form .inline-toast {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.kpi-block {
|
||||
min-height: 96px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.kpi-block--dark {
|
||||
background: var(--teal-900);
|
||||
color: var(--text-on-dark);
|
||||
}
|
||||
|
||||
.kpi-block--dark .kpi-block__label,
|
||||
.kpi-block--dark .kpi-block__sub-label {
|
||||
color: var(--text-on-dark);
|
||||
}
|
||||
|
||||
.kpi-block__label,
|
||||
.kpi-block__sub-label {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.kpi-block__value {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
font-family: "Space Grotesk", sans-serif;
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 600;
|
||||
line-height: 1.15;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.kpi-block__sub-label {
|
||||
margin-top: 4px;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.machine-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.machine-card {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 180px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
color: var(--text-primary);
|
||||
text-align: left;
|
||||
transition: box-shadow 160ms ease, transform 160ms ease;
|
||||
}
|
||||
|
||||
.machine-card__select {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.machine-card:hover,
|
||||
.machine-card.is-selected {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 16px rgba(10, 47, 90, 0.12), 0 0 0 2px var(--accent);
|
||||
}
|
||||
|
||||
.machine-card__top,
|
||||
.machine-card__hero,
|
||||
.machine-card__progress-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.machine-card__status-stack {
|
||||
display: grid;
|
||||
justify-items: end;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.machine-card__code {
|
||||
margin: 0;
|
||||
font-family: "Space Grotesk", sans-serif;
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.machine-card__meta {
|
||||
margin: 4px 0 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.machine-card__hero {
|
||||
margin-top: 12px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-panel-alt);
|
||||
}
|
||||
|
||||
.machine-card__hero strong {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
.machine-card__empty {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--accent-light);
|
||||
}
|
||||
|
||||
.machine-card__empty strong {
|
||||
font-family: "Space Grotesk", sans-serif;
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.machine-card__empty span {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.machine-card__stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.machine-card__stats span,
|
||||
.machine-card__progress span {
|
||||
display: block;
|
||||
margin-bottom: 3px;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.machine-card__stats strong {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.machine-card__progress {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.machine-card__actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 1px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.machine-card__actions button {
|
||||
min-height: 44px;
|
||||
border: 0;
|
||||
background: var(--bg-panel-alt);
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.machine-card__actions button:hover:not(:disabled) {
|
||||
background: var(--accent-light);
|
||||
}
|
||||
|
||||
.progress-track {
|
||||
height: 6px;
|
||||
margin-top: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-panel-alt);
|
||||
}
|
||||
|
||||
.progress-track progress {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
appearance: none;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.progress-track progress::-webkit-progress-bar {
|
||||
background: var(--bg-panel-alt);
|
||||
}
|
||||
|
||||
.progress-track progress::-webkit-progress-value {
|
||||
background: var(--teal-700);
|
||||
}
|
||||
|
||||
.progress-track progress::-moz-progress-bar {
|
||||
background: var(--teal-700);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
min-height: 24px;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
border-radius: 99px;
|
||||
color: var(--text-on-dark);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.duration-badge {
|
||||
display: inline-flex;
|
||||
min-height: 24px;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 99px;
|
||||
background: var(--bg-panel-alt);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-badge--production {
|
||||
background: var(--status-production);
|
||||
}
|
||||
|
||||
.status-badge--arret_non_qualifie {
|
||||
background: var(--status-unqualified);
|
||||
}
|
||||
|
||||
.status-badge--arret_qualifie {
|
||||
background: var(--status-qualified);
|
||||
}
|
||||
|
||||
.status-badge--reglage {
|
||||
background: var(--status-setup);
|
||||
}
|
||||
|
||||
.status-badge--hors_planning {
|
||||
background: var(--status-off-plan);
|
||||
}
|
||||
|
||||
.detail-stack {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.metric-row,
|
||||
.oee-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.machine-context {
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 1.6fr 0.8fr;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-panel-alt);
|
||||
}
|
||||
|
||||
.machine-context strong {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.cycle-panel {
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--teal-900);
|
||||
color: var(--text-on-dark);
|
||||
}
|
||||
|
||||
.cycle-panel__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.cycle-panel h3 {
|
||||
margin: 0;
|
||||
font-family: "Space Grotesk", sans-serif;
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cycle-panel span {
|
||||
color: var(--text-on-dark);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.cycle-chart {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 150px;
|
||||
border-radius: 6px;
|
||||
background: var(--teal-700);
|
||||
}
|
||||
|
||||
.cycle-chart--empty {
|
||||
display: grid;
|
||||
min-height: 150px;
|
||||
place-items: center;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-on-dark);
|
||||
}
|
||||
|
||||
.cycle-chart__reference {
|
||||
stroke: var(--teal-100);
|
||||
stroke-width: 2;
|
||||
stroke-dasharray: 5 5;
|
||||
}
|
||||
|
||||
.cycle-chart__bar {
|
||||
fill: var(--status-production);
|
||||
}
|
||||
|
||||
.cycle-chart__bar--slow {
|
||||
fill: var(--status-unqualified);
|
||||
}
|
||||
|
||||
.detail-columns {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.detail-columns h3 {
|
||||
margin: 0 0 10px;
|
||||
font-family: "Space Grotesk", sans-serif;
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.list li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-panel-alt);
|
||||
}
|
||||
|
||||
.list li strong {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.list li span {
|
||||
flex: 0 0 auto;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.timeline-list {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.timeline-list li {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 12px 12px 28px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.timeline-list li::before {
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
left: 8px;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 99px;
|
||||
background: var(--status-unqualified);
|
||||
content: "";
|
||||
}
|
||||
|
||||
.timeline-list li::after {
|
||||
position: absolute;
|
||||
top: 29px;
|
||||
bottom: 0;
|
||||
left: 12px;
|
||||
width: 1px;
|
||||
background: var(--border);
|
||||
content: "";
|
||||
}
|
||||
|
||||
.timeline-list li:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.timeline-list li:last-child::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.timeline-list strong,
|
||||
.timeline-list span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.timeline-list span,
|
||||
.timeline-list time {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.timeline-list time {
|
||||
flex: 0 0 auto;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.oee-mini-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.oee-mini-list__item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-panel-alt);
|
||||
color: var(--text-primary);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.oee-mini-list__item span {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.form-grid input,
|
||||
.form-grid select,
|
||||
.form-grid textarea {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-panel);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.form-grid textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.checkbox-field {
|
||||
display: flex;
|
||||
min-height: 44px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-panel);
|
||||
}
|
||||
|
||||
.checkbox-field input {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.primary-button,
|
||||
table button {
|
||||
min-height: 44px;
|
||||
padding: 10px 20px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: var(--accent);
|
||||
color: var(--bg-panel);
|
||||
font-family: "Space Grotesk", sans-serif;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.primary-button:disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.inline-toast {
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
background: var(--status-production);
|
||||
color: var(--bg-panel);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.inline-error,
|
||||
.field-error {
|
||||
color: var(--status-qualified);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.inline-error {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--status-qualified);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-panel);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.field-error {
|
||||
display: block;
|
||||
grid-column: 1 / -1;
|
||||
margin-top: -6px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 16px;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-panel-alt);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
td {
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.table-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 1279px) {
|
||||
.workspace {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.summary-ribbon {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.module-tabs {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.module-grid--split,
|
||||
.module-grid--form-table {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.app-shell {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.hero,
|
||||
.machine-card__top,
|
||||
.machine-card__hero,
|
||||
.machine-card__progress-head,
|
||||
.cycle-panel__header,
|
||||
.list li,
|
||||
.timeline-list li {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.machine-card__status-stack {
|
||||
justify-items: start;
|
||||
}
|
||||
|
||||
.panel-actions {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.panel-actions select,
|
||||
.panel-actions button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.summary-ribbon,
|
||||
.module-tabs,
|
||||
.metric-row,
|
||||
.oee-grid,
|
||||
.machine-context,
|
||||
.detail-columns,
|
||||
.config-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.table-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
transition-duration: 0.01ms !important;
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
1
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user