- Ajouter import HelpCircle depuis lucide-react - Afficher petite icône ? (3x3px) à droite de chaque ligne avec description - Icône visible uniquement sur les lignes avec tooltip disponible - Design discret (text-slate-400) sans augmenter la hauteur du header
354 lines
14 KiB
TypeScript
354 lines
14 KiB
TypeScript
"use client";
|
|
import { Search, Menu, HelpCircle } from "lucide-react";
|
|
import * as React from "react";
|
|
import { Command } from "cmdk";
|
|
import Link from "next/link";
|
|
import StatusEditModal from "./StatusEditModal";
|
|
import { Tooltip } from "@/components/ui/tooltip";
|
|
|
|
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_middle_text: string;
|
|
status_bottom_text: string;
|
|
status_top_color: string;
|
|
status_middle_color: string;
|
|
status_bottom_color: string;
|
|
status_top_description?: string;
|
|
status_middle_description?: string;
|
|
status_bottom_description?: string;
|
|
}
|
|
|
|
export default function Header({ clientInfo, isStaff }: {
|
|
clientInfo?: ClientInfo;
|
|
isStaff?: boolean;
|
|
}) {
|
|
const paletteRef = React.useRef<HTMLDivElement | null>(null);
|
|
const [paletteH, setPaletteH] = React.useState<number | null>(null);
|
|
|
|
// État pour le modal et les données de statut
|
|
const [statusData, setStatusData] = React.useState<StatusData>({
|
|
status_top_text: '',
|
|
status_middle_text: '',
|
|
status_bottom_text: '',
|
|
status_top_color: 'emerald',
|
|
status_middle_color: 'blue',
|
|
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';
|
|
}, []);
|
|
|
|
// Fonction pour convertir le markdown en HTML
|
|
const renderMarkdown = React.useCallback((text: string) => {
|
|
let html = text;
|
|
|
|
// Gras
|
|
html = html.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
|
|
|
|
// Italique
|
|
html = html.replace(/\*(.*?)\*/g, '<em>$1</em>');
|
|
|
|
// Liens
|
|
html = html.replace(/\[(.*?)\]\((.*?)\)/g, '<a href="$2" class="text-blue-600 hover:underline" target="_blank" rel="noopener noreferrer">$1</a>');
|
|
|
|
// Listes - d'abord identifier les blocs de listes
|
|
const lines = html.split('\n');
|
|
let inList = false;
|
|
const processedLines: string[] = [];
|
|
|
|
lines.forEach((line) => {
|
|
if (line.trim().startsWith('• ')) {
|
|
if (!inList) {
|
|
processedLines.push('<ul class="list-disc list-inside space-y-0.5">');
|
|
inList = true;
|
|
}
|
|
processedLines.push(`<li class="ml-4">${line.trim().substring(2)}</li>`);
|
|
} else {
|
|
if (inList) {
|
|
processedLines.push('</ul>');
|
|
inList = false;
|
|
}
|
|
processedLines.push(line);
|
|
}
|
|
});
|
|
|
|
if (inList) {
|
|
processedLines.push('</ul>');
|
|
}
|
|
|
|
html = processedLines.join('\n');
|
|
|
|
// Retours à la ligne (sauf dans les listes)
|
|
html = html.replace(/\n(?![^\n]*<\/?(ul|li))/g, '<br />');
|
|
|
|
return html;
|
|
}, []);
|
|
|
|
// Fonction pour créer le contenu HTML du tooltip avec titre et voyant
|
|
const createTooltipContent = React.useCallback((title: string, description: string, color: string) => {
|
|
const colorClasses = getColorClasses(color);
|
|
return `
|
|
<div class="space-y-3">
|
|
<div class="flex items-center gap-2.5 pb-2 border-b border-slate-200">
|
|
<span class="inline-block w-3 h-3 rounded-full ${colorClasses}"></span>
|
|
<span class="font-semibold text-slate-900">${title}</span>
|
|
</div>
|
|
<div class="text-sm text-slate-700 leading-relaxed">
|
|
${renderMarkdown(description)}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}, [getColorClasses, renderMarkdown]);
|
|
|
|
// Charger les données de statut au montage
|
|
React.useEffect(() => {
|
|
loadStatusData();
|
|
}, [loadStatusData]);
|
|
|
|
React.useEffect(() => {
|
|
const update = () => {
|
|
if (paletteRef.current) setPaletteH(paletteRef.current.offsetHeight);
|
|
};
|
|
update();
|
|
window.addEventListener('resize', update);
|
|
return () => window.removeEventListener('resize', update);
|
|
}, [statusData]); // Re-calculer quand le statusData change
|
|
|
|
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>
|
|
<Link href="/" className="flex items-center gap-3 hover:opacity-80 transition-opacity">
|
|
<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>
|
|
</Link>
|
|
</div>
|
|
{/* Centre: card d'état (dynamique) */}
|
|
<div className="hidden md:flex flex-1 justify-center items-center min-w-0 mx-2">
|
|
<div
|
|
ref={paletteRef}
|
|
className={`relative w-full max-w-[420px] h-fit rounded-xl border bg-white/70 shadow-sm overflow-hidden grid grid-rows-3 transition-all duration-150 ${
|
|
isStaff ? 'cursor-pointer hover:bg-white/80' : ''
|
|
}`}
|
|
onClick={handleCardClick}
|
|
title={isStaff ? 'Cliquer pour modifier les statuts' : undefined}
|
|
>
|
|
{/* Ligne 1 : Services Odentas */}
|
|
{statusData.status_top_description ? (
|
|
<Tooltip
|
|
content={createTooltipContent('Services Odentas', statusData.status_top_description, statusData.status_top_color)}
|
|
side="bottom"
|
|
allowHTML={true}
|
|
>
|
|
<div className="px-3 py-1.5 text-xs flex items-center gap-2 border-b border-slate-200/70 min-w-0 transition-colors hover:bg-slate-50/50 cursor-help">
|
|
<span className={`inline-block w-2.5 h-2.5 rounded-full ring-2 flex-shrink-0 ${getColorClasses(statusData.status_top_color)}`} aria-hidden />
|
|
<span
|
|
className={`${getTextColorClasses(statusData.status_top_color)} truncate block min-w-0 flex-1`}
|
|
>
|
|
Services Odentas : {statusData.status_top_text}
|
|
</span>
|
|
<HelpCircle className="w-3 h-3 text-slate-400 flex-shrink-0" />
|
|
</div>
|
|
</Tooltip>
|
|
) : (
|
|
<div className="px-3 py-1.5 text-xs flex items-center gap-2 border-b border-slate-200/70 min-w-0">
|
|
<span className={`inline-block w-2.5 h-2.5 rounded-full ring-2 flex-shrink-0 ${getColorClasses(statusData.status_top_color)}`} aria-hidden />
|
|
<span
|
|
className={`${getTextColorClasses(statusData.status_top_color)} truncate block min-w-0 flex-1`}
|
|
>
|
|
Services Odentas : {statusData.status_top_text}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Ligne 2 : Caisses & orga. */}
|
|
{statusData.status_middle_description ? (
|
|
<Tooltip
|
|
content={createTooltipContent('Caisses & organismes', statusData.status_middle_description, statusData.status_middle_color)}
|
|
side="bottom"
|
|
allowHTML={true}
|
|
>
|
|
<div className="px-3 py-1.5 text-xs flex items-center gap-2 border-b border-slate-200/70 min-w-0 transition-colors hover:bg-slate-50/50 cursor-help">
|
|
<span className={`inline-block w-2.5 h-2.5 rounded-full ring-2 flex-shrink-0 ${getColorClasses(statusData.status_middle_color)}`} aria-hidden />
|
|
<span
|
|
className={`${getTextColorClasses(statusData.status_middle_color)} truncate block min-w-0 flex-1`}
|
|
>
|
|
{statusData.status_middle_text && `Caisses & orga. : ${statusData.status_middle_text}`}
|
|
</span>
|
|
<HelpCircle className="w-3 h-3 text-slate-400 flex-shrink-0" />
|
|
</div>
|
|
</Tooltip>
|
|
) : (
|
|
<div className="px-3 py-1.5 text-xs flex items-center gap-2 border-b border-slate-200/70 min-w-0">
|
|
<span className={`inline-block w-2.5 h-2.5 rounded-full ring-2 flex-shrink-0 ${getColorClasses(statusData.status_middle_color)}`} aria-hidden />
|
|
<span
|
|
className={`${getTextColorClasses(statusData.status_middle_color)} truncate block min-w-0 flex-1`}
|
|
>
|
|
{statusData.status_middle_text && `Caisses & orga. : ${statusData.status_middle_text}`}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Ligne 3 : Actus */}
|
|
{statusData.status_bottom_description ? (
|
|
<Tooltip
|
|
content={createTooltipContent('Actus', statusData.status_bottom_description, statusData.status_bottom_color)}
|
|
side="bottom"
|
|
allowHTML={true}
|
|
>
|
|
<div className="px-3 py-1.5 text-xs flex items-center gap-2 min-w-0 transition-colors hover:bg-slate-50/50 cursor-help">
|
|
<span className={`inline-block w-2.5 h-2.5 rounded-full ring-2 flex-shrink-0 ${getColorClasses(statusData.status_bottom_color)}`} aria-hidden />
|
|
<span
|
|
className={`${getTextColorClasses(statusData.status_bottom_color)} truncate block min-w-0 flex-1`}
|
|
>
|
|
{statusData.status_bottom_text && `Actus : ${statusData.status_bottom_text}`}
|
|
</span>
|
|
<HelpCircle className="w-3 h-3 text-slate-400 flex-shrink-0" />
|
|
</div>
|
|
</Tooltip>
|
|
) : (
|
|
<div className="px-3 py-1.5 text-xs flex items-center gap-2 min-w-0">
|
|
<span className={`inline-block w-2.5 h-2.5 rounded-full ring-2 flex-shrink-0 ${getColorClasses(statusData.status_bottom_color)}`} aria-hidden />
|
|
<span
|
|
className={`${getTextColorClasses(statusData.status_bottom_color)} truncate block min-w-0 flex-1`}
|
|
>
|
|
{statusData.status_bottom_text && `Actus : ${statusData.status_bottom_text}`}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="ml-auto flex items-center gap-2">
|
|
<button
|
|
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>
|
|
);
|
|
}
|