"use client"; import { useMemo, useState, useTransition, type FormEvent } from "react"; import { AlertTriangle, CalendarClock, Plus, Power, Trash2 } from "lucide-react"; import { upsertCadence, toggleCadence, removeCadence, type CadenceView, type CadenceStatut, } from "@/features/commandes-auto/actions"; import { Badge, type Ton } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { confirmer, toast } from "@/components/ui/feedback"; import { Input, Label } from "@/components/ui/form-controls"; import { EmptyState } from "@/components/ui/states"; import { Terme, Tooltip, InfoBulle } from "@/components/ui/tooltip"; import { MAGASINS, nomMagasin } from "@/lib/magasins"; import { cn } from "@/lib/utils"; type Fournisseur = { code: string; nom: string }; type Resultat = { ok: boolean; error?: string }; const SEMAINES_MIN = 1; const SEMAINES_MAX = 104; function fmtDate(iso: string | null): string { if (!iso) return "—"; const d = new Date(iso); if (Number.isNaN(d.getTime())) return "—"; return d.toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric" }); } const pluriel = (n: number, mot: string) => `${n} ${mot}${Math.abs(n) > 1 ? "s" : ""}`; function tempsRestant(jours: number | null): string { if (jours === null) return "Réception inconnue"; if (jours === 0) return "Aujourd'hui"; if (jours < 0) return `En retard de ${pluriel(Math.abs(jours), "jour")}`; if (jours === 1) return "Demain"; return `Dans ${pluriel(jours, "jour")}`; } const STATUT_META: Record = { a_commander: { libelle: "À commander", ton: "erreur", explication: "L'échéance est atteinte ou dépassée : passez commande.", }, bientot: { libelle: "Bientôt", ton: "alerte", explication: "L'échéance tombe dans les 7 prochains jours.", }, ok: { libelle: "À jour", ton: "succes", explication: "L'échéance est dans plus de 7 jours.", }, inconnu: { libelle: "Inconnu", ton: "neutre", explication: "Aucune réception connue pour ce fournisseur dans ce magasin : l'échéance ne peut pas être calculée.", }, }; function StatutBadge({ cadence }: { cadence: CadenceView }) { if (!cadence.actif) { return Désactivé; } const { libelle, ton, explication } = STATUT_META[cadence.statut]; return ( {cadence.statut === "a_commander" && } {libelle} ); } /** Libellé suivi d'un « i » : explication libre au survol (hors glossaire). */ /** Tri : à commander d'abord (plus en retard en tête), puis bientôt, ok, inconnu, et inactifs en dernier. */ function trier(rows: CadenceView[]): CadenceView[] { const rank: Record = { a_commander: 0, bientot: 1, ok: 2, inconnu: 3 }; return [...rows].sort((a, b) => { if (a.actif !== b.actif) return a.actif ? -1 : 1; if (a.statut !== b.statut) return rank[a.statut] - rank[b.statut]; const ja = a.joursRestants ?? Number.POSITIVE_INFINITY; const jb = b.joursRestants ?? Number.POSITIVE_INFINITY; return ja - jb; }); } function FormulaireAjout({ site, fournisseurs, codesExistants, onAjouter, pending, }: { site: string; fournisseurs: Fournisseur[]; codesExistants: Set; onAjouter: (input: { codefou: string; nomfou: string; semaines: number }) => Promise; pending: boolean; }) { const [saisie, setSaisie] = useState(""); const [semaines, setSemaines] = useState("4"); const [erreur, setErreur] = useState(null); const idFournisseur = `cadence-fournisseur-${site}`; const idSemaines = `cadence-semaines-${site}`; const idListe = `cadence-liste-${site}`; const idErreur = `cadence-erreur-${site}`; // Libellé affiché dans la liste de suggestions → fournisseur const parLibelle = useMemo(() => { const m = new Map(); for (const f of fournisseurs) m.set(`${f.nom} — ${f.code}`, f); return m; }, [fournisseurs]); async function ajouter(e: FormEvent) { e.preventDefault(); setErreur(null); const f = parLibelle.get(saisie.trim()); if (!f) { setErreur("Choisissez un fournisseur dans la liste proposée pendant la saisie."); return; } if (codesExistants.has(f.code)) { setErreur("Ce fournisseur a déjà un rappel dans ce magasin : modifiez son rythme dans le tableau ci-dessous."); return; } const n = Number(semaines); if (!Number.isInteger(n) || n < SEMAINES_MIN || n > SEMAINES_MAX) { setErreur(`Indiquez un nombre entier de semaines, entre ${SEMAINES_MIN} et ${SEMAINES_MAX}.`); return; } // Le formulaire n'est vidé qu'une fois le rappel réellement enregistré. if (await onAjouter({ codefou: f.code, nomfou: f.nom, semaines: n })) { setSaisie(""); setSemaines("4"); } } return (

Ajouter un rappel pour ce magasin

setSaisie(e.target.value)} placeholder="Tapez le nom ou le code…" autoComplete="off" aria-invalid={erreur !== null || undefined} aria-describedby={erreur ? idErreur : undefined} className="w-[300px] max-w-full" /> {fournisseurs.map((f) => (
setSemaines(e.target.value)} className="w-32" />
{erreur && ( )}
); } const TH = "whitespace-nowrap border-b border-[var(--border-strong)] px-3 py-2.5 text-left text-[13px] font-semibold text-[var(--text-secondary)]"; function SectionMagasin({ site, rows, fournisseurs, }: { site: string; rows: CadenceView[]; fournisseurs: Fournisseur[]; }) { const [pending, startTransition] = useTransition(); const nom = nomMagasin(site); const triees = useMemo(() => trier(rows), [rows]); const codesExistants = useMemo(() => new Set(rows.map((r) => r.codefou)), [rows]); const nbACommander = rows.filter((r) => r.actif && r.statut === "a_commander").length; /** * Exécute une action serveur et en rend compte : message de réussite, ou * message d'erreur (refus de l'action ou exception) — jamais d'échec muet. * Pas de router.refresh() : les actions appellent déjà revalidatePath, dont * la réponse rafraîchit la page. Les deux ensemble rechargeaient tout deux fois. */ function executer(action: () => Promise, messages: { succes: string; echec: string }): Promise { return new Promise((resolve) => { startTransition(async () => { try { const resultat = await action(); if (resultat.ok) toast.succes(messages.succes); else toast.erreur(resultat.error ?? messages.echec); resolve(resultat.ok); } catch (e) { console.error("[cadencier]", e); toast.erreur(`${messages.echec} Vérifiez votre connexion puis réessayez.`); resolve(false); } }); }); } const ajouter = (input: { codefou: string; nomfou: string; semaines: number }) => executer( () => upsertCadence({ codefou: input.codefou, nomfou: input.nomfou, site, intervalleSemaines: input.semaines, actif: true, }), { succes: `Rappel ajouté : ${input.nomfou}, toutes les ${pluriel(input.semaines, "semaine")}.`, echec: "Le rappel n'a pas pu être ajouté.", }, ); const changerSemaines = (r: CadenceView, semaines: number) => executer( () => upsertCadence({ codefou: r.codefou, nomfou: r.nomfou, site, intervalleSemaines: semaines, actif: r.actif, }), { succes: `Rythme enregistré : ${r.nomfou}, toutes les ${pluriel(semaines, "semaine")}.`, echec: "Le rythme n'a pas pu être enregistré.", }, ); const basculer = (r: CadenceView) => executer(() => toggleCadence(r.codefou, r.site, !r.actif), { succes: r.actif ? `Rappel désactivé : ${r.nomfou}.` : `Rappel réactivé : ${r.nomfou}.`, echec: r.actif ? "Le rappel n'a pas pu être désactivé." : "Le rappel n'a pas pu être réactivé.", }); async function supprimer(r: CadenceView) { const ok = await confirmer({ titre: "Supprimer ce rappel ?", message: `Le rythme de commande de ${r.nomfou} pour ${nom} sera supprimé. Pour simplement suspendre les alertes, utilisez plutôt « Désactiver ».`, libelleConfirmer: "Supprimer", danger: true, }); if (!ok) return; await executer(() => removeCadence(r.codefou, r.site), { succes: `Rappel supprimé : ${r.nomfou}.`, echec: "Le rappel n'a pas pu être supprimé.", }); } return (

{nom}

{pluriel(rows.length, "fournisseur")} suivi{rows.length > 1 ? "s" : ""} {nbACommander > 0 && ( {nbACommander} à commander )}
{triees.length === 0 ? ( ) : (
{triees.map((r) => { const urgent = r.actif && r.statut === "a_commander"; const texte = r.actif ? "text-[var(--text-primary)]" : "text-[var(--text-muted)]"; return ( ); })}
Fournisseur Rythme Dernière réception Prochaine commande Temps restant Statut Actions
{r.nomfou}
Code {r.codefou}
Toutes les { if (e.key === "Enter") e.currentTarget.blur(); }} onBlur={(e) => { const champ = e.currentTarget; const brut = champ.value.trim(); const v = Number(brut); if (brut !== "" && v === r.intervalleSemaines) return; if (brut === "" || !Number.isInteger(v) || v < SEMAINES_MIN || v > SEMAINES_MAX) { champ.value = String(r.intervalleSemaines); toast.erreur( `Rythme non enregistré : indiquez un nombre entier de semaines, entre ${SEMAINES_MIN} et ${SEMAINES_MAX}.`, ); return; } void changerSemaines(r, v).then((ok) => { if (!ok) champ.value = String(r.intervalleSemaines); }); }} className="h-8 w-20 px-2 text-center" /> semaines {fmtDate(r.derniereReception)} {fmtDate(r.echeance)} {tempsRestant(r.joursRestants)}
)}
); } export function CadencierClient({ cadences, fournisseurs, }: { cadences: CadenceView[]; fournisseurs: Fournisseur[]; }) { const parMagasin = useMemo(() => { const m = new Map(); for (const c of cadences) { if (!m.has(c.site)) m.set(c.site, []); m.get(c.site)!.push(c); } return m; }, [cadences]); return (

Le cadencier vous rappelle quand commander : pour chaque fournisseur suivi, choisissez un rythme (toutes les X semaines). La prochaine commande est calculée à partir de la{" "} dernière réception du fournisseur dans le magasin. Le statut passe à « Bientôt » dans les 7 jours qui précèdent, puis à « À commander » le jour venu.

{MAGASINS.map((m) => ( ))}
); }