- 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
337 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|