espace-paie-odentas/components/Header.tsx
2025-10-15 00:40:57 +02:00

259 lines
10 KiB
TypeScript

"use client";
import { Search, Menu } from "lucide-react";
import * as React from "react";
import { Command } from "cmdk";
import StatusEditModal from "./StatusEditModal";
type ClientInfo = {
id: string;
name: string;
api_name?: string;
user?: {
id: string;
email: string;
display_name?: string | null;
first_name?: string | null;
} | null;
} | null;
interface StatusData {
status_top_text: string;
status_bottom_text: string;
status_top_color: string;
status_bottom_color: string;
}
export default function Header({ clientInfo, isStaff }: {
clientInfo?: ClientInfo;
isStaff?: boolean;
}) {
const paletteRef = React.useRef<HTMLButtonElement | null>(null);
const [paletteH, setPaletteH] = React.useState<number | null>(null);
const [expandSection, setExpandSection] = React.useState<null | 'top' | 'bottom'>(null);
const topMsgRef = React.useRef<HTMLSpanElement | null>(null);
const botMsgRef = React.useRef<HTMLSpanElement | null>(null);
const [topTrunc, setTopTrunc] = React.useState(false);
const [botTrunc, setBotTrunc] = React.useState(false);
// État pour le modal et les données de statut
const [statusData, setStatusData] = React.useState<StatusData>({
status_top_text: 'Aucun incident à signaler.',
status_bottom_text: 'Nous serons exceptionnellement fermés le 3 novembre 2025.',
status_top_color: 'emerald',
status_bottom_color: 'sky'
});
const [isModalOpen, setIsModalOpen] = React.useState(false);
const [isLoading, setIsLoading] = React.useState(false);
// Fonction pour charger les données de statut
const loadStatusData = React.useCallback(async () => {
try {
const response = await fetch('/api/maintenance/status');
if (response.ok) {
const data = await response.json();
setStatusData(data);
}
} catch (error) {
console.error('Erreur lors du chargement des données de statut:', error);
}
}, []);
// Fonction pour sauvegarder les modifications
const handleSaveStatus = React.useCallback(async (newData: StatusData) => {
setIsLoading(true);
try {
const response = await fetch('/api/maintenance/status', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newData)
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Erreur lors de la sauvegarde');
}
const updatedData = await response.json();
setStatusData(updatedData);
} catch (error) {
throw error; // Re-lancer l'erreur pour que le modal la gère
} finally {
setIsLoading(false);
}
}, []);
// Fonction pour gérer le clic sur la card
const handleCardClick = React.useCallback(() => {
if (isStaff) {
setIsModalOpen(true);
}
}, [isStaff]);
// Fonction pour obtenir les classes CSS des couleurs
const getColorClasses = React.useCallback((color: string) => {
const colorMap: Record<string, string> = {
emerald: 'bg-emerald-500 ring-emerald-200/70',
red: 'bg-red-500 ring-red-200/70',
yellow: 'bg-yellow-500 ring-yellow-200/70',
orange: 'bg-orange-500 ring-orange-200/70',
sky: 'bg-sky-500 ring-sky-200/70',
blue: 'bg-blue-500 ring-blue-200/70',
purple: 'bg-purple-500 ring-purple-200/70',
pink: 'bg-pink-500 ring-pink-200/70',
gray: 'bg-gray-500 ring-gray-200/70'
};
return colorMap[color] || 'bg-gray-500 ring-gray-200/70';
}, []);
const getTextColorClasses = React.useCallback((color: string) => {
const textColorMap: Record<string, string> = {
emerald: 'text-emerald-700',
red: 'text-red-700',
yellow: 'text-yellow-700',
orange: 'text-orange-700',
sky: 'text-sky-700',
blue: 'text-blue-700',
purple: 'text-purple-700',
pink: 'text-pink-700',
gray: 'text-gray-700'
};
return textColorMap[color] || 'text-gray-700';
}, []);
const recomputeTrunc = React.useCallback(() => {
const isTrunc = (el: HTMLSpanElement | null) => !!el && el.scrollWidth > el.clientWidth + 1;
setTopTrunc(isTrunc(topMsgRef.current));
setBotTrunc(isTrunc(botMsgRef.current));
}, []);
// Charger les données de statut au montage
React.useEffect(() => {
loadStatusData();
}, [loadStatusData]);
React.useEffect(() => {
recomputeTrunc();
const onResize = () => recomputeTrunc();
window.addEventListener('resize', onResize);
const id = window.setInterval(recomputeTrunc, 1500);
return () => { window.removeEventListener('resize', onResize); window.clearInterval(id); };
}, [recomputeTrunc]);
React.useEffect(() => {
const update = () => {
if (paletteRef.current) setPaletteH(paletteRef.current.offsetHeight);
};
update();
window.addEventListener('resize', update);
return () => window.removeEventListener('resize', update);
}, []);
return (
<header className="sticky top-0 z-40 h-[var(--header-h)] border-b bg-white/80 backdrop-blur supports-[backdrop-filter]:bg-white/60">
<div className="h-full px-4 flex items-center gap-3">
<div className="flex items-center gap-3">
{/* Bouton hamburger mobile */}
<button
type="button"
className="md:hidden inline-flex items-center justify-center w-9 h-9 rounded-lg border bg-white shadow-sm hover:bg-slate-50"
aria-label="Ouvrir le menu"
onClick={() => window.dispatchEvent(new CustomEvent("open-mobile-sidebar"))}
>
<Menu className="w-5 h-5 text-slate-700" />
</button>
<img
src="/odentas-logo.png"
alt="Odentas"
className="w-20 h-20 rounded-lg object-contain"
/>
<div className="h-6 w-px bg-slate-200"/>
<span className="font-semibold">Espace Paie Odentas</span>
</div>
{/* Centre: card d'état (dynamique) */}
<div className="hidden md:flex flex-1 justify-center">
<div
className={`relative w-full max-w-[420px] rounded-xl border bg-white/70 shadow-sm overflow-hidden grid grid-rows-2 transition-all duration-150 ${
isStaff ? 'cursor-pointer hover:bg-white/80' : ''
}`}
style={{ height: expandSection ? undefined : (paletteH ? `${paletteH}px` : undefined) }}
onClick={handleCardClick}
title={isStaff ? 'Cliquer pour modifier les statuts' : undefined}
>
<div
className="px-3 py-1.5 text-xs flex items-center gap-2 border-b border-slate-200/70"
onMouseEnter={() => topTrunc && setExpandSection('top')}
onMouseLeave={() => setExpandSection(null)}
title={statusData.status_top_text}
>
<span className={`inline-block w-2.5 h-2.5 rounded-full ring-2 ${getColorClasses(statusData.status_top_color)}`} aria-hidden />
<span
ref={topMsgRef}
className={
`${getTextColorClasses(statusData.status_top_color)} ${expandSection==='top' ? 'whitespace-normal' : 'truncate whitespace-nowrap overflow-hidden'}`
}
>{statusData.status_top_text}</span>
{expandSection==='top' && topTrunc && (
<div className="absolute left-full ml-2 top-[25%] -translate-y-1/2 z-50">
<div className="rounded-lg border bg-white shadow p-2 text-xs text-slate-700 max-w-[360px]">
{statusData.status_top_text}
</div>
</div>
)}
</div>
<div
className="px-3 py-1.5 text-xs flex items-center gap-2"
onMouseEnter={() => botTrunc && setExpandSection('bottom')}
onMouseLeave={() => setExpandSection(null)}
title={statusData.status_bottom_text}
>
<span className={`inline-block w-2.5 h-2.5 rounded-full ring-2 ${getColorClasses(statusData.status_bottom_color)}`} aria-hidden />
<span
ref={botMsgRef}
className={
`${getTextColorClasses(statusData.status_bottom_color)} ${expandSection==='bottom' ? 'whitespace-normal' : 'truncate whitespace-nowrap overflow-hidden'}`
}
>{statusData.status_bottom_text}</span>
{expandSection==='bottom' && botTrunc && (
<div className="absolute left-full ml-2 top-[75%] -translate-y-1/2 z-50">
<div className="rounded-lg border bg-white shadow p-2 text-xs text-slate-700 max-w-[360px]">
{statusData.status_bottom_text}
</div>
</div>
)}
</div>
</div>
</div>
<div className="ml-auto flex items-center gap-2">
<button
ref={paletteRef}
onClick={() => window.dispatchEvent(new CustomEvent("open-global-search"))}
className="hidden sm:flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-left text-black shadow-sm bg-gradient-to-r from-indigo-200 via-purple-200 to-pink-200 hover:from-indigo-300 hover:via-purple-300 hover:to-pink-300 transition-colors"
aria-label="Palette de Commandes (⌘K ou Ctrl+K)"
title="Palette de Commandes — Recherchez et lancez des actions (⌘K sur Mac, Ctrl+K sur Windows)"
>
{/* Icône loupe occupant la hauteur des deux lignes */}
<span className="self-stretch flex items-center pr-2 mr-1 border-r border-black/10">
<Search className="w-5 h-5 opacity-80" aria-hidden />
</span>
<span className="flex flex-col">
<span className="opacity-80 font-medium">Palette de Commandes</span>
<span className="text-xs opacity-60">Rechercher et lancer des actions</span>
</span>
<span className="ml-2 inline-flex items-center gap-0.5 text-xs px-1.5 py-0.5 rounded bg-slate-900 text-white">
<span className="hidden md:inline"></span>K / Ctrl+K
</span>
</button>
</div>
</div>
{/* Modal de modification des statuts (staff uniquement) */}
{isStaff && (
<StatusEditModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onSave={handleSaveStatus}
initialData={statusData}
/>
)}
</header>
);
}