feat(ui): lot 3 — toutes les pages lisibles, cohérentes et en français clair

Chaque page reprend le socle du lot 2 : en-tête avec une phrase
d'explication, couleurs du thème (plus de pages figées en blanc), textes
de 12 px minimum, noms de magasins, infobulles sur le jargon, retours par
toast / confirmation, états vides et d'erreur expliqués.

- Accueil : CA, tickets et panier moyen d'hier vs N-1, par magasin, top 10
  avec lien vers la fiche produit.
- Révision d'assortiment : un seul endroit pour enregistrer et exporter
  (menu Export unique, mêmes droits pour tous), annulation confirmée, sens
  des gammes affiché partout, magasin choisi en un clic, colonnes nommées
  en clair, bandeau de progression au lieu de la fenêtre floue, droits lus
  côté serveur, ligne sélectionnée opaque sur les colonnes figées.
- Recherche produit, Stocks à surveiller (export FF inchangé), Meilleures
  ventes, Ventes par mois (dernier mois complet par défaut), Publicités,
  Commandes fournisseurs, Historique, Paramètres (onglets), Synchronisation
  et Connexion migrés sur DataTable / Tabs / Card.
- Le nom de l'utilisateur est transmis à la session (affiché dans
  l'en-tête).
- Composants devenus inutiles supprimés (anciennes fenêtres modales,
  export-dropdown, financial-cell, supplier-selection).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcA129WzGTH6zxPHFBGWmn
This commit is contained in:
Claude committed 2026-10-03 21:05:46 +00:00
1 parent 0c890c84c5
commit 220ca2263a
61 files changed
+6101 -6864

No files matched your search

@@ -1,15 +1,7 @@
"use client";
import { useMemo, useState, useTransition } from "react";
import {
AlertTriangle,
CalendarClock,
CheckCircle2,
HelpCircle,
Plus,
Power,
Trash2,
} from "lucide-react";
import { useMemo, useState, useTransition, type FormEvent } from "react";
import { AlertTriangle, CalendarClock, Plus, Power, Trash2 } from "lucide-react";
import {
upsertCadence,
toggleCadence,
@@ -17,41 +9,76 @@ import {
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";
const SITES: { code: string; label: string }[] = [
{ code: "292", label: "292 — Frouard / Nancy" },
{ code: "579", label: "579 — Houdemont" },
];
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 "—";
return new Date(iso).toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric" });
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 ${Math.abs(jours)} j`;
if (jours < 0) return `En retard de ${pluriel(Math.abs(jours), "jour")}`;
if (jours === 1) return "Demain";
return `Dans ${jours} j`;
return `Dans ${pluriel(jours, "jour")}`;
}
const STATUT_META: Record<CadenceStatut, { label: string; cls: string; Icon: typeof CheckCircle2 }> = {
a_commander: { label: "À commander", cls: "bg-red-100 text-red-700", Icon: AlertTriangle },
bientot: { label: "Bientôt", cls: "bg-amber-100 text-amber-700", Icon: CalendarClock },
ok: { label: "OK", cls: "bg-emerald-100 text-emerald-700", Icon: CheckCircle2 },
inconnu: { label: "Inconnu", cls: "bg-gray-100 text-gray-500", Icon: HelpCircle },
const STATUT_META: Record<CadenceStatut, { libelle: string; ton: Ton; explication: string }> = {
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({ statut }: { statut: CadenceStatut }) {
const { label, cls, Icon } = STATUT_META[statut];
function StatutBadge({ cadence }: { cadence: CadenceView }) {
if (!cadence.actif) {
return <Badge title="Rappel en pause : aucune alerte pour ce fournisseur.">Désactivé</Badge>;
}
const { libelle, ton, explication } = STATUT_META[cadence.statut];
return (
<span className={`inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-semibold ${cls}`}>
<Icon className="h-3 w-3" /> {label}
</span>
<Badge ton={ton} title={explication}>
{cadence.statut === "a_commander" && <AlertTriangle className="h-3.5 w-3.5" aria-hidden />}
{libelle}
</Badge>
);
}
/** 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<CadenceStatut, number> = { a_commander: 0, bientot: 1, ok: 2, inconnu: 3 };
@@ -64,248 +91,365 @@ function trier(rows: CadenceView[]): CadenceView[] {
});
}
function AddCadenceForm({
function FormulaireAjout({
site,
fournisseurs,
existingCodes,
onDone,
codesExistants,
onAjouter,
pending,
}: {
site: string;
fournisseurs: { code: string; nom: string }[];
existingCodes: Set<string>;
onDone: (input: { codefou: string; nomfou: string; semaines: number }) => void;
fournisseurs: Fournisseur[];
codesExistants: Set<string>;
onAjouter: (input: { codefou: string; nomfou: string; semaines: number }) => Promise<boolean>;
pending: boolean;
}) {
const [value, setValue] = useState("");
const [semaines, setSemaines] = useState(4);
const [err, setErr] = useState<string | null>(null);
const listId = `fou-list-${site}`;
const [saisie, setSaisie] = useState("");
const [semaines, setSemaines] = useState("4");
const [erreur, setErreur] = useState<string | null>(null);
const idFournisseur = `cadence-fournisseur-${site}`;
const idSemaines = `cadence-semaines-${site}`;
const idListe = `cadence-liste-${site}`;
const idErreur = `cadence-erreur-${site}`;
// label affiché dans le datalist → fournisseur
const byLabel = useMemo(() => {
const m = new Map<string, { code: string; nom: string }>();
// Libellé affiché dans la liste de suggestions → fournisseur
const parLibelle = useMemo(() => {
const m = new Map<string, Fournisseur>();
for (const f of fournisseurs) m.set(`${f.nom} — ${f.code}`, f);
return m;
}, [fournisseurs]);
function submit() {
setErr(null);
const f = byLabel.get(value.trim());
async function ajouter(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
setErreur(null);
const f = parLibelle.get(saisie.trim());
if (!f) {
setErr("Sélectionnez un fournisseur dans la liste.");
setErreur("Choisissez un fournisseur dans la liste proposée pendant la saisie.");
return;
}
if (existingCodes.has(f.code)) {
setErr("Ce fournisseur est déjà cadencé sur ce magasin.");
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;
}
if (!Number.isFinite(semaines) || semaines < 1 || semaines > 104) {
setErr("Intervalle : 1 à 104 semaines.");
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;
}
onDone({ codefou: f.code, nomfou: f.nom, semaines });
setValue("");
setSemaines(4);
// 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 (
<div className="flex flex-wrap items-end gap-3 rounded-xl border border-dashed border-gray-300 bg-gray-50 p-3">
<div className="flex flex-col">
<label className="mb-1 text-xs font-medium text-gray-500">Fournisseur</label>
<input
list={listId}
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="Rechercher…"
className="min-w-[260px] rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<datalist id={listId}>
{fournisseurs.map((f) => (
<option key={f.code} value={`${f.nom} — ${f.code}`} />
))}
</datalist>
<form
onSubmit={ajouter}
className="rounded-xl border border-dashed border-[var(--border-strong)] bg-[var(--bg-surface)] p-4"
>
<p className="mb-3 text-sm font-medium text-[var(--text-primary)]">Ajouter un rappel pour ce magasin</p>
<div className="flex flex-wrap items-end gap-3">
<div>
<Label htmlFor={idFournisseur}>Fournisseur</Label>
<Input
id={idFournisseur}
list={idListe}
value={saisie}
onChange={(e) => 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"
/>
<datalist id={idListe}>
{fournisseurs.map((f) => (
<option key={f.code} value={`${f.nom} — ${f.code}`} />
))}
</datalist>
</div>
<div>
<Label htmlFor={idSemaines}>Toutes les … semaines</Label>
<Input
id={idSemaines}
type="number"
inputMode="numeric"
min={SEMAINES_MIN}
max={SEMAINES_MAX}
step={1}
value={semaines}
onChange={(e) => setSemaines(e.target.value)}
className="w-32"
/>
</div>
<Button type="submit" disabled={pending}>
<Plus /> Ajouter le rappel
</Button>
</div>
<div className="flex flex-col">
<label className="mb-1 text-xs font-medium text-gray-500">Toutes les (semaines)</label>
<input
type="number"
min={1}
max={104}
value={semaines}
onChange={(e) => setSemaines(parseInt(e.target.value, 10))}
className="w-28 rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<button
type="button"
onClick={submit}
disabled={pending}
className="inline-flex items-center gap-1.5 rounded-lg bg-blue-600 px-3.5 py-2 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-blue-700 disabled:opacity-50"
>
<Plus className="h-4 w-4" /> Activer l&apos;alerte
</button>
{err && <span className="text-xs font-medium text-red-600">{err}</span>}
</div>
{erreur && (
<p id={idErreur} role="alert" className="mt-2 text-[13px] font-medium text-[var(--accent-error)]">
{erreur}
</p>
)}
</form>
);
}
function SiteSection({
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,
label,
rows,
fournisseurs,
}: {
site: string;
label: string;
rows: CadenceView[];
fournisseurs: { code: string; nom: string }[];
fournisseurs: Fournisseur[];
}) {
const [pending, startTransition] = useTransition();
const nom = nomMagasin(site);
const sorted = useMemo(() => trier(rows), [rows]);
const existingCodes = useMemo(() => new Set(rows.map((r) => r.codefou)), [rows]);
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;
// 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 run(fn: () => Promise<unknown>) {
startTransition(async () => {
await fn();
/**
* 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<Resultat>, messages: { succes: string; echec: string }): Promise<boolean> {
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 add = (input: { codefou: string; nomfou: string; semaines: number }) =>
run(() =>
upsertCadence({
codefou: input.codefou,
nomfou: input.nomfou,
site,
intervalleSemaines: input.semaines,
actif: true,
}),
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 changeSemaines = (r: CadenceView, semaines: number) =>
run(() =>
upsertCadence({
codefou: r.codefou,
nomfou: r.nomfou,
site,
intervalleSemaines: semaines,
actif: r.actif,
}),
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 (
<section className="space-y-3">
<div className="flex flex-wrap items-center gap-3">
<h2 className="text-lg font-bold text-gray-900">{label}</h2>
<span className="rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-semibold text-blue-700">
{rows.length} cadencé{rows.length !== 1 ? "s" : ""}
</span>
<section className="space-y-3" aria-label={`Rythme de commande — ${nom}`}>
<div className="flex flex-wrap items-center gap-2">
<h2 className="mr-1 text-lg font-semibold text-[var(--text-primary)]">{nom}</h2>
<Badge>
{pluriel(rows.length, "fournisseur")} suivi{rows.length > 1 ? "s" : ""}
</Badge>
{nbACommander > 0 && (
<span className="inline-flex items-center gap-1 rounded-full bg-red-100 px-2.5 py-0.5 text-xs font-semibold text-red-700">
<AlertTriangle className="h-3 w-3" /> {nbACommander} à commander
</span>
<Badge ton="erreur">
<AlertTriangle className="h-3.5 w-3.5" aria-hidden /> {nbACommander} à commander
</Badge>
)}
</div>
<AddCadenceForm
<FormulaireAjout
site={site}
fournisseurs={fournisseurs}
existingCodes={existingCodes}
onDone={add}
codesExistants={codesExistants}
onAjouter={ajouter}
pending={pending}
/>
<div className="overflow-x-auto rounded-xl border border-gray-200 bg-white shadow-sm">
<table className="min-w-full text-sm">
<thead className="border-b border-gray-200 bg-gray-50">
<tr>
<th className="px-3 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-gray-600">Code</th>
<th className="px-3 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-gray-600">Fournisseur</th>
<th className="px-3 py-2.5 text-center text-xs font-semibold uppercase tracking-wide text-gray-600">Toutes les</th>
<th className="px-3 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-gray-600">Dernière réception</th>
<th className="px-3 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-gray-600">Prochaine commande</th>
<th className="px-3 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-gray-600">Temps restant</th>
<th className="px-3 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-gray-600">Statut</th>
<th className="px-3 py-2.5 text-right text-xs font-semibold uppercase tracking-wide text-gray-600">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{sorted.length === 0 && (
{triees.length === 0 ? (
<EmptyState
icon={CalendarClock}
className="py-8"
title="Aucun rappel pour ce magasin"
description="Ajoutez un fournisseur avec le formulaire ci-dessus pour être prévenu quand il faut lui passer commande."
/>
) : (
<div
aria-busy={pending}
className="overflow-x-auto rounded-xl border border-[var(--border)] bg-[var(--bg-surface)] shadow-[var(--shadow-sm)]"
>
<table className="min-w-full text-sm">
<thead className="bg-[var(--bg-elevated)]">
<tr>
<td colSpan={8} className="px-3 py-8 text-center text-gray-400">
Aucun fournisseur cadencé sur ce magasin. Ajoutez-en un ci-dessus.
</td>
<th scope="col" className={TH}>Fournisseur</th>
<th scope="col" className={TH}>Rythme</th>
<th scope="col" className={TH}>Dernière réception</th>
<th scope="col" className={TH}>
<InfoBulle explication="Date de la dernière réception du fournisseur dans ce magasin, plus le rythme choisi.">
Prochaine commande
</InfoBulle>
</th>
<th scope="col" className={TH}>Temps restant</th>
<th scope="col" className={TH}>Statut</th>
<th scope="col" className={cn(TH, "text-right")}>Actions</th>
</tr>
)}
{sorted.map((r) => {
const urgent = r.actif && r.statut === "a_commander";
return (
<tr
key={`${r.site}-${r.codefou}`}
className={[
"transition-colors",
!r.actif ? "opacity-50" : "",
urgent ? "bg-red-50/60 hover:bg-red-50" : "hover:bg-gray-50",
].join(" ")}
>
<td className="px-3 py-2.5 font-mono text-xs text-gray-500">{r.codefou}</td>
<td className="px-3 py-2.5 font-medium text-gray-900">{r.nomfou}</td>
<td className="px-3 py-2.5 text-center">
<span className="inline-flex items-center gap-1">
<input
type="number"
min={1}
max={104}
defaultValue={r.intervalleSemaines}
onBlur={(e) => {
const v = parseInt(e.target.value, 10);
if (v && v !== r.intervalleSemaines && v >= 1 && v <= 104) changeSemaines(r, v);
}}
className="w-16 rounded-md border border-gray-200 px-2 py-1 text-center text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<span className="text-xs text-gray-400">sem.</span>
</span>
</td>
<td className="px-3 py-2.5 text-gray-600">{fmtDate(r.derniereReception)}</td>
<td className="px-3 py-2.5 font-medium text-gray-800">{fmtDate(r.echeance)}</td>
<td className={["px-3 py-2.5 font-semibold", urgent ? "text-red-700" : "text-gray-700"].join(" ")}>
{tempsRestant(r.joursRestants)}
</td>
<td className="px-3 py-2.5">
<StatutBadge statut={r.actif ? r.statut : "inconnu"} />
</td>
<td className="px-3 py-2.5">
<div className="flex items-center justify-end gap-1">
<button
type="button"
title={r.actif ? "Désactiver" : "Réactiver"}
onClick={() => run(() => toggleCadence(r.codefou, r.site, !r.actif))}
disabled={pending}
className="rounded-md p-1.5 text-gray-500 hover:bg-gray-100 hover:text-gray-800 disabled:opacity-50"
>
<Power className="h-4 w-4" />
</button>
<button
type="button"
title="Supprimer"
onClick={() => run(() => removeCadence(r.codefou, r.site))}
disabled={pending}
className="rounded-md p-1.5 text-gray-500 hover:bg-red-100 hover:text-red-700 disabled:opacity-50"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</thead>
<tbody>
{triees.map((r) => {
const urgent = r.actif && r.statut === "a_commander";
const texte = r.actif ? "text-[var(--text-primary)]" : "text-[var(--text-muted)]";
return (
<tr
key={`${r.site}-${r.codefou}`}
className={cn(
"border-b border-[var(--border)] last:border-b-0",
urgent ? "bg-[var(--accent-error-bg)]" : "hover:bg-[var(--bg-elevated)]",
)}
>
<td className={cn("px-3 py-2", texte)}>
<div className="font-medium">{r.nomfou}</div>
<div className="text-xs text-[var(--text-muted)]">Code {r.codefou}</div>
</td>
<td className={cn("whitespace-nowrap px-3 py-2", texte)}>
<span className="inline-flex items-center gap-2">
Toutes les
<Input
// Remonté quand la valeur enregistrée change (après revalidation).
key={r.intervalleSemaines}
type="number"
inputMode="numeric"
min={SEMAINES_MIN}
max={SEMAINES_MAX}
step={1}
defaultValue={r.intervalleSemaines}
aria-label={`Rythme de commande de ${r.nomfou}, en semaines`}
title="Modifiez le nombre puis quittez le champ (ou appuyez sur Entrée) pour l'enregistrer."
onKeyDown={(e) => {
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
</span>
</td>
<td className={cn("whitespace-nowrap px-3 py-2 tabular-nums", r.actif ? "text-[var(--text-secondary)]" : texte)}>
{fmtDate(r.derniereReception)}
</td>
<td className={cn("whitespace-nowrap px-3 py-2 font-medium tabular-nums", texte)}>
{fmtDate(r.echeance)}
</td>
<td
className={cn(
"whitespace-nowrap px-3 py-2 font-semibold",
urgent ? "text-[var(--accent-error)]" : texte,
)}
>
{tempsRestant(r.joursRestants)}
</td>
<td className="px-3 py-2">
<StatutBadge cadence={r} />
</td>
<td className="px-3 py-2">
<div className="flex items-center justify-end gap-1.5">
<Tooltip
content={
r.actif
? "Mettre les alertes en pause, sans perdre le rythme choisi."
: "Reprendre les alertes pour ce fournisseur."
}
>
<Button
variant="outline"
size="sm"
onClick={() => void basculer(r)}
disabled={pending}
aria-label={`${r.actif ? "Désactiver" : "Réactiver"} le rappel de ${r.nomfou}`}
>
<Power /> {r.actif ? "Désactiver" : "Réactiver"}
</Button>
</Tooltip>
<Tooltip content="Supprimer définitivement ce rappel.">
<Button
variant="ghost"
size="sm"
onClick={() => void supprimer(r)}
disabled={pending}
aria-label={`Supprimer le rappel de ${r.nomfou}`}
className="text-[var(--accent-error)] hover:bg-[var(--accent-error-bg)] hover:text-[var(--accent-error)]"
>
<Trash2 /> Supprimer
</Button>
</Tooltip>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</section>
);
}
@@ -315,9 +459,9 @@ export function CadencierClient({
fournisseurs,
}: {
cadences: CadenceView[];
fournisseurs: { code: string; nom: string }[];
fournisseurs: Fournisseur[];
}) {
const bySite = useMemo(() => {
const parMagasin = useMemo(() => {
const m = new Map<string, CadenceView[]>();
for (const c of cadences) {
if (!m.has(c.site)) m.set(c.site, []);
@@ -328,19 +472,17 @@ export function CadencierClient({
return (
<div className="space-y-10">
<p className="rounded-lg border border-blue-100 bg-blue-50 px-4 py-3 text-sm text-blue-800">
Activez un fournisseur en alerte de commande sur un magasin et définissez sa fréquence
(toutes les X semaines). L&apos;échéance est calculée à partir de la{" "}
<strong>dernière réception</strong> du fournisseur sur le magasin.
</p>
{SITES.map(({ code, label }) => (
<SiteSection
key={code}
site={code}
label={label}
rows={bySite.get(code) ?? []}
fournisseurs={fournisseurs}
/>
<div className="flex items-start gap-3 rounded-xl border border-[var(--accent-border)] bg-[var(--accent-bg)] px-4 py-3 text-sm text-[var(--text-primary)]">
<CalendarClock className="mt-0.5 h-5 w-5 shrink-0 text-[var(--accent)]" aria-hidden />
<p>
Le <Terme id="cadencier">cadencier</Terme> vous rappelle quand commander : pour chaque fournisseur suivi,
choisissez un rythme (toutes les X semaines). La prochaine commande est calculée à partir de la{" "}
<strong>dernière réception</strong> du fournisseur dans le magasin. Le statut passe à « Bientôt » dans les
7 jours qui précèdent, puis à « À commander » le jour venu.
</p>
</div>
{MAGASINS.map((m) => (
<SectionMagasin key={m.code} site={m.code} rows={parMagasin.get(m.code) ?? []} fournisseurs={fournisseurs} />
))}
</div>
);