feat: Améliorations du breadcrumb

- Labels CCN collés : CCNEAC, CCNPA, CCNSVP (au lieu de CCN EAC, etc.)
- Labels dynamiques : récupération automatique des noms de salariés, factures et tickets
- Badge Staff : icône Shield sur les routes /staff/* (visible sur liens et dernier segment)
- Hook useDynamicLabel pour charger les données en temps réel
- Amélioration de l'accessibilité avec aria-label
This commit is contained in:
odentas 2025-10-31 14:57:51 +01:00
parent 0f6687a88c
commit 35d5283434

View file

@ -2,8 +2,8 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { ChevronRight, Home } from "lucide-react";
import { useMemo } from "react";
import { ChevronRight, Home, Shield } from "lucide-react";
import { useMemo, useState, useEffect } from "react";
// Mapping des routes vers des labels lisibles
const ROUTE_LABELS: Record<string, string> = {
@ -19,9 +19,9 @@ const ROUTE_LABELS: Record<string, string> = {
"/vos-documents": "Vos documents",
"/vos-acces": "Gestion des accès",
"/minima-ccn": "Minima CCN",
"/minima-ccn/ccneac": "CCN EAC",
"/minima-ccn/ccnpa": "CCN PA",
"/minima-ccn/ccnsvp": "CCN SVP",
"/minima-ccn/ccneac": "CCNEAC",
"/minima-ccn/ccnpa": "CCNPA",
"/minima-ccn/ccnsvp": "CCNSVP",
"/simulateur": "Simulateur de paie",
"/support": "Support",
"/debug": "Debug",
@ -37,6 +37,77 @@ const ROUTE_LABELS: Record<string, string> = {
"/staff/analytics": "Analytics",
};
// Hook pour récupérer les labels dynamiques
function useDynamicLabel(segment: string, currentPath: string): string | null {
const [label, setLabel] = useState<string | null>(null);
useEffect(() => {
// Réinitialiser le label si le segment change
setLabel(null);
// Détecter le pattern et récupérer les données
const fetchLabel = async () => {
try {
// Pattern : /salaries/[matricule]
if (currentPath.match(/^\/salaries\/[^/]+$/)) {
const matricule = segment;
const res = await fetch(`/api/salaries?matricule=${matricule}`, {
credentials: 'include',
cache: 'no-store'
});
if (res.ok) {
const data = await res.json();
if (data.length > 0) {
const salarie = data[0];
setLabel(`${salarie.prenom} ${salarie.nom}`.trim() || matricule);
}
}
}
// Pattern : /staff/facturation/[id]
else if (currentPath.match(/^\/staff\/facturation\/[^/]+$/)) {
const id = segment;
const res = await fetch(`/api/staff/facturation/${id}`, {
credentials: 'include',
cache: 'no-store'
});
if (res.ok) {
const data = await res.json();
setLabel(data.period_label || `Facture ${id.substring(0, 8)}`);
}
}
// Pattern : /support/[id] (ticket)
else if (currentPath.match(/^\/support\/[^/]+$/)) {
const id = segment;
const res = await fetch(`/api/tickets/${id}`, {
credentials: 'include',
cache: 'no-store'
});
if (res.ok) {
const data = await res.json();
setLabel(data.subject || `Ticket #${id.substring(0, 8)}`);
}
}
} catch (error) {
// En cas d'erreur, ne rien faire (on garde le label par défaut)
console.debug('Breadcrumb: impossible de récupérer le label dynamique', error);
}
};
// Lancer la récupération seulement si on détecte un pattern connu
if (
currentPath.match(/^\/salaries\/[^/]+$/) ||
currentPath.match(/^\/staff\/facturation\/[^/]+$/) ||
currentPath.match(/^\/support\/[^/]+$/)
) {
fetchLabel();
}
}, [segment, currentPath]);
return label;
}
export default function Breadcrumb() {
const pathname = usePathname();
@ -45,13 +116,23 @@ export default function Breadcrumb() {
if (pathname === "/") return [];
const segments = pathname.split("/").filter(Boolean);
const crumbs: Array<{ label: string; href: string; isLast: boolean }> = [];
const crumbs: Array<{
label: string;
href: string;
isLast: boolean;
isStaff: boolean;
isDynamic: boolean;
segment: string;
}> = [];
// Toujours commencer par l'accueil
crumbs.push({
label: "Accueil",
href: "/",
isLast: false,
isStaff: false,
isDynamic: false,
segment: "",
});
// Construire le breadcrumb segment par segment
@ -59,13 +140,24 @@ export default function Breadcrumb() {
segments.forEach((segment, index) => {
currentPath += `/${segment}`;
const isLast = index === segments.length - 1;
const isStaff = currentPath.startsWith("/staff");
// Détection des patterns spéciaux (IDs, matricules, etc.)
let label = ROUTE_LABELS[currentPath];
let isDynamic = false;
if (!label) {
// Vérifier si c'est un pattern dynamique connu
if (
currentPath.match(/^\/salaries\/[^/]+$/) ||
currentPath.match(/^\/staff\/facturation\/[^/]+$/) ||
currentPath.match(/^\/support\/[^/]+$/)
) {
isDynamic = true;
label = "Chargement..."; // Temporaire, sera remplacé par le hook
}
// Si c'est un UUID ou un nombre (probablement un ID)
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(segment)) {
else if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(segment)) {
label = "Détails";
} else if (/^\d+$/.test(segment)) {
label = `#${segment}`;
@ -81,6 +173,9 @@ export default function Breadcrumb() {
label,
href: currentPath,
isLast,
isStaff,
isDynamic,
segment,
});
});
@ -96,32 +191,78 @@ export default function Breadcrumb() {
className="flex items-center gap-2 text-sm mb-4 px-1"
>
{breadcrumbs.map((crumb, index) => (
<div key={crumb.href} className="flex items-center gap-2">
{index > 0 && (
<ChevronRight className="w-3.5 h-3.5 text-slate-400 flex-shrink-0" aria-hidden />
)}
{crumb.isLast ? (
<span className="text-slate-700 font-medium truncate max-w-[200px]">
{crumb.label}
</span>
) : (
<Link
href={crumb.href}
className="text-slate-500 hover:text-indigo-600 transition truncate max-w-[150px] flex items-center gap-1.5"
title={crumb.label}
>
{index === 0 ? (
<>
<Home className="w-4 h-4 flex-shrink-0" aria-label="Accueil" />
<span className="hidden sm:inline">Accueil</span>
</>
) : (
crumb.label
)}
</Link>
)}
</div>
<BreadcrumbItem
key={crumb.href}
crumb={crumb}
index={index}
/>
))}
</nav>
);
}
// Composant pour un item de breadcrumb (permet d'utiliser le hook dynamique)
function BreadcrumbItem({
crumb,
index
}: {
crumb: {
label: string;
href: string;
isLast: boolean;
isStaff: boolean;
isDynamic: boolean;
segment: string;
};
index: number;
}) {
// Récupérer le label dynamique si nécessaire
const dynamicLabel = useDynamicLabel(
crumb.isDynamic ? crumb.segment : "",
crumb.isDynamic ? crumb.href : ""
);
// Utiliser le label dynamique si disponible, sinon le label statique
const displayLabel = dynamicLabel || crumb.label;
return (
<div className="flex items-center gap-2">
{index > 0 && (
<ChevronRight className="w-3.5 h-3.5 text-slate-400 flex-shrink-0" aria-hidden />
)}
{crumb.isLast ? (
<span className="text-slate-700 font-medium truncate max-w-[200px] flex items-center gap-1.5">
{displayLabel}
{crumb.isStaff && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-semibold bg-indigo-100 text-indigo-700 border border-indigo-200">
<Shield className="w-3 h-3" />
Staff
</span>
)}
</span>
) : (
<Link
href={crumb.href}
className="text-slate-500 hover:text-indigo-600 transition truncate max-w-[150px] flex items-center gap-1.5"
title={displayLabel}
>
{index === 0 ? (
<>
<Home className="w-4 h-4 flex-shrink-0" aria-label="Accueil" />
<span className="hidden sm:inline">Accueil</span>
</>
) : (
<>
{displayLabel}
{crumb.isStaff && (
<span className="inline-flex items-center gap-0.5 px-1 py-0.5 rounded text-xs font-semibold bg-indigo-100 text-indigo-700 border border-indigo-200">
<Shield className="w-2.5 h-2.5" />
</span>
)}
</>
)}
</Link>
)}
</div>
);
}