docv-ged-archive/components/4nk/FolderModal.tsx
2025-11-10 17:28:01 +01:00

459 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useEffect, useState, memo } from 'react';
import Modal from './Modal';
import type { FolderData, AttachedFile } from '@/lib/4nk/models/FolderData';
import { MemberAutocomplete } from '../ui/member-autocomplete';
type FolderType = 'contrat' | 'projet' | 'rapport' | 'finance' | 'rh' | 'marketing' | 'autre';
interface FolderModalProps {
folder?: FolderData;
// --- MODIFIÉ ---
onSave?: (folderData: FolderData, selectedMembers: string[]) => void;
onCancel?: () => void;
readOnly?: boolean;
isOpen: boolean;
onClose: () => void;
folderType?: FolderType;
// --- NOUVEAU ---
members?: string[]; // Liste des membres disponibles
renderExtraFields?: (
folderData: FolderData,
setFolderData: React.Dispatch<React.SetStateAction<FolderData>>
) => React.ReactNode;
}
const defaultFolder: FolderData = {
folderNumber: '',
name: '',
description: '',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
notes: [],
messages: [],
messages_owner: [],
attachedFiles: []
};
function capitalize(s?: string) {
if (!s) return '';
return s.charAt(0).toUpperCase() + s.slice(1);
}
// Types de fichiers autorisés
const ALLOWED_FILE_TYPES = {
'application/pdf': '.pdf',
'image/png': '.png',
'image/jpeg': '.jpg,.jpeg',
'image/gif': '.gif',
'image/webp': '.webp',
'text/plain': '.txt',
'application/msword': '.doc',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': '.docx',
'application/vnd.ms-excel': '.xls',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': '.xlsx'
};
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
// Fonction pour convertir un fichier en base64
const fileToBase64 = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
const result = reader.result as string;
// Enlever le préfixe "data:type/subtype;base64,"
const base64 = result.split(',')[1];
resolve(base64);
};
reader.onerror = error => reject(error);
});
};
// Fonction pour formater la taille des fichiers
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
// Mapping des couleurs
const folderColors: Record<FolderType, { bg: string; border: string; focus: string; button: string }> = {
contrat: { bg: 'bg-blue-50 dark:bg-blue-900', border: 'border-blue-300 dark:border-blue-700', focus: 'focus:ring-blue-400 dark:focus:ring-blue-600', button: 'bg-blue-500 hover:bg-blue-600' },
projet: { bg: 'bg-green-50 dark:bg-green-900', border: 'border-green-300 dark:border-green-700', focus: 'focus:ring-green-400 dark:focus:ring-green-600', button: 'bg-green-500 hover:bg-green-600' },
rapport: { bg: 'bg-yellow-50 dark:bg-yellow-900', border: 'border-yellow-300 dark:border-yellow-700', focus: 'focus:ring-yellow-400 dark:focus:ring-yellow-600', button: 'bg-yellow-500 hover:bg-yellow-600' },
finance: { bg: 'bg-indigo-50 dark:bg-indigo-900', border: 'border-indigo-300 dark:border-indigo-700', focus: 'focus:ring-indigo-400 dark:focus:ring-indigo-600', button: 'bg-indigo-500 hover:bg-indigo-600' },
rh: { bg: 'bg-pink-50 dark:bg-pink-900', border: 'border-pink-300 dark:border-pink-700', focus: 'focus:ring-pink-400 dark:focus:ring-pink-600', button: 'bg-pink-500 hover:bg-pink-600' },
marketing: { bg: 'bg-purple-50 dark:bg-purple-900', border: 'border-purple-300 dark:border-purple-700', focus: 'focus:ring-purple-400 dark:focus:ring-purple-600', button: 'bg-purple-500 hover:bg-purple-600' },
autre: { bg: 'bg-gray-50 dark:bg-gray-800', border: 'border-gray-300 dark:border-gray-600', focus: 'focus:ring-blue-400 dark:focus:ring-blue-600', button: 'bg-blue-500 hover:bg-blue-600' },
};
function FolderModal({
folder = defaultFolder,
onSave,
onCancel,
readOnly = false,
isOpen,
onClose,
folderType = 'autre',
members = [],
renderExtraFields
}: FolderModalProps) {
const [folderData, setFolderData] = useState<FolderData>({ ...defaultFolder, ...folder });
const [currentNote, setCurrentNote] = useState('');
// --- NOUVEAU: État pour les membres sélectionnés ---
const [selectedMembers, setSelectedMembers] = useState<string[]>([]);
// --- NOUVEAU: États pour la gestion des fichiers ---
const [isUploading, setIsUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
useEffect(() => {
if (isOpen) {
setFolderData({ ...defaultFolder, ...(folder || {}) });
setCurrentNote('');
setSelectedMembers([]); // <-- MODIFIÉ: Réinitialise les membres
}
}, [isOpen, folder]);
const handleInputChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
) => {
const { name, value } = e.target;
setFolderData(prev => ({ ...(prev as any), [name]: value } as FolderData));
};
const handleMemberToggle = (memberId: string) => {
setSelectedMembers(prev =>
prev.includes(memberId)
? prev.filter(id => id !== memberId)
: [...prev, memberId]
);
};
const addNote = () => {
const v = currentNote.trim();
if (!v) return;
setFolderData(prev => ({ ...prev, notes: [...(prev.notes || []), v] }));
setCurrentNote('');
};
const removeNote = (note: string) => {
setFolderData(prev => ({ ...prev, notes: (prev.notes || []).filter(n => n !== note) }));
};
// --- NOUVEAU: Fonctions pour la gestion des fichiers ---
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (!files || files.length === 0) return;
setIsUploading(true);
setUploadError(null);
try {
const newFiles: AttachedFile[] = [];
for (const file of Array.from(files)) {
// Vérifier le type de fichier
if (!Object.keys(ALLOWED_FILE_TYPES).includes(file.type)) {
throw new Error(`Type de fichier non autorisé: ${file.type}`);
}
// Vérifier la taille
if (file.size > MAX_FILE_SIZE) {
throw new Error(`Fichier trop volumineux: ${file.name} (${formatFileSize(file.size)}). Taille maximale: ${formatFileSize(MAX_FILE_SIZE)}`);
}
// Convertir en base64
const base64Data = await fileToBase64(file);
const attachedFile: AttachedFile = {
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
name: file.name,
type: file.type,
size: file.size,
base64Data,
uploadedAt: new Date().toISOString()
};
newFiles.push(attachedFile);
}
// Ajouter les nouveaux fichiers
setFolderData(prev => ({
...prev,
attachedFiles: [...(prev.attachedFiles || []), ...newFiles]
}));
} catch (error) {
setUploadError(error instanceof Error ? error.message : 'Erreur lors du téléchargement');
} finally {
setIsUploading(false);
// Réinitialiser l'input
event.target.value = '';
}
};
const removeFile = (fileId: string) => {
setFolderData(prev => ({
...prev,
attachedFiles: (prev.attachedFiles || []).filter(f => f.id !== fileId)
}));
};
const downloadFile = (file: AttachedFile) => {
const link = document.createElement('a');
link.href = `data:${file.type};base64,${file.base64Data}`;
link.download = file.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSave?.({ ...folderData, updated_at: new Date().toISOString() }, selectedMembers);
onClose();
};
const handleCancel = () => {
if (onCancel) onCancel();
else onClose();
};
const colors = folderColors[folderType];
const title = `Créer un dossier ${capitalize(folderType)}`;
return (
<Modal isOpen={isOpen} onClose={onClose} title={title} size="lg">
<div className={`p-6 rounded-lg space-y-8 ${colors.bg} text-gray-900 dark:text-gray-100`}>
<form className="space-y-8" onSubmit={handleSubmit}>
{/* Informations principales */}
<div className="space-y-6">
<h3 className="text-xl font-semibold text-gray-900 dark:text-gray-100">Informations principales</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{['folderNumber', 'name'].map((field) => (
<div className="relative" key={field}>
<input
type="text"
name={field}
value={folderData[field as keyof FolderData] || ''}
onChange={handleInputChange}
required
disabled={readOnly}
placeholder=" "
className={`peer block w-full px-3 pt-5 pb-2 rounded-md ${colors.bg} text-gray-900 dark:text-gray-100 placeholder-transparent border ${colors.border} focus:outline-none ${colors.focus}`}
/>
<label className="absolute left-3 top-2.5 text-gray-500 dark:text-gray-400 text-sm transition-all peer-placeholder-shown:top-5 peer-placeholder-shown:text-base peer-focus:top-2.5 peer-focus:text-sm">
{field === 'folderNumber' ? 'Numéro de dossier *' : 'Nom *'}
</label>
</div>
))}
</div>
{/* Description */}
<div className="relative">
<textarea
name="description"
value={folderData.description || ''}
onChange={handleInputChange}
disabled={readOnly}
placeholder=" "
rows={3}
className={`peer block w-full px-3 pt-5 pb-2 rounded-md ${colors.bg} text-gray-900 dark:text-gray-100 placeholder-transparent border ${colors.border} focus:outline-none ${colors.focus} resize-none`}
/>
<label className="absolute left-3 top-2.5 text-gray-500 dark:text-gray-400 text-sm transition-all peer-placeholder-shown:top-5 peer-placeholder-shown:text-base peer-focus:top-2.5 peer-focus:text-sm">
Description
</label>
</div>
</div>
{/* Membres */}
{!readOnly && (
<div className="space-y-4">
<h3 className="text-xl font-semibold text-gray-900 dark:text-gray-100">Membres</h3>
<MemberAutocomplete
allMembers={members}
selectedMembers={selectedMembers}
onChange={setSelectedMembers} // On passe le setter de l'état
/>
</div>
)}
{/* Notes */}
<div className="space-y-4">
<h3 className="text-xl font-semibold text-gray-900 dark:text-gray-100">Notes</h3>
<div className="space-y-2">
{(folderData.notes || []).map((note, index) => (
<div key={index} className="flex items-center justify-between bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-gray-100 px-3 py-1 rounded-md">
<span>{note}</span>
{!readOnly && (
<button type="button" className="text-red-500 hover:text-red-700 ml-2" onClick={() => removeNote(note)}>×</button>
)}
</div>
))}
</div>
{!readOnly && (
<div className="flex space-x-2">
<input
type="text"
value={currentNote}
onChange={(e) => setCurrentNote(e.target.value)}
placeholder="Ajouter une note"
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addNote(); } }}
className={`flex-1 border rounded-md px-3 py-2 ${colors.bg} text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-400 border ${colors.border} focus:outline-none ${colors.focus}`}
/>
<button type="button" className={`px-4 py-2 text-white rounded-md ${colors.button}`} onClick={addNote}>Ajouter</button>
</div>
)}
</div>
{/* Fichiers attachés */}
<div className="space-y-4">
<h3 className="text-xl font-semibold text-gray-900 dark:text-gray-100">Fichiers attachés</h3>
{/* Liste des fichiers */}
<div className="space-y-2">
{(folderData.attachedFiles || []).map((file) => (
<div key={file.id} className="flex items-center justify-between bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-gray-100 px-3 py-2 rounded-md">
<div className="flex items-center space-x-3">
<div className="flex-shrink-0">
{file.type.startsWith('image/') ? (
<svg className="w-5 h-5 text-green-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M4 3a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V5a2 2 0 00-2-2H4zm12 12H4l4-8 3 6 2-4 3 6z" clipRule="evenodd" />
</svg>
) : file.type === 'application/pdf' ? (
<svg className="w-5 h-5 text-red-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clipRule="evenodd" />
</svg>
) : (
<svg className="w-5 h-5 text-blue-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clipRule="evenodd" />
</svg>
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{file.name}</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{formatFileSize(file.size)}</p>
</div>
</div>
<div className="flex items-center space-x-2">
<button
type="button"
onClick={() => downloadFile(file)}
className="text-blue-500 hover:text-blue-700 text-sm"
>
Télécharger
</button>
{!readOnly && (
<button
type="button"
onClick={() => removeFile(file.id)}
className="text-red-500 hover:text-red-700 ml-2"
>
×
</button>
)}
</div>
</div>
))}
</div>
{/* Zone de téléchargement */}
{!readOnly && (
<div className="space-y-2">
<div className="flex items-center space-x-2">
<input
type="file"
id="file-upload"
multiple
accept={Object.values(ALLOWED_FILE_TYPES).join(',')}
onChange={handleFileUpload}
disabled={isUploading}
className="hidden"
/>
<label
htmlFor="file-upload"
className={`cursor-pointer inline-flex items-center px-4 py-2 text-white rounded-md ${colors.button} disabled:opacity-50 ${isUploading ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{isUploading ? (
<>
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Téléchargement...
</>
) : (
<>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8l-8-8-8 8" />
</svg>
Ajouter des fichiers
</>
)}
</label>
</div>
{/* Message d'erreur */}
{uploadError && (
<div className="text-red-500 text-sm bg-red-50 dark:bg-red-900/20 p-2 rounded-md">
{uploadError}
</div>
)}
{/* Informations sur les types de fichiers autorisés */}
<div className="text-xs text-gray-500 dark:text-gray-400">
Types autorisés: PDF, Images (PNG, JPG, GIF, WebP), Documents (DOC, DOCX, XLS, XLSX), TXT
<br />
Taille maximale: {formatFileSize(MAX_FILE_SIZE)}
</div>
</div>
)}
</div>
{/* Informations système */}
<div className="space-y-4">
<h3 className="text-xl font-semibold text-gray-900 dark:text-gray-100">Informations système</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{['created_at', 'updated_at'].map((field) => {
const value = new Date(folderData[field as keyof FolderData] as string).toLocaleString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
const label = field === 'created_at' ? 'Créé le' : 'Dernière mise à jour';
return (
<div className="relative" key={field}>
<input
type="text"
value={value}
disabled
readOnly
placeholder=" "
className={`peer block w-full px-3 pt-5 pb-2 rounded-md ${colors.bg} text-gray-900 dark:text-gray-100 placeholder-transparent border ${colors.border} focus:outline-none ${colors.focus}`}
/>
<label className="absolute left-3 top-2.5 text-gray-500 dark:text-gray-400 text-sm transition-all peer-placeholder-shown:top-5 peer-placeholder-shown:text-base peer-focus:top-2.5 peer-focus:text-sm">
{label}
</label>
</div>
);
})}
</div>
</div>
{/* Actions */}
<div className="flex justify-end space-x-3">
<button type="button" className={`px-4 py-2 rounded-md ${colors.bg} text-gray-900 dark:text-gray-100 hover:bg-gray-300 dark:hover:bg-gray-700`} onClick={handleCancel}>Annuler</button>
<button type="submit" className={`px-4 py-2 text-white rounded-md ${colors.button} disabled:opacity-50`} disabled={readOnly}>Enregistrer</button>
</div>
{/* Champs spécifiques injectés */}
{renderExtraFields && renderExtraFields(folderData, setFolderData)}
</form>
</div>
</Modal>
);
}
FolderModal.displayName = 'FolderModal';
export default memo(FolderModal);