const { useState, useEffect, useCallback, useMemo, useRef } = React; /* --------------------------------------------------- Filet anti-crash ---- */ // Un bug dans un composant n'écroule plus toute l'appli : on affiche un message // récupérable au lieu d'un écran blanc. (Doit être une classe : les hooks ne // peuvent pas intercepter les erreurs de rendu.) class ErrorBoundary extends React.Component { constructor(props) { super(props); this.state = { err: null }; } static getDerivedStateFromError(err) { return { err }; } componentDidCatch(err, info) { console.error("[RMA] crash intercepté :", err, info); } componentDidUpdate(prev) { if (prev.resetKey !== this.props.resetKey && this.state.err) this.setState({ err: null }); } render() { if (!this.state.err) return this.props.children; const reset = () => this.setState({ err: null }); if (this.props.fallback) return this.props.fallback(this.state.err, reset); return (
⚠️

Une erreur est survenue

L'application a rencontré un problème mais tes données sont intactes (rien n'est perdu).

          {String(this.state.err && (this.state.err.message || this.state.err))}
        
); } } /* ---------------------------------------------------------------- API ---- */ let onUnauthorized = () => {}; // appelé si une requête renvoie 401 (session expirée) const api = { async get(url) { const r = await fetch(url); if (!r.ok) throw await err(r); return r.json(); }, async post(url, body) { return send("POST", url, body); }, async patch(url, body) { return send("PATCH", url, body); }, async del(url) { const r = await fetch(url, { method: "DELETE" }); if (!r.ok) throw await err(r); }, }; async function send(method, url, body) { const r = await fetch(url, { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); if (!r.ok) throw await err(r); return r.status === 204 ? null : r.json(); } async function err(r) { if (r.status === 401) onUnauthorized(); try { const j = await r.json(); return new Error(j.detail || r.statusText); } catch { return new Error(r.statusText); } } /* ------------------------------------------------------------ helpers ---- */ const STATUSES = [ { key: "ouvert", label: "Ouvert" }, { key: "en_attente", label: "En attente" }, { key: "valide", label: "Validé" }, { key: "rembourse", label: "Remboursé" }, { key: "refuse", label: "Refusé" }, ]; const ST_COLOR = { ouvert: "#4f8ff7", en_attente: "#f5a623", valide: "#a78bfa", rembourse: "#34d399", refuse: "#f87171" }; const ACTION_TYPES = [ { key: "relancer", label: "Relancer", icon: "📣" }, { key: "attendre", label: "Attendre réponse", icon: "⏳" }, { key: "escalader", label: "Escalader", icon: "🚀" }, { key: "soumettre", label: "Soumettre", icon: "📤" }, { key: "autre", label: "Autre", icon: "✦" }, ]; const actionMeta = (k) => ACTION_TYPES.find((a) => a.key === k) || { label: k, icon: "✦" }; const DOC_CATS = [ { key: "facture", label: "Facture / preuve d'achat", icon: "🧾" }, { key: "photo", label: "Photos du produit", icon: "📷" }, { key: "serie", label: "Photo du n° de série", icon: "🔢" }, { key: "etiquette", label: "Étiquette de retour", icon: "🏷️" }, { key: "autre", label: "Autre document", icon: "📎" }, ]; const docCatMeta = (k) => DOC_CATS.find((c) => c.key === k) || DOC_CATS[4]; const todayISO = () => new Date().toISOString().slice(0, 10); const plusDaysISO = (n) => { const d = new Date(); d.setDate(d.getDate() + n); return d.toISOString().slice(0, 10); }; const statusLabel = (k) => (STATUSES.find((s) => s.key === k) || { label: k }).label; const fmtMoney = (n, c) => (n || 0).toLocaleString("fr-FR", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + " " + (c || "€").replace("EUR", "€"); // Les dates "YYYY-MM-DD" doivent être interprétées en heure LOCALE (pas UTC), // sinon un dossier dû aujourd'hui apparaît "J-1" le matin. const parseLocalDate = (s) => { const [y, m, dd] = String(s).slice(0, 10).split("-").map(Number); return new Date(y, m - 1, dd); }; const todayLocal = () => { const n = new Date(); return new Date(n.getFullYear(), n.getMonth(), n.getDate()); }; const fmtDate = (s) => (s ? (String(s).length === 10 ? parseLocalDate(s) : new Date(s)).toLocaleDateString("fr-FR") : "—"); const fmtDateTime = (s) => (s ? new Date(s).toLocaleString("fr-FR") : "—"); const CLOSED = ["rembourse", "refuse"]; const isOverdue = (d) => d.due_date && !CLOSED.includes(d.status) && parseLocalDate(d.due_date) < todayLocal(); const daysUntil = (s) => Math.round((parseLocalDate(s) - todayLocal()) / 86400000); const ago = (s) => { const days = Math.floor((Date.now() - new Date(s)) / 86400000); return days <= 0 ? "aujourd'hui" : days === 1 ? "hier" : `il y a ${days} j`; }; function Badge({ status }) { return {statusLabel(status)}; } function SiteLogo({ name, logo, size = 30 }) { const [broken, setBroken] = useState(false); useEffect(() => setBroken(false), [logo]); if (!logo || broken) { const initials = (name || "?").replace(/[^A-Za-zÀ-ÿ0-9 ]/g, "").split(/\s+/).filter(Boolean).map((w) => w[0]).slice(0, 2).join("").toUpperCase() || "?"; return {initials}; } return {name} setBroken(true)} />; } /* ------------------------------------------------------------- icons ----- */ const Icon = { grid: , folder: , layers: , store: , search: , mail: , shield: , }; /* Icônes inline (bouton/texte) — remplacent les emojis les plus répétés dans l'UI. */ const ic = { display: "inline-block", verticalAlign: "-2px", marginRight: 5 }; const Ico = { x: , check: , edit: , trash: , warn: , key: , eye: , lock: , lockOpen: , doc: , copy: , globe: , send: , }; /* ------------------------------------------------------------- toasts ---- */ let pushToast = () => {}; function toast(msg, isError) { pushToast(msg, isError); } function Toasts() { const [items, setItems] = useState([]); useEffect(() => { pushToast = (msg, isError) => { const id = Date.now() + Math.random(); setItems((it) => [...it, { id, msg, isError }]); setTimeout(() => setItems((it) => it.filter((x) => x.id !== id)), 3500); }; return () => { pushToast = () => {}; }; }, []); return (
{items.map((t) => (
{t.msg}
))}
); } /* ----------------------------------------------------------- Auth gate --- */ function AuthGate() { const [state, setState] = useState("loading"); // loading | login | ready const [user, setUser] = useState(null); const check = useCallback(async () => { try { const u = await api.get("/api/auth/me"); setUser(u); setState("ready"); } catch { setState("login"); } }, []); useEffect(() => { check(); }, [check]); useEffect(() => { onUnauthorized = () => { setUser(null); setState("login"); }; return () => { onUnauthorized = () => {}; }; }, []); const logout = async () => { try { await api.post("/api/auth/logout", {}); } catch {} setUser(null); setState("login"); }; if (state === "loading") return
Chargement…
; if (state === "login") return ; return ; } function StarField({ className = "login-stars" }) { const ref = useRef(null); useEffect(() => { const canvas = ref.current; const ctx = canvas.getContext("2d"); const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches; const dpr = Math.min(window.devicePixelRatio || 1, 2); let w = 0, h = 0, dust = [], shooting = [], raf = 0; let lastShoot = 0, nextShoot = 5000; function resize() { w = canvas.clientWidth; h = canvas.clientHeight; canvas.width = w * dpr; canvas.height = h * dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); const count = Math.min(150, Math.round((w * h) / 11000)); dust = Array.from({ length: count }, () => ({ x: Math.random() * w, y: Math.random() * h, r: Math.random() * 1.2 + 0.3, vx: (Math.random() * 0.12 + 0.02) * (Math.random() < 0.5 ? -1 : 1), vy: (Math.random() * 0.08 + 0.015), a: Math.random() * 0.5 + 0.2, tw: Math.random() * Math.PI * 2, })); } resize(); window.addEventListener("resize", resize); function spawnShoot() { const dir = Math.random() < 0.5 ? 1 : -1; const speed = 7 + Math.random() * 5; const ang = (16 + Math.random() * 14) * Math.PI / 180; shooting.push({ x: dir > 0 ? Math.random() * w * 0.5 : w * 0.5 + Math.random() * w * 0.5, y: Math.random() * h * 0.35, vx: Math.cos(ang) * speed * dir, vy: Math.sin(ang) * speed, len: 110 + Math.random() * 90, }); } function drawDust() { for (const p of dust) { const a = p.a * (0.6 + 0.4 * Math.sin(p.tw)); ctx.globalAlpha = a; ctx.fillStyle = "#fff"; ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, 7); ctx.fill(); } ctx.globalAlpha = 1; } function frame(t) { ctx.clearRect(0, 0, w, h); for (const p of dust) { p.x += p.vx; p.y += p.vy; p.tw += 0.02; if (p.x < -2) p.x = w + 2; if (p.x > w + 2) p.x = -2; if (p.y > h + 2) p.y = -2; if (p.y < -2) p.y = h + 2; } drawDust(); if (t - lastShoot > nextShoot) { spawnShoot(); lastShoot = t; nextShoot = 9000 + Math.random() * 15000; } shooting = shooting.filter((s) => s.x > -300 && s.x < w + 300 && s.y < h + 300); for (const s of shooting) { const mag = Math.hypot(s.vx, s.vy), ux = s.vx / mag, uy = s.vy / mag; const tx = s.x - ux * s.len, ty = s.y - uy * s.len; const grad = ctx.createLinearGradient(s.x, s.y, tx, ty); grad.addColorStop(0, "rgba(255,255,255,0.9)"); grad.addColorStop(1, "rgba(255,255,255,0)"); ctx.strokeStyle = grad; ctx.lineWidth = 2; ctx.lineCap = "round"; ctx.beginPath(); ctx.moveTo(s.x, s.y); ctx.lineTo(tx, ty); ctx.stroke(); ctx.fillStyle = "rgba(255,255,255,0.95)"; ctx.beginPath(); ctx.arc(s.x, s.y, 1.6, 0, 7); ctx.fill(); s.x += s.vx; s.y += s.vy; } raf = requestAnimationFrame(frame); } if (reduced) { drawDust(); } // pas d'animation si l'utilisateur l'a désactivée else raf = requestAnimationFrame(frame); return () => { cancelAnimationFrame(raf); window.removeEventListener("resize", resize); }; }, []); return