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

+311 -186
View File
@@ -1,209 +1,334 @@
"use client";
import { useState, useEffect } from "react";
import { useCallback, useEffect, useState, type FormEvent } from "react";
import { KeyRound, Loader2, Shield, Trash2, User, UserPlus } from "lucide-react";
import { getUsers, createUser, deleteUser, updatePassword } from "../api/user-actions";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input, Label, Select } from "@/components/ui/form-controls";
import { EmptyState, ErrorState, Skeleton } from "@/components/ui/states";
import { confirmer, toast } from "@/components/ui/feedback";
import {
UserPlus,
Trash2,
Shield,
User,
Loader2,
Check,
Key,
} from "lucide-react";
import { SuccessModal } from "@/components/shared/success-modal";
import { ChangePasswordModal } from "@/components/shared/change-password-modal";
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { isStaleServerActionError, STALE_ACTION_MESSAGE } from "@/lib/stale-action";
export function UserManagement() {
const [users, setUsers] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [isCreating, setIsCreating] = useState(false);
const [newUsername, setNewUsername] = useState("");
const [newPassword, setNewPassword] = useState("");
const [newRole, setNewRole] = useState<"admin" | "user">("user");
const [modal, setModal] = useState({ isOpen: false, title: "", message: "" });
const [pwdModal, setPwdModal] = useState<{ isOpen: boolean; userId: number; username: string }>({
isOpen: false, userId: 0, username: ""
});
type Utilisateur = Awaited<ReturnType<typeof getUsers>>[number];
type Role = "admin" | "user";
const fetchUsers = async () => {
setLoading(true);
try {
const data = await getUsers();
setUsers(data);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
const ROLES = [
{ value: "user", label: "Utilisateur" },
{ value: "admin", label: "Administrateur" },
] as const;
useEffect(() => {
fetchUsers();
}, []);
function messageErreur(e: unknown): string {
if (isStaleServerActionError(e)) return STALE_ACTION_MESSAGE;
return e instanceof Error ? e.message : String(e);
}
const handleCreate = async (e: React.FormEvent) => {
function fmtJour(valeur: Date | string | null | undefined): string {
if (!valeur) return "—";
const d = new Date(valeur);
return isNaN(d.getTime()) ? "—" : d.toLocaleDateString("fr-FR");
}
/**
* Changement du mot de passe d'un compte. Monté à l'ouverture (clé = compte) :
* les champs repartent vides à chaque fois.
*/
function DialogueMotDePasse({ cible, onFermer }: { cible: Utilisateur; onFermer: () => void }) {
const [motDePasse, setMotDePasse] = useState("");
const [confirmation, setConfirmation] = useState("");
const [erreur, setErreur] = useState<string | null>(null);
const [envoi, setEnvoi] = useState(false);
const valider = async (e: FormEvent) => {
e.preventDefault();
setIsCreating(true);
const res = await createUser(newUsername, newPassword, newRole);
if (res.success) {
setModal({
isOpen: true,
title: "Utilisateur Créé",
message: `L'utilisateur ${newUsername} a été ajouté avec succès.`
});
setNewUsername("");
setNewPassword("");
fetchUsers();
} else {
alert(res.error);
setErreur(null);
if (motDePasse.length < 4) {
setErreur("Le mot de passe doit faire au moins 4 caractères.");
return;
}
setIsCreating(false);
};
const handleUpdatePassword = (id: number, username: string) => {
setPwdModal({ isOpen: true, userId: id, username });
};
const handleConfirmPassword = async (newPassword: string) => {
const res = await updatePassword(pwdModal.userId, newPassword);
if (res.success) {
setModal({
isOpen: true,
title: "Mot de passe mis à jour",
message: `Le mot de passe de ${pwdModal.username} a été modifié avec succès.`
});
} else {
throw new Error(res.error ?? "Erreur lors de la mise à jour.");
if (motDePasse !== confirmation) {
setErreur("Les deux mots de passe ne sont pas identiques.");
return;
}
setEnvoi(true);
try {
const res = await updatePassword(cible.id, motDePasse);
if (!res.success) {
setErreur(res.error ?? "Erreur lors de la mise à jour.");
return;
}
toast.succes(`Le mot de passe de « ${cible.username} » a été modifié.`);
onFermer();
} catch (err) {
setErreur(messageErreur(err));
} finally {
setEnvoi(false);
}
};
const handleDelete = async (id: number, username: string) => {
if (!window.confirm(`Supprimer l'utilisateur ${username} ?`)) return;
const res = await deleteUser(id);
if (res.success) {
setUsers(users.filter(u => u.id !== id));
} else {
alert(res.error);
}
};
if (loading) {
return (
<div className="flex flex-col items-center justify-center p-12 space-y-4">
<Loader2 className="w-8 h-8 text-[var(--accent)] animate-spin" />
<p className="text-[var(--text-muted)] text-sm">Chargement des utilisateurs...</p>
</div>
);
}
return (
<div className="space-y-8">
{/* Formulaire de création */}
<div className="bg-[var(--bg-elevated)] border border-[var(--border)] rounded-2xl p-6">
<div className="flex items-center gap-4 mb-8">
<div className="w-12 h-12 rounded-2xl bg-[var(--accent-bg)] border border-[var(--accent-border)] flex items-center justify-center">
<UserPlus className="w-6 h-6 text-[var(--accent)]" />
<Dialog open onOpenChange={(open) => { if (!open && !envoi) onFermer(); }}>
<DialogContent className="sm:max-w-md">
<form onSubmit={valider} className="grid gap-4">
<DialogHeader>
<DialogTitle>Changer le mot de passe</DialogTitle>
<DialogDescription className="text-[var(--text-secondary)]">
Compte « {cible.username} ». Le nouveau mot de passe sera demandé dès la prochaine connexion.
</DialogDescription>
</DialogHeader>
<div>
<Label htmlFor="mdp-nouveau">Nouveau mot de passe</Label>
<Input id="mdp-nouveau" type="password" className="w-full" autoFocus autoComplete="new-password"
value={motDePasse} onChange={(e) => setMotDePasse(e.target.value)} />
<p className="mt-1 text-xs text-[var(--text-muted)]">Au moins 4 caractères.</p>
</div>
<div>
<h2 className="text-lg font-bold text-[var(--text-primary)]">Nouvel Utilisateur</h2>
<p className="text-[var(--text-secondary)] text-xs">Ajoutez un collaborateur à la plateforme.</p>
<Label htmlFor="mdp-confirmation">Confirmer le mot de passe</Label>
<Input id="mdp-confirmation" type="password" className="w-full" autoComplete="new-password"
value={confirmation} onChange={(e) => setConfirmation(e.target.value)} />
</div>
</div>
<form onSubmit={handleCreate} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-[var(--text-muted)] ml-1">Utilisateur</label>
<input
required
value={newUsername}
onChange={(e) => setNewUsername(e.target.value)}
className="apple-input"
placeholder="ex: jean.dupont"
/>
</div>
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-[var(--text-muted)] ml-1">Mot de passe</label>
<input
required
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="apple-input"
placeholder="••••••••"
/>
</div>
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-[var(--text-muted)] ml-1">Rôle</label>
<select
value={newRole}
onChange={(e) => setNewRole(e.target.value as any)}
className="apple-input"
>
<option value="user">Utilisateur</option>
<option value="admin">Administrateur</option>
</select>
</div>
</div>
<button
type="submit"
disabled={isCreating}
className="apple-btn-primary"
>
{isCreating ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
Créer le compte
</button>
{erreur && (
<p role="alert" className="text-[13px] text-[var(--accent-error)]">{erreur}</p>
)}
<DialogFooter>
<Button type="button" variant="outline" onClick={onFermer} disabled={envoi}>Annuler</Button>
<Button type="submit" disabled={envoi}>
{envoi && <Loader2 className="animate-spin" />}
Enregistrer le mot de passe
</Button>
</DialogFooter>
</form>
</div>
{/* Liste des utilisateurs */}
<div className="grid gap-3">
<h3 className="text-[10px] font-black uppercase tracking-widest text-[var(--text-muted)] ml-1 mb-1">Liste des comptes</h3>
{users.map((u) => (
<div key={u.id} className="flex items-center gap-5 p-4 rounded-xl bg-[var(--bg-surface)] border border-[var(--border)] hover:border-[var(--text-muted)] transition-all group">
<div className="w-10 h-10 rounded-xl bg-[var(--bg-elevated)] flex items-center justify-center shrink-0 border border-[var(--border)]">
{u.role === "admin" ? <Shield className="w-5 h-5 text-amber-500" /> : <User className="w-5 h-5 text-[var(--text-muted)]" />}
</DialogContent>
</Dialog>
);
}
export function UserManagement() {
const [users, setUsers] = useState<Utilisateur[]>([]);
const [chargement, setChargement] = useState(true);
const [erreurChargement, setErreurChargement] = useState<string | null>(null);
const [creation, setCreation] = useState(false);
const [newUsername, setNewUsername] = useState("");
const [newPassword, setNewPassword] = useState("");
const [newRole, setNewRole] = useState<Role>("user");
const [suppression, setSuppression] = useState<number | null>(null);
const [cibleMotDePasse, setCibleMotDePasse] = useState<Utilisateur | null>(null);
// Aucun setState avant le premier await : appelable depuis l'effet de montage.
const charger = useCallback(async () => {
try {
setUsers(await getUsers());
setErreurChargement(null);
} catch (e) {
setErreurChargement(messageErreur(e));
} finally {
setChargement(false);
}
}, []);
useEffect(() => { void charger(); }, [charger]);
const reessayer = () => {
setChargement(true);
void charger();
};
const handleCreate = async (e: FormEvent) => {
e.preventDefault();
setCreation(true);
try {
const res = await createUser(newUsername, newPassword, newRole);
if (res.success) {
toast.succes(`Le compte « ${newUsername} » a été créé : il peut se connecter dès maintenant.`);
setNewUsername("");
setNewPassword("");
void charger();
} else {
toast.erreur(res.error ?? "Le compte n'a pas pu être créé.");
}
} catch (err) {
toast.erreur(`Le compte n'a pas pu être créé : ${messageErreur(err)}`);
} finally {
setCreation(false);
}
};
const handleDelete = async (u: Utilisateur) => {
const ok = await confirmer({
titre: `Supprimer le compte « ${u.username} » ?`,
message: "Cette personne ne pourra plus se connecter à CollectFlow. La suppression est définitive.",
libelleConfirmer: "Supprimer",
danger: true,
});
if (!ok) return;
setSuppression(u.id);
try {
const res = await deleteUser(u.id);
if (res.success) {
setUsers((prev) => prev.filter((x) => x.id !== u.id));
toast.succes(`Le compte « ${u.username} » a été supprimé.`);
} else {
toast.erreur(res.error ?? "Le compte n'a pas pu être supprimé.");
}
} catch (err) {
toast.erreur(`Le compte n'a pas pu être supprimé : ${messageErreur(err)}`);
} finally {
setSuppression(null);
}
};
return (
<>
{/* Création */}
<Card>
<CardHeader
title="Ajouter un utilisateur"
description="Le compte peut se connecter dès sa création, avec cet identifiant et ce mot de passe."
/>
<CardContent>
<form onSubmit={handleCreate} className="space-y-4">
<div className="grid gap-4 md:grid-cols-3">
<div>
<Label htmlFor="nouvel-identifiant">Identifiant</Label>
<Input
id="nouvel-identifiant"
required
value={newUsername}
onChange={(e) => setNewUsername(e.target.value)}
className="w-full"
placeholder="ex. jean.dupont"
autoComplete="off"
/>
</div>
<div>
<Label htmlFor="nouveau-mdp">Mot de passe</Label>
<Input
id="nouveau-mdp"
required
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="w-full"
placeholder="••••••••"
autoComplete="new-password"
/>
</div>
<div>
<Select
id="nouveau-role"
label="Rôle"
value={newRole}
onChange={(v) => setNewRole(v as Role)}
options={ROLES}
className="w-full"
/>
<p className="mt-1 text-xs text-[var(--text-muted)]">
Un administrateur accède en plus à la synchronisation et aux paramètres.
</p>
</div>
</div>
<div className="flex-1">
<h4 className="text-sm font-bold text-[var(--text-primary)]">{u.username}</h4>
<span className="text-[10px] uppercase font-black text-[var(--text-muted)] tracking-wider">
{u.role === "admin" ? "Administrateur" : "Utilisateur Standard"}
</span>
<Button type="submit" disabled={creation}>
{creation ? <Loader2 className="animate-spin" /> : <UserPlus />}
Créer le compte
</Button>
</form>
</CardContent>
</Card>
{/* Liste */}
<Card>
<CardHeader
title="Comptes"
description="Les personnes autorisées à se connecter à CollectFlow."
actions={!chargement && !erreurChargement ? <Badge>{users.length} compte{users.length > 1 ? "s" : ""}</Badge> : undefined}
/>
<CardContent>
{chargement ? (
<div className="space-y-2" aria-busy="true" aria-label="Chargement des comptes">
{Array.from({ length: 4 }, (_, i) => <Skeleton key={i} className="h-10 w-full" />)}
</div>
<div className="flex items-center gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => handleUpdatePassword(u.id, u.username)}
className="p-2.5 rounded-lg hover:bg-[var(--accent-bg)] text-[var(--text-muted)] hover:text-[var(--accent)] transition-colors"
title="Changer le mot de passe"
>
<Key className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(u.id, u.username)}
className="p-2.5 rounded-lg hover:bg-[var(--accent-error-bg)] text-[var(--text-muted)] hover:text-[var(--accent-error)] transition-colors"
title="Supprimer"
>
<Trash2 className="w-4 h-4" />
</button>
) : erreurChargement ? (
<ErrorState
title="La liste des comptes n'a pas pu être chargée"
detail={erreurChargement}
action={<Button variant="outline" onClick={reessayer}>Réessayer</Button>}
/>
) : users.length === 0 ? (
<EmptyState
icon={User}
title="Aucun compte"
description="Créez le premier compte avec le formulaire ci-dessus."
/>
) : (
<div className="overflow-x-auto rounded-lg border border-[var(--border)]">
<table className="w-full">
<thead className="bg-[var(--bg-elevated)] text-[13px] font-semibold text-[var(--text-secondary)]">
<tr>
<th scope="col" className="px-3 py-2 text-left">Identifiant</th>
<th scope="col" className="px-3 py-2 text-left">Rôle</th>
<th scope="col" className="px-3 py-2 text-left">Créé le</th>
<th scope="col" className="px-3 py-2 text-right"><span className="sr-only">Actions</span></th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id} className="border-t border-[var(--border)] hover:bg-[var(--bg-elevated)]">
<td className="px-3 py-2 text-sm font-medium text-[var(--text-primary)]">
<span className="inline-flex items-center gap-2">
{u.role === "admin"
? <Shield className="h-4 w-4 text-[var(--accent)]" aria-hidden />
: <User className="h-4 w-4 text-[var(--text-muted)]" aria-hidden />}
{u.username}
</span>
</td>
<td className="px-3 py-2 text-sm">
{u.role === "admin"
? <Badge ton="accent">Administrateur</Badge>
: <Badge>Utilisateur</Badge>}
</td>
<td className="px-3 py-2 text-sm text-[var(--text-secondary)]">{fmtJour(u.createdAt)}</td>
<td className="px-3 py-2 text-right">
<div className="inline-flex items-center gap-1">
<Button variant="outline" size="sm" onClick={() => setCibleMotDePasse(u)}>
<KeyRound /> Mot de passe
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(u)}
disabled={suppression === u.id}
className="text-[var(--accent-error)] hover:bg-[var(--accent-error-bg)] hover:text-[var(--accent-error)]"
>
{suppression === u.id ? <Loader2 className="animate-spin" /> : <Trash2 />}
Supprimer
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
))}
</div>
<SuccessModal
isOpen={modal.isOpen}
onClose={() => setModal({ ...modal, isOpen: false })}
title={modal.title}
message={modal.message}
/>
<ChangePasswordModal
isOpen={pwdModal.isOpen}
onClose={() => setPwdModal({ ...pwdModal, isOpen: false })}
onConfirm={handleConfirmPassword}
username={pwdModal.username}
/>
</div>
)}
</CardContent>
</Card>
{cibleMotDePasse && (
<DialogueMotDePasse
key={cibleMotDePasse.id}
cible={cibleMotDePasse}
onFermer={() => setCibleMotDePasse(null)}
/>
)}
</>
);
}