espace-paie-odentas/components/Sidebar.tsx

502 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useState, useEffect, useRef } from "react";
import { Shield, KeyRound, Home, FileSignature, Banknote, Users, Percent, FolderOpen, Receipt, Building2, UserCog, Edit3, LifeBuoy, CreditCard, Calculator, Euro, Mail, Database } from "lucide-react";
// import { api } from "@/lib/fetcher";
import { createPortal } from "react-dom";
import LogoutButton from "@/components/LogoutButton";
function AccessLink({ disabled, fullWidth = false }: { disabled: boolean; fullWidth?: boolean }) {
const btnRef = useRef<HTMLElement | null>(null);
const [tipOpen, setTipOpen] = useState(false);
const [tipPos, setTipPos] = useState<{ top: number; left: number } | null>(null);
function computePos() {
const el = btnRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
setTipPos({ top: r.top + window.scrollY, left: r.left + window.scrollX + r.width / 2 });
}
useEffect(() => {
if (!tipOpen) return;
const onScroll = () => computePos();
const onResize = () => computePos();
window.addEventListener('scroll', onScroll, true);
window.addEventListener('resize', onResize);
return () => {
window.removeEventListener('scroll', onScroll, true);
window.removeEventListener('resize', onResize);
};
}, [tipOpen]);
if (disabled) {
return (
<>
<span
ref={btnRef}
onMouseEnter={() => { computePos(); setTipOpen(true); }}
onMouseLeave={() => setTipOpen(false)}
onFocus={() => { computePos(); setTipOpen(true); }}
onBlur={() => setTipOpen(false)}
className={
`inline-flex items-center gap-1 px-2 py-1 rounded-md text-[11px] font-medium bg-emerald-50 text-emerald-700/60 cursor-not-allowed ${fullWidth ? 'w-full justify-center' : ''}`
}
role="link"
aria-disabled="true"
tabIndex={0}
>
<KeyRound className="w-3.5 h-3.5" />
Vos accès
</span>
{tipOpen && tipPos && createPortal(
(() => {
const top = tipPos.top - 10; // au-dessus
const left = tipPos.left;
return (
<div className="z-[1200]" style={{ position: 'fixed', top, left, transform: 'translate(-50%, -100%)' }}>
<div className="inline-block max-w-[300px] rounded-md border border-amber-400 bg-amber-50 text-amber-900 text-[12px] px-2 py-1 shadow">
Vous n'avez pas les habilitations nécessaires pour accéder à cette page
</div>
<div className="mx-auto w-0 h-0" style={{ borderLeft: '8px solid transparent', borderRight: '8px solid transparent', borderTop: '8px solid #f59e0b' }} />
</div>
);
})(),
document.body
)}
</>
);
}
return (
<Link
href="/vos-acces"
prefetch={false}
className={`inline-flex items-center gap-1 px-2 py-1 rounded-md text-[11px] font-medium bg-emerald-50 text-emerald-700 hover:bg-emerald-100 ${fullWidth ? 'w-full justify-center' : ''}`}
title={'Gérer les accès'}
>
<KeyRound className="w-3.5 h-3.5" />
Vos accès
</Link>
);
}
function DisabledMenuItem({ icon: Icon, label, tooltipMessage }: { icon: any; label: string; tooltipMessage: string }) {
const btnRef = useRef<HTMLElement | null>(null);
const [tipOpen, setTipOpen] = useState(false);
const [tipPos, setTipPos] = useState<{ top: number; left: number } | null>(null);
function computePos() {
const el = btnRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
// Position à droite de l'élément
setTipPos({
top: r.top + window.scrollY + r.height / 2,
left: r.right + window.scrollX + 8 // 8px de marge à droite
});
}
useEffect(() => {
if (!tipOpen) return;
const onScroll = () => computePos();
const onResize = () => computePos();
window.addEventListener('scroll', onScroll, true);
window.addEventListener('resize', onResize);
return () => {
window.removeEventListener('scroll', onScroll, true);
window.removeEventListener('resize', onResize);
};
}, [tipOpen]);
return (
<>
<span
ref={btnRef}
onMouseEnter={() => { computePos(); setTipOpen(true); }}
onMouseLeave={() => setTipOpen(false)}
onFocus={() => { computePos(); setTipOpen(true); }}
onBlur={() => setTipOpen(false)}
className="flex items-center justify-between px-3 py-2 rounded-xl text-sm transition truncate cursor-not-allowed opacity-50"
role="link"
aria-disabled="true"
tabIndex={0}
>
<span className="inline-flex items-center gap-2">
<Icon className="w-4 h-4" aria-hidden />
<span>{label}</span>
</span>
</span>
{tipOpen && tipPos && createPortal(
(() => {
const top = tipPos.top;
const left = tipPos.left;
return (
<div className="z-[1200] fixed" style={{ top, left, transform: 'translateY(-50%)' }}>
<div className="flex items-center">
{/* Flèche pointant vers la gauche */}
<div className="w-0 h-0" style={{
borderTop: '6px solid transparent',
borderBottom: '6px solid transparent',
borderRight: '6px solid rgb(17, 24, 39)' // gray-900
}} />
<div className="inline-block max-w-[280px] rounded-lg bg-gray-900 text-white text-sm px-3 py-2 shadow-xl">
{tooltipMessage}
</div>
</div>
</div>
);
})(),
document.body
)}
</>
);
}
type ClientInfo = {
id: string;
name: string;
api_name?: string;
user?: {
id: string;
email: string;
display_name?: string;
first_name?: string;
} | null;
} | null;
function isActivePath(pathname: string, href: string) {
if (href === "/") return pathname === "/"; // dashboard only on exact root
// Spécifique : l'onglet Contrats & Paies doit aussi être actif sur /contrats-multi
if (href === "/contrats") {
if (pathname === "/contrats" || pathname.startsWith("/contrats/")) return true;
if (pathname.startsWith("/contrats-multi")) return true;
if (pathname.startsWith("/contrats-rg")) return true;
return false;
}
if (href === "/virements-salaires") {
if (pathname === "/virements-salaires" || pathname.startsWith("/virements-salaires/")) return true;
return false;
}
return pathname === href || pathname.startsWith(href + "/");
}
const items = [
{ href: "/", label: "Tableau de bord", icon: Home },
{ href: "/contrats", label: "Contrats & Paies", icon: FileSignature },
{ href: "/signatures-electroniques", label: "Signatures électroniques", icon: Edit3 },
{ href: "/virements-salaires", label: "Virements de salaires", icon: Banknote },
{ href: "/salaries", label: "Salariés", icon: Users },
{ href: "/cotisations", label: "Cotisations", icon: Percent },
];
// Hook pour l'horloge en temps réel
function useCurrentTime() {
// Important: initialiser mais ne pas utiliser avant le montage pour éviter la
// divergence SSR/CSR (l'heure évolue entre le rendu serveur et l'hydratation)
const [currentTime, setCurrentTime] = useState(new Date());
useEffect(() => {
const timer = setInterval(() => {
setCurrentTime(new Date());
}, 1000);
return () => clearInterval(timer);
}, []);
return currentTime;
}
// Composant horloge
function LiveClock() {
// Ne rien afficher de dépendant du temps pendant l'hydratation
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const currentTime = useCurrentTime();
const timeString = mounted
? currentTime.toLocaleTimeString('fr-FR', {
timeZone: 'Europe/Paris',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
: '—:—:—';
const dateString = mounted
? currentTime.toLocaleDateString('fr-FR', {
timeZone: 'Europe/Paris',
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
})
: '—';
return (
<div className="mt-3 mb-3 mx-auto max-w-[280px] rounded-xl border bg-white p-2 text-center">
<div className="font-mono text-sm font-medium text-slate-700" suppressHydrationWarning>
{timeString}
</div>
<div className="text-xs text-slate-500 capitalize leading-tight" suppressHydrationWarning>
{dateString}
</div>
</div>
);
}
export default function Sidebar({ clientInfo, isStaff = false, mobile = false, onNavigate }: { clientInfo?: ClientInfo, isStaff?: boolean, mobile?: boolean, onNavigate?: () => void }) {
const pathname = usePathname();
const [canManageAccess, setCanManageAccess] = useState(false);
const [userRole, setUserRole] = useState<string | null>(null);
// Signature count in sidebar disabled to reduce Airtable load
useEffect(() => {
let cancelled = false;
async function run() {
try {
const res = await fetch('/api/me/role', { credentials: 'include', cache: 'no-store' });
if (!res.ok) throw new Error(String(res.status));
const j = await res.json();
const role = (j?.role ? String(j.role).toUpperCase() : null) as string | null;
if (!cancelled) {
setUserRole(role);
setCanManageAccess(role === 'ADMIN' || role === 'SUPER_ADMIN' || j?.isStaff === true);
}
} catch {
if (!cancelled) {
setUserRole(null);
setCanManageAccess(false);
}
}
}
run();
return () => { cancelled = true; };
}, [clientInfo?.id, clientInfo?.user?.id]);
// Removed: polling signatures count to avoid Airtable 429s
// Plus besoin de useState ou useEffect - les infos viennent directement du serveur
const orgName = clientInfo?.name || "Organisation";
const rawFirstName = clientInfo?.user?.first_name || clientInfo?.user?.display_name?.split(' ')[0] || null;
const userFirstName = rawFirstName
? rawFirstName.trim().replace(/^./, (ch) => ch.toLocaleUpperCase('fr-FR'))
: null;
return (
<aside className={`${mobile ? 'block' : 'hidden md:block'} w-full min-w-0 overflow-y-auto overflow-x-hidden px-2 box-border`}>
{/* Horloge en temps réel */}
<LiveClock />
<div className="mt-3 mx-auto max-w-[280px] rounded-2xl border bg-white p-4 text-sm mb-3">
<div className="font-medium truncate">👋 Bonjour{userFirstName ? ` ${userFirstName}` : ''}</div>
<div className="text-xs text-slate-500 mt-1">Votre structure</div>
<div className="mb-2 text-xs truncate" title={isStaff ? 'Odentas' : orgName}>{isStaff ? 'Odentas' : orgName}</div>
<div className="text-xs text-slate-500">Votre niveau d'accès</div>
<div className="mb-2 text-xs uppercase tracking-wide">{isStaff ? 'STAFF' : (userRole || '')}</div>
{!isStaff && (
<>
<div className="text-xs text-slate-500">Votre gestionnaire</div>
<div className="text-xs truncate">Renaud BREVIERE-ABRAHAM</div>
</>
)}
{/* Sous-card liens sécurité & accès (compacte) */}
<div className="mt-2 rounded-lg border bg-slate-50 p-2 overflow-hidden">
<div className="grid grid-cols-2 gap-2">
<Link
href="/compte/securite"
prefetch={false}
className="inline-flex items-center gap-1 px-2 py-1 rounded-md text-[11px] font-medium bg-indigo-50 text-indigo-700 hover:bg-indigo-100 w-full justify-center"
title="Sécurité du compte"
>
<Shield className="w-3.5 h-3.5" />
Sécurité
</Link>
{/* Vos accès: toujours visible, mais si pas habilité → curseur interdit + tooltip + blocage du clic */}
<AccessLink disabled={!canManageAccess} fullWidth />
{/* Support */}
<Link
href="/support"
prefetch={false}
className="inline-flex items-center gap-1 px-2 py-1 rounded-md text-[11px] font-medium bg-blue-50 text-blue-700 hover:bg-blue-100 w-full justify-center"
title="Support"
>
<LifeBuoy className="w-3.5 h-3.5" />
Support
</Link>
{/* Déconnexion compacte avec modale de confirmation */}
<LogoutButton variant="compact" className="w-full justify-center" />
</div>
</div>
</div>
<nav className="mt-3 mx-auto max-w-[280px] rounded-2xl border bg-white p-2">
{items.map((it) => {
const active = isActivePath(pathname, it.href);
const Icon = it.icon as any;
return (
<Link
key={it.href}
href={it.href}
onClick={() => onNavigate && onNavigate()}
className={`flex items-center justify-between px-3 py-2 rounded-xl text-sm transition truncate ${
active
? "bg-gradient-to-r from-indigo-200 via-purple-200 to-pink-200 text-slate-700 font-semibold"
: "hover:bg-slate-50"
}`}
title={it.label}
>
<span className="inline-flex items-center gap-2">
{Icon ? <Icon className="w-4 h-4" aria-hidden /> : null}
<span>{it.label}</span>
</span>
{/* Badge disabled to reduce Airtable load */}
</Link>
);
})}
</nav>
<div className="mt-3 mx-auto max-w-[280px] rounded-2xl border bg-white p-2">
<Link href="/facturation" onClick={() => onNavigate && onNavigate()} className={`block px-3 py-2 rounded-xl text-sm transition truncate ${
isActivePath(pathname, "/facturation") ? "bg-gradient-to-r from-indigo-200 via-purple-200 to-pink-200 text-slate-700 font-semibold" : "hover:bg-slate-50"
}`} title="Facturation">
<span className="inline-flex items-center gap-2">
<Receipt className="w-4 h-4" aria-hidden />
<span>Facturation</span>
</span>
</Link>
<Link href="/informations" onClick={() => onNavigate && onNavigate()} className={`block px-3 py-2 rounded-xl text-sm transition truncate ${
isActivePath(pathname, "/informations") ? "bg-gradient-to-r from-indigo-200 via-purple-200 to-pink-200 text-slate-700 font-semibold" : "hover:bg-slate-50"
}`} title="Vos informations">
<span className="inline-flex items-center gap-2">
<Building2 className="w-4 h-4" aria-hidden />
<span>Vos informations</span>
</span>
</Link>
<Link href="/vos-documents" onClick={() => onNavigate && onNavigate()} className={`block px-3 py-2 rounded-xl text-sm transition truncate ${
isActivePath(pathname, "/vos-documents") ? "bg-gradient-to-r from-indigo-200 via-purple-200 to-pink-200 text-slate-700 font-semibold" : "hover:bg-slate-50"
}`} title="Vos documents">
<span className="inline-flex items-center gap-2">
<FolderOpen className="w-4 h-4" aria-hidden />
<span>Vos documents</span>
</span>
</Link>
<DisabledMenuItem
icon={Euro}
label="Minima CCN"
tooltipMessage="Le simulateur et les minima seront de retour dans quelques jours."
/>
<DisabledMenuItem
icon={Calculator}
label="Simulateur de paie"
tooltipMessage="Le simulateur et les minima seront de retour dans quelques jours."
/>
</div>
{/* Menu Staff */}
{isStaff && (
<div className="mt-3 mx-auto max-w-[280px] rounded-2xl border bg-white p-2">
<div className="px-3 py-2 text-sm font-medium text-slate-600">
Staff
</div>
<Link href="/staff/tickets" onClick={() => onNavigate && onNavigate()} className={`block px-3 py-2 rounded-xl text-sm transition truncate ${
isActivePath(pathname, "/staff/tickets") ? "bg-gradient-to-r from-indigo-200 via-purple-200 to-pink-200 text-slate-700 font-semibold" : "hover:bg-slate-50"
}`} title="Tickets support">
<span className="inline-flex items-center gap-2">
<LifeBuoy className="w-4 h-4" aria-hidden />
<span>Tickets support</span>
</span>
</Link>
<Link href="/staff/contrats" onClick={() => onNavigate && onNavigate()} className={`block px-3 py-2 rounded-xl text-sm transition truncate ${
isActivePath(pathname, "/staff/contrats") ? "bg-gradient-to-r from-indigo-200 via-purple-200 to-pink-200 text-slate-700 font-semibold" : "hover:bg-slate-50"
}`} title="Gestion des contrats">
<span className="inline-flex items-center gap-2">
<FileSignature className="w-4 h-4" aria-hidden />
<span>Gestion des contrats</span>
</span>
</Link>
<Link href="/staff/salaries" onClick={() => onNavigate && onNavigate()} className={`block px-3 py-2 rounded-xl text-sm transition truncate ${
isActivePath(pathname, "/staff/salaries") ? "bg-gradient-to-r from-indigo-200 via-purple-200 to-pink-200 text-slate-700 font-semibold" : "hover:bg-slate-50"
}`} title="Gestion des salariés">
<span className="inline-flex items-center gap-2">
<Users className="w-4 h-4" aria-hidden />
<span>Gestion des salariés</span>
</span>
</Link>
<Link href="/staff/clients" onClick={() => onNavigate && onNavigate()} className={`block px-3 py-2 rounded-xl text-sm transition truncate ${
isActivePath(pathname, "/staff/clients") ? "bg-gradient-to-r from-indigo-200 via-purple-200 to-pink-200 text-slate-700 font-semibold" : "hover:bg-slate-50"
}`} title="Gestion des clients">
<span className="inline-flex items-center gap-2">
<Building2 className="w-4 h-4" aria-hidden />
<span>Gestion des clients</span>
</span>
</Link>
<Link href="/staff/facturation" onClick={() => onNavigate && onNavigate()} className={`block px-3 py-2 rounded-xl text-sm transition truncate ${
isActivePath(pathname, "/staff/facturation") ? "bg-gradient-to-r from-indigo-200 via-purple-200 to-pink-200 text-slate-700 font-semibold" : "hover:bg-slate-50"
}`} title="Gestion de la facturation">
<span className="inline-flex items-center gap-2">
<CreditCard className="w-4 h-4" aria-hidden />
<span>Facturation</span>
</span>
</Link>
<Link href="/staff/virements-salaires" onClick={() => onNavigate && onNavigate()} className={`block px-3 py-2 rounded-xl text-sm transition truncate ${
isActivePath(pathname, "/staff/virements-salaires") ? "bg-gradient-to-r from-indigo-200 via-purple-200 to-pink-200 text-slate-700 font-semibold" : "hover:bg-slate-50"
}`} title="Virements de salaires">
<span className="inline-flex items-center gap-2">
<Banknote className="w-4 h-4" aria-hidden />
<span>Virements salaires</span>
</span>
</Link>
<Link href="/staff/utilisateurs" onClick={() => onNavigate && onNavigate()} className={`block px-3 py-2 rounded-xl text-sm transition truncate ${
isActivePath(pathname, "/staff/utilisateurs") ? "bg-gradient-to-r from-indigo-200 via-purple-200 to-pink-200 text-slate-700 font-semibold" : "hover:bg-slate-50"
}`} title="Gestion des utilisateurs">
<span className="inline-flex items-center gap-2">
<UserCog className="w-4 h-4" aria-hidden />
<span>Gestion des utilisateurs</span>
</span>
</Link>
<Link href="/staff/emails-groupes" onClick={() => onNavigate && onNavigate()} className={`block px-3 py-2 rounded-xl text-sm transition truncate ${
isActivePath(pathname, "/staff/emails-groupes") ? "bg-gradient-to-r from-indigo-200 via-purple-200 to-pink-200 text-slate-700 font-semibold" : "hover:bg-slate-50"
}`} title="Envoi d'emails groupés">
<span className="inline-flex items-center gap-2">
<Mail className="w-4 h-4" aria-hidden />
<span>Emails groupés</span>
</span>
</Link>
<Link href="/staff/email-logs" onClick={() => onNavigate && onNavigate()} className={`block px-3 py-2 rounded-xl text-sm transition truncate ${
isActivePath(pathname, "/staff/email-logs") ? "bg-gradient-to-r from-indigo-200 via-purple-200 to-pink-200 text-slate-700 font-semibold" : "hover:bg-slate-50"
}`} title="Logs des emails">
<span className="inline-flex items-center gap-2">
<Database className="w-4 h-4" aria-hidden />
<span>Logs des emails</span>
</span>
</Link>
<Link href="/staff/documents" onClick={() => onNavigate && onNavigate()} className={`block px-3 py-2 rounded-xl text-sm transition truncate ${
isActivePath(pathname, "/staff/documents") ? "bg-gradient-to-r from-indigo-200 via-purple-200 to-pink-200 text-slate-700 font-semibold" : "hover:bg-slate-50"
}`} title="Gestion des documents">
<span className="inline-flex items-center gap-2">
<FolderOpen className="w-4 h-4" aria-hidden />
<span>Gestion des documents</span>
</span>
</Link>
</div>
)}
<div className="mt-3 mx-auto max-w-[280px] rounded-2xl border bg-white p-2 text-xs">
<div className="text-[11px] text-slate-400 flex flex-wrap items-center justify-center gap-1">
<span>
© 2021<span suppressHydrationWarning>{new Date().getFullYear()}</span> Odentas Media SAS
</span>
<span className="inline-flex items-center gap-1 whitespace-nowrap" title="Hébergement France">
<span role="img" aria-label="Drapeau français">🇫🇷</span>
<span>Données hébergées en France</span>
</span>
<a href="/mentions-legales" className="underline hover:text-slate-600 whitespace-nowrap">Mentions légales</a>
<span>•</span>
<a href="/politique-confidentialite" className="underline hover:text-slate-600 whitespace-nowrap">Confidentialité</a>
</div>
<div className="text-[11px] text-slate-400 mt-2 text-center">
v2.0.0
</div>
</div>
</aside>
);
}