- 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
168 lines
5.1 KiB
TypeScript
168 lines
5.1 KiB
TypeScript
"use client";
|
|
|
|
import React, { useRef } from 'react';
|
|
import { Bold, Italic, List, Link as LinkIcon } from 'lucide-react';
|
|
|
|
interface RichTextEditorProps {
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
placeholder?: string;
|
|
maxLength?: number;
|
|
}
|
|
|
|
export default function RichTextEditor({
|
|
value,
|
|
onChange,
|
|
placeholder = "Saisissez votre texte...",
|
|
maxLength = 1000
|
|
}: RichTextEditorProps) {
|
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
|
|
const insertMarkdown = (before: string, after: string = '') => {
|
|
const textarea = textareaRef.current;
|
|
if (!textarea) return;
|
|
|
|
const start = textarea.selectionStart;
|
|
const end = textarea.selectionEnd;
|
|
const selectedText = value.substring(start, end);
|
|
const newText = value.substring(0, start) + before + selectedText + after + value.substring(end);
|
|
|
|
onChange(newText);
|
|
|
|
// Restaurer la sélection après le rendu
|
|
setTimeout(() => {
|
|
textarea.focus();
|
|
const newCursorPos = start + before.length + selectedText.length;
|
|
textarea.setSelectionRange(newCursorPos, newCursorPos);
|
|
}, 0);
|
|
};
|
|
|
|
const handleBold = () => insertMarkdown('**', '**');
|
|
const handleItalic = () => insertMarkdown('*', '*');
|
|
const handleList = () => {
|
|
const textarea = textareaRef.current;
|
|
if (!textarea) return;
|
|
|
|
const start = textarea.selectionStart;
|
|
const lineStart = value.lastIndexOf('\n', start - 1) + 1;
|
|
const newText = value.substring(0, lineStart) + '• ' + value.substring(lineStart);
|
|
onChange(newText);
|
|
};
|
|
|
|
const handleLink = () => {
|
|
const url = prompt('Entrez l\'URL du lien :');
|
|
if (url) {
|
|
insertMarkdown('[', `](${url})`);
|
|
}
|
|
};
|
|
|
|
// Fonction pour prévisualiser le markdown en HTML simple
|
|
const renderPreview = (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 my-2">');
|
|
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;
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-2">
|
|
{/* Barre d'outils */}
|
|
<div className="flex items-center gap-1 p-2 bg-slate-50 border border-slate-300 rounded-t-lg">
|
|
<button
|
|
type="button"
|
|
onClick={handleBold}
|
|
className="p-2 hover:bg-slate-200 rounded transition-colors"
|
|
title="Gras (Ctrl+B)"
|
|
>
|
|
<Bold className="w-4 h-4 text-slate-700" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={handleItalic}
|
|
className="p-2 hover:bg-slate-200 rounded transition-colors"
|
|
title="Italique (Ctrl+I)"
|
|
>
|
|
<Italic className="w-4 h-4 text-slate-700" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={handleList}
|
|
className="p-2 hover:bg-slate-200 rounded transition-colors"
|
|
title="Liste à puces"
|
|
>
|
|
<List className="w-4 h-4 text-slate-700" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={handleLink}
|
|
className="p-2 hover:bg-slate-200 rounded transition-colors"
|
|
title="Insérer un lien"
|
|
>
|
|
<LinkIcon className="w-4 h-4 text-slate-700" />
|
|
</button>
|
|
<div className="ml-auto text-xs text-slate-500">
|
|
{value.length}/{maxLength} caractères
|
|
</div>
|
|
</div>
|
|
|
|
{/* Zone de texte */}
|
|
<textarea
|
|
ref={textareaRef}
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
placeholder={placeholder}
|
|
maxLength={maxLength}
|
|
className="w-full px-3 py-2 border border-slate-300 border-t-0 rounded-b-lg bg-white text-slate-900 focus:ring-2 focus:ring-blue-500 focus:border-transparent min-h-[120px] resize-y font-mono text-sm"
|
|
/>
|
|
|
|
{/* Prévisualisation */}
|
|
{value && (
|
|
<div className="p-3 bg-slate-50 border border-slate-300 rounded-lg">
|
|
<div className="text-xs font-medium text-slate-600 mb-2">Aperçu :</div>
|
|
<div
|
|
className="text-sm text-slate-700 prose prose-sm max-w-none"
|
|
dangerouslySetInnerHTML={{ __html: renderPreview(value) }}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|