- 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
427 lines
No EOL
18 KiB
TypeScript
427 lines
No EOL
18 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import { X, Save, AlertCircle } from 'lucide-react';
|
|
import RichTextEditor from './RichTextEditor';
|
|
|
|
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;
|
|
}
|
|
|
|
interface StatusEditModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
onSave: (data: StatusData) => Promise<void>;
|
|
initialData?: Partial<StatusData>;
|
|
}
|
|
|
|
const AVAILABLE_COLORS = [
|
|
{ value: 'emerald', label: 'Vert (Emerald)', class: 'bg-emerald-500' },
|
|
{ value: 'red', label: 'Rouge (Red)', class: 'bg-red-500' },
|
|
{ value: 'yellow', label: 'Jaune (Yellow)', class: 'bg-yellow-500' },
|
|
{ value: 'orange', label: 'Orange', class: 'bg-orange-500' },
|
|
{ value: 'sky', label: 'Bleu ciel (Sky)', class: 'bg-sky-500' },
|
|
{ value: 'blue', label: 'Bleu (Blue)', class: 'bg-blue-500' },
|
|
{ value: 'purple', label: 'Violet (Purple)', class: 'bg-purple-500' },
|
|
{ value: 'pink', label: 'Rose (Pink)', class: 'bg-pink-500' },
|
|
{ value: 'gray', label: 'Gris (Gray)', class: 'bg-gray-500' }
|
|
];
|
|
|
|
export default function StatusEditModal({
|
|
isOpen,
|
|
onClose,
|
|
onSave,
|
|
initialData
|
|
}: StatusEditModalProps) {
|
|
const [formData, setFormData] = useState<StatusData>({
|
|
status_top_text: '',
|
|
status_middle_text: '',
|
|
status_bottom_text: '',
|
|
status_top_color: 'emerald',
|
|
status_middle_color: 'blue',
|
|
status_bottom_color: 'sky',
|
|
status_top_description: '',
|
|
status_middle_description: '',
|
|
status_bottom_description: ''
|
|
});
|
|
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [activeTab, setActiveTab] = useState<'top' | 'middle' | 'bottom'>('top');
|
|
const [mounted, setMounted] = useState(false);
|
|
|
|
// S'assurer que le composant est monté côté client
|
|
useEffect(() => {
|
|
setMounted(true);
|
|
}, []);
|
|
|
|
// Initialiser le formulaire avec les données reçues
|
|
useEffect(() => {
|
|
if (initialData) {
|
|
setFormData({
|
|
status_top_text: initialData.status_top_text || '',
|
|
status_middle_text: initialData.status_middle_text || '',
|
|
status_bottom_text: initialData.status_bottom_text || '',
|
|
status_top_color: initialData.status_top_color || 'emerald',
|
|
status_middle_color: initialData.status_middle_color || 'blue',
|
|
status_bottom_color: initialData.status_bottom_color || 'sky',
|
|
status_top_description: initialData.status_top_description || '',
|
|
status_middle_description: initialData.status_middle_description || '',
|
|
status_bottom_description: initialData.status_bottom_description || ''
|
|
});
|
|
}
|
|
}, [initialData]);
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setIsLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
await onSave(formData);
|
|
onClose();
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Une erreur est survenue');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleInputChange = (field: keyof StatusData, value: string) => {
|
|
setFormData(prev => ({ ...prev, [field]: value }));
|
|
};
|
|
|
|
const getColorPreview = (color: string) => {
|
|
const colorInfo = AVAILABLE_COLORS.find(c => c.value === color);
|
|
return colorInfo?.class || 'bg-gray-500';
|
|
};
|
|
|
|
if (!isOpen || !mounted) return null;
|
|
|
|
const modalContent = (
|
|
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50">
|
|
<div className="bg-white rounded-lg shadow-xl w-full max-w-2xl mx-4 max-h-[90vh] overflow-hidden">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between p-6 border-b">
|
|
<h2 className="text-xl font-semibold text-slate-900">
|
|
Modifier les statuts du header
|
|
</h2>
|
|
<button
|
|
onClick={onClose}
|
|
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
|
|
disabled={isLoading}
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<form onSubmit={handleSubmit} className="flex flex-col h-full">
|
|
{error && (
|
|
<div className="flex items-center gap-2 p-3 mx-6 mt-4 bg-red-50 border border-red-200 rounded-lg text-red-700">
|
|
<AlertCircle className="w-5 h-5 flex-shrink-0" />
|
|
<span className="text-sm">{error}</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Preview */}
|
|
<div className="p-6 space-y-3">
|
|
<h3 className="font-medium text-slate-900">Aperçu</h3>
|
|
<div className="border rounded-lg p-4 bg-slate-50">
|
|
<div className="grid grid-rows-3 rounded-lg border bg-white overflow-hidden">
|
|
<div className="px-3 py-2 text-sm flex items-center gap-2 border-b border-slate-200">
|
|
<span
|
|
className={`inline-block w-2.5 h-2.5 rounded-full ${getColorPreview(formData.status_top_color)} ring-2 ring-white/70`}
|
|
/>
|
|
<span className="text-slate-700 truncate">
|
|
{formData.status_top_text && `Services Odentas : ${formData.status_top_text}`}
|
|
</span>
|
|
</div>
|
|
<div className="px-3 py-2 text-sm flex items-center gap-2 border-b border-slate-200">
|
|
<span
|
|
className={`inline-block w-2.5 h-2.5 rounded-full ${getColorPreview(formData.status_middle_color)} ring-2 ring-white/70`}
|
|
/>
|
|
<span className="text-slate-700 truncate">
|
|
{formData.status_middle_text && `Caisses & orga. : ${formData.status_middle_text}`}
|
|
</span>
|
|
</div>
|
|
<div className="px-3 py-2 text-sm flex items-center gap-2">
|
|
<span
|
|
className={`inline-block w-2.5 h-2.5 rounded-full ${getColorPreview(formData.status_bottom_color)} ring-2 ring-white/70`}
|
|
/>
|
|
<span className="text-slate-700 truncate">
|
|
{formData.status_bottom_text && `Actus : ${formData.status_bottom_text}`}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div className="px-6">
|
|
<div className="flex border-b border-slate-200">
|
|
<button
|
|
type="button"
|
|
onClick={() => setActiveTab('top')}
|
|
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
|
activeTab === 'top'
|
|
? 'border-blue-500 text-blue-600'
|
|
: 'border-transparent text-slate-500 hover:text-slate-700'
|
|
}`}
|
|
>
|
|
Services Odentas
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setActiveTab('middle')}
|
|
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
|
activeTab === 'middle'
|
|
? 'border-blue-500 text-blue-600'
|
|
: 'border-transparent text-slate-500 hover:text-slate-700'
|
|
}`}
|
|
>
|
|
Caisses & orga.
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setActiveTab('bottom')}
|
|
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
|
activeTab === 'bottom'
|
|
? 'border-blue-500 text-blue-600'
|
|
: 'border-transparent text-slate-500 hover:text-slate-700'
|
|
}`}
|
|
>
|
|
Actus
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tab Content */}
|
|
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
|
|
{activeTab === 'top' && (
|
|
<div className="space-y-4">
|
|
<div className="space-y-2">
|
|
<label className="block text-sm font-medium text-slate-700">
|
|
Titre (Services Odentas)
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={formData.status_top_text || ''}
|
|
onChange={(e) => handleInputChange('status_top_text', e.target.value)}
|
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg bg-white text-slate-900 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
placeholder="Ex: Aucun incident à signaler"
|
|
maxLength={200}
|
|
required
|
|
/>
|
|
<p className="text-xs text-slate-500">
|
|
{(formData.status_top_text || '').length}/200 caractères - Affiché dans la card du header
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<label className="block text-sm font-medium text-slate-700">
|
|
Description détaillée (affichée au survol)
|
|
</label>
|
|
<RichTextEditor
|
|
value={formData.status_top_description || ''}
|
|
onChange={(value) => handleInputChange('status_top_description', value)}
|
|
placeholder="Ajoutez des détails, des liens, etc. Supporte le markdown (gras, italique, liens, listes)."
|
|
maxLength={1000}
|
|
/>
|
|
<p className="text-xs text-slate-500">
|
|
Cette description sera affichée dans le tooltip au survol de la ligne
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<label className="block text-sm font-medium text-slate-700">
|
|
Couleur du voyant (Services Odentas)
|
|
</label>
|
|
<div className="grid grid-cols-3 gap-2">
|
|
{AVAILABLE_COLORS.map((color) => (
|
|
<button
|
|
key={color.value}
|
|
type="button"
|
|
onClick={() => handleInputChange('status_top_color', color.value)}
|
|
className={`flex items-center gap-2 p-2 rounded-lg border text-sm transition-colors ${
|
|
formData.status_top_color === color.value
|
|
? 'border-blue-500 bg-blue-50'
|
|
: 'border-slate-300 hover:bg-slate-50'
|
|
}`}
|
|
>
|
|
<span className={`w-4 h-4 rounded-full ${color.class}`} />
|
|
<span className="text-slate-700">{color.label}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'middle' && (
|
|
<div className="space-y-4">
|
|
<div className="space-y-2">
|
|
<label className="block text-sm font-medium text-slate-700">
|
|
Titre (Caisses & orga.)
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={formData.status_middle_text || ''}
|
|
onChange={(e) => handleInputChange('status_middle_text', e.target.value)}
|
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg bg-white text-slate-900 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
placeholder="Ex: Tous nos services sont opérationnels"
|
|
maxLength={200}
|
|
required
|
|
/>
|
|
<p className="text-xs text-slate-500">
|
|
{(formData.status_middle_text || '').length}/200 caractères - Affiché dans la card du header
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<label className="block text-sm font-medium text-slate-700">
|
|
Description détaillée (affichée au survol)
|
|
</label>
|
|
<RichTextEditor
|
|
value={formData.status_middle_description || ''}
|
|
onChange={(value) => handleInputChange('status_middle_description', value)}
|
|
placeholder="Ajoutez des détails, des liens, etc. Supporte le markdown (gras, italique, liens, listes)."
|
|
maxLength={1000}
|
|
/>
|
|
<p className="text-xs text-slate-500">
|
|
Cette description sera affichée dans le tooltip au survol de la ligne
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<label className="block text-sm font-medium text-slate-700">
|
|
Couleur du voyant (Caisses & orga.)
|
|
</label>
|
|
<div className="grid grid-cols-3 gap-2">
|
|
{AVAILABLE_COLORS.map((color) => (
|
|
<button
|
|
key={color.value}
|
|
type="button"
|
|
onClick={() => handleInputChange('status_middle_color', color.value)}
|
|
className={`flex items-center gap-2 p-2 rounded-lg border text-sm transition-colors ${
|
|
formData.status_middle_color === color.value
|
|
? 'border-blue-500 bg-blue-50'
|
|
: 'border-slate-300 hover:bg-slate-50'
|
|
}`}
|
|
>
|
|
<span className={`w-4 h-4 rounded-full ${color.class}`} />
|
|
<span className="text-slate-700">{color.label}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'bottom' && (
|
|
<div className="space-y-4">
|
|
<div className="space-y-2">
|
|
<label className="block text-sm font-medium text-slate-700">
|
|
Titre (Actus)
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={formData.status_bottom_text || ''}
|
|
onChange={(e) => handleInputChange('status_bottom_text', e.target.value)}
|
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg bg-white text-slate-900 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
placeholder="Ex: Nous serons fermés le..."
|
|
maxLength={200}
|
|
required
|
|
/>
|
|
<p className="text-xs text-slate-500">
|
|
{(formData.status_bottom_text || '').length}/200 caractères - Affiché dans la card du header
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<label className="block text-sm font-medium text-slate-700">
|
|
Description détaillée (affichée au survol)
|
|
</label>
|
|
<RichTextEditor
|
|
value={formData.status_bottom_description || ''}
|
|
onChange={(value) => handleInputChange('status_bottom_description', value)}
|
|
placeholder="Ajoutez des détails, des liens, etc. Supporte le markdown (gras, italique, liens, listes)."
|
|
maxLength={1000}
|
|
/>
|
|
<p className="text-xs text-slate-500">
|
|
Cette description sera affichée dans le tooltip au survol de la ligne
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<label className="block text-sm font-medium text-slate-700">
|
|
Couleur du voyant (Actus)
|
|
</label>
|
|
<div className="grid grid-cols-3 gap-2">
|
|
{AVAILABLE_COLORS.map((color) => (
|
|
<button
|
|
key={color.value}
|
|
type="button"
|
|
onClick={() => handleInputChange('status_bottom_color', color.value)}
|
|
className={`flex items-center gap-2 p-2 rounded-lg border text-sm transition-colors ${
|
|
formData.status_bottom_color === color.value
|
|
? 'border-blue-500 bg-blue-50'
|
|
: 'border-slate-300 hover:bg-slate-50'
|
|
}`}
|
|
>
|
|
<span className={`w-4 h-4 rounded-full ${color.class}`} />
|
|
<span className="text-slate-700">{color.label}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</form>
|
|
|
|
{/* Footer */}
|
|
<div className="flex justify-end gap-3 p-6 border-t">
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
disabled={isLoading}
|
|
className="px-4 py-2 text-slate-700 border border-slate-300 rounded-lg hover:bg-slate-50 transition-colors disabled:opacity-50"
|
|
>
|
|
Annuler
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
onClick={handleSubmit}
|
|
disabled={isLoading}
|
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 flex items-center gap-2"
|
|
>
|
|
{isLoading ? (
|
|
<>
|
|
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
|
Sauvegarde...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Save className="w-4 h-4" />
|
|
Sauvegarder
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
// Utiliser createPortal pour rendre le modal dans le body
|
|
return createPortal(modalContent, document.body);
|
|
} |