espace-paie-odentas/components/Breadcrumb.tsx
odentas 73e914a303 feat: Système de statuts enrichi avec descriptions markdown et refonte navigation
- Header: Ajout 3ème ligne de statut (Caisses & orga.) avec descriptions détaillées
- Tooltips: Affichage riche avec titre, voyant coloré et contenu markdown formaté
- Éditeur markdown: Nouveau composant RichTextEditor avec toolbar (gras, italique, liens, listes)
- Modal staff: StatusEditModal étendu avec onglets et éditeur de descriptions
- Migration: Ajout colonnes status_*_description dans maintenance_status
- API: Routes GET/PUT enrichies pour gérer les 9 champs de statut
- Navigation: Redirection /compte/securite → /securite (nouvelle page centralisée)
- Breadcrumb: Support contrats RG/CDDU multi + labels dynamiques salariés
- UX Documents: Bouton 'Nouvel onglet / Télécharger' au lieu de téléchargement forcé
- Contrats staff: Pagination paies (6/page) pour RG et CDDU multi-mois avec vue compacte
- PayslipCard: Bouton cliquable 'Ouvrir le PDF' pour accès direct aux bulletins
2025-10-31 19:42:25 +01:00

337 lines
11 KiB
TypeScript

"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
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> = {
"/": "Tableau de bord",
"/contrats": "Contrats & Paies",
"/contrats-rg": "Contrats & Paies",
"/contrats-multi": "Contrats & Paies",
"/salaries": "Salariés",
"/salaries/nouveau": "Nouveau salarié",
"/signatures-electroniques": "Signatures électroniques",
"/virements-salaires": "Virements de salaires",
"/cotisations": "Cotisations",
"/facturation": "Facturation",
"/informations": "Vos informations",
"/vos-documents": "Vos documents",
"/vos-acces": "Gestion des accès",
"/vos-acces/nouveau": "Nouvel utilisateur",
"/securite": "Sécurité",
"/minima-ccn": "Minima CCN",
"/minima-ccn/ccneac": "Entreprises Artistiques & Culturelles",
"/minima-ccn/ccnpa": "Production Audiovisuelle",
"/minima-ccn/ccnsvp": "Spectacle Vivant Privé",
"/simulateur": "Simulateur de paie",
"/support": "Support",
"/debug": "Debug",
"/staff": "Staff",
"/staff/tickets": "Tickets support",
"/staff/contrats": "Contrats",
"/staff/avenants": "Avenants",
"/staff/payslips": "Fiches de paie",
"/staff/salaries": "Salariés",
"/staff/facturation": "Facturation",
"/staff/virements-salaires": "Virements de salaires",
"/staff/organisations": "Organisations",
"/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?search=${encodeURIComponent(matricule)}`, {
credentials: 'include',
cache: 'no-store'
});
if (res.ok) {
const data = await res.json();
if (data.items && data.items.length > 0) {
// Trouver le salarié exact avec le bon matricule
const salarie = data.items.find((s: any) =>
s.matricule === matricule || s.code_salarie === matricule
);
if (salarie) {
// Format: NOM Prénom (nom de famille en majuscules)
// Extraire nom et prénom du champ nom complet
const parts = salarie.nom?.split(' ') || [];
if (parts.length >= 2) {
const nom = parts[0]?.toUpperCase();
const prenom = parts.slice(1).join(' ');
setLabel(`${nom} ${prenom}`.trim());
} else {
setLabel(salarie.nom || 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)}`);
}
}
// Pattern : /contrats-multi/[id] (CDDU multi-mois)
else if (currentPath.match(/^\/contrats-multi\/[^/]+$/)) {
const id = segment;
const res = await fetch(`/api/contrats/${id}`, {
credentials: 'include',
cache: 'no-store'
});
if (res.ok) {
const data = await res.json();
setLabel(`CDDU multi-mois ${data.numero || id.substring(0, 8)}`);
}
}
// Pattern : /contrats-rg/[id] (Contrat Régime Général)
else if (currentPath.match(/^\/contrats-rg\/[^/]+$/)) {
const id = segment;
const res = await fetch(`/api/contrats/${id}`, {
credentials: 'include',
cache: 'no-store'
});
if (res.ok) {
const data = await res.json();
setLabel(`Contrat Régime Général ${data.numero || id.substring(0, 8)}`);
}
}
// Pattern : /contrats/[id] (CDDU mono-mois ou autre)
else if (currentPath.match(/^\/contrats\/[^/]+$/)) {
const id = segment;
const res = await fetch(`/api/contrats/${id}`, {
credentials: 'include',
cache: 'no-store'
});
if (res.ok) {
const data = await res.json();
setLabel(`CDDU ${data.numero || 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\/[^/]+$/) ||
currentPath.match(/^\/contrats-multi\/[^/]+$/) ||
currentPath.match(/^\/contrats-rg\/[^/]+$/) ||
currentPath.match(/^\/contrats\/[^/]+$/)
) {
fetchLabel();
}
}, [segment, currentPath]);
return label;
}
export default function Breadcrumb() {
const pathname = usePathname();
const breadcrumbs = useMemo(() => {
// Ne pas afficher sur la page d'accueil
if (pathname === "/") return [];
const segments = pathname.split("/").filter(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
let currentPath = "";
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\/[^/]+$/) ||
currentPath.match(/^\/contrats-multi\/[^/]+$/) ||
currentPath.match(/^\/contrats-rg\/[^/]+$/) ||
currentPath.match(/^\/contrats\/[^/]+$/)
) {
isDynamic = true;
// Label temporaire selon le type de page
if (currentPath.match(/^\/salaries\/[^/]+$/)) {
label = "Salarié";
} else {
label = "Chargement...";
}
}
// Si c'est un UUID ou un nombre (probablement un ID)
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}`;
} else {
// Fallback : capitaliser et remplacer les tirets
label = segment
.replace(/-/g, " ")
.replace(/\b\w/g, (l) => l.toUpperCase());
}
}
crumbs.push({
label,
href: currentPath,
isLast,
isStaff,
isDynamic,
segment,
});
});
return crumbs;
}, [pathname]);
// Ne rien afficher si c'est la page d'accueil
if (breadcrumbs.length === 0) return null;
return (
<nav
aria-label="Fil d'Ariane"
className="flex items-center gap-2 text-sm mb-4 px-1"
>
{breadcrumbs.map((crumb, index) => (
<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-[400px] 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-[300px] 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>
);
}