261 lines
10 KiB
TypeScript
261 lines
10 KiB
TypeScript
"use client";
|
|
import { Search } 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"))}
|
|
>
|
|
<span className="block w-5 h-0.5 bg-slate-700 rounded" />
|
|
<span className="block w-5 h-0.5 bg-slate-700 rounded mt-1" />
|
|
<span className="block w-5 h-0.5 bg-slate-700 rounded mt-1" />
|
|
</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>
|
|
);
|
|
}
|