459 lines
20 KiB
TypeScript
459 lines
20 KiB
TypeScript
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);
|