426 lines
19 KiB
TypeScript
426 lines
19 KiB
TypeScript
"use client"
|
|
|
|
import { useState, useMemo, useCallback } from "react" // <-- useCallback ajouté
|
|
import { useRouter } from "next/navigation"
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
import { Badge } from "@/components/ui/badge"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Input } from "@/components/ui/input"
|
|
import { Skeleton } from "@/components/ui/skeleton"
|
|
import { MessageSquare } from "lucide-react"
|
|
import {
|
|
Folder,
|
|
Search,
|
|
FolderPlus,
|
|
Clock,
|
|
StickyNote,
|
|
FileText,
|
|
UploadCloud,
|
|
X // <-- Ajout de X pour la notification
|
|
} from "lucide-react"
|
|
import { FolderData, FolderCreated, FolderPrivateFields, setDefaultFolderRoles } from "@/lib/4nk/models/FolderData"
|
|
import MessageBus from "@/lib/4nk/MessageBus"
|
|
import { iframeUrl } from "@/app/page"
|
|
import FolderModal from "@/components/4nk/FolderModal"
|
|
import FolderChat from "@/components/4nk/FolderChat"
|
|
import { use4NK, EnrichedFolderData } from "@/lib/contexts/FourNKContext"
|
|
|
|
// Fonction simple pour formater la taille des fichiers
|
|
const formatBytes = (bytes: number, decimals = 2) => {
|
|
if (bytes === 0) return '0 Bytes';
|
|
const k = 1024;
|
|
const dm = decimals < 0 ? 0 : decimals;
|
|
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
|
}
|
|
|
|
type FolderType = 'contrat' | 'projet' | 'rapport' | 'finance' | 'rh' | 'marketing' | 'autre';
|
|
|
|
function DashboardLoadingSkeleton() {
|
|
return (
|
|
<div className="flex h-full text-gray-100 p-6 space-x-6">
|
|
{/* Colonne 1: Squelette Liste */}
|
|
<div className="w-80 flex-shrink-0 flex flex-col h-full">
|
|
<div className="flex-shrink-0">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<Skeleton className="h-7 w-32 bg-gray-700" />
|
|
<Skeleton className="h-8 w-8 bg-gray-700" />
|
|
</div>
|
|
<div className="relative mb-4">
|
|
<Skeleton className="h-10 w-full bg-gray-700" />
|
|
</div>
|
|
</div>
|
|
<div className="flex-1 overflow-y-auto -mr-3 pr-3">
|
|
<div className="space-y-2">
|
|
{[...Array(8)].map((_, i) => (
|
|
<Card key={i} className="bg-gray-800 border-gray-700">
|
|
<CardContent className="p-3">
|
|
<div className="flex items-center space-x-3 animate-pulse">
|
|
<Skeleton className="h-5 w-5 bg-gray-700" />
|
|
<div className="min-w-0">
|
|
<Skeleton className="h-4 w-32 bg-gray-700" />
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Colonne 2: Squelette Résumé */}
|
|
<div className="w-[600px] flex-shrink-0 flex flex-col h-full overflow-y-auto">
|
|
<div className="flex h-full items-center justify-center">
|
|
<p className="text-gray-500 animate-pulse">Chargement des données...</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Colonne 3: Squelette Chat */}
|
|
<div className="flex-1 bg-gray-800 border border-gray-700 rounded-lg flex flex-col overflow-hidden h-full">
|
|
<div className="flex h-full items-center justify-center text-gray-500 p-6">
|
|
<div className="text-center animate-pulse">
|
|
<MessageSquare className="h-12 w-12 mx-auto mb-4" />
|
|
<h3 className="text-lg font-medium text-gray-100 mb-2">
|
|
Chargement du chat...
|
|
</h3>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default function DashboardPage() {
|
|
const [searchTerm, setSearchTerm] = useState("")
|
|
const [folderType, setFolderType] = useState<FolderType | null>(null);
|
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
const [notification, setNotification] = useState<{ type: "success" | "error" | "info"; message: string } | null>(null)
|
|
const [selectedFolder, setSelectedFolder] = useState<EnrichedFolderData | null>(null);
|
|
|
|
const {
|
|
isConnected,
|
|
userPairingId,
|
|
folders,
|
|
loadingFolders,
|
|
members,
|
|
setFolderProcesses,
|
|
setMyFolderProcesses,
|
|
setFolderPrivateData
|
|
} = use4NK();
|
|
|
|
const filteredFolders = folders.filter(folder => {
|
|
const matchesSearch = folder.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
folder.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
folder.folderNumber.toLowerCase().includes(searchTerm.toLowerCase()) // On garde la recherche par ID
|
|
return matchesSearch
|
|
})
|
|
|
|
const showNotification = (type: "success" | "error" | "info", message: string) => {
|
|
setNotification({ type, message })
|
|
setTimeout(() => setNotification(null), 5000)
|
|
}
|
|
|
|
const handleOpenModal = (type: FolderType) => {
|
|
setFolderType(type);
|
|
setIsModalOpen(true);
|
|
};
|
|
const handleCloseModal = () => {
|
|
setIsModalOpen(false);
|
|
setFolderType(null);
|
|
};
|
|
|
|
const handleSaveNewFolder = useCallback(
|
|
(folderData: FolderData, selectedMembers: string[]) => {
|
|
if (!isConnected || !userPairingId) {
|
|
showNotification("error", "Vous devez être connecté à 4NK pour créer un dossier");
|
|
return;
|
|
}
|
|
|
|
// Crée les rôles par défaut (probablement 'owner' = vous)
|
|
const roles = setDefaultFolderRoles(userPairingId);
|
|
const folderPrivateFields = FolderPrivateFields;
|
|
|
|
// Fusionne votre userPairingId avec les membres sélectionnés
|
|
// On utilise un Set pour éviter les doublons
|
|
const allOwnerMembers = new Set([
|
|
...roles.owner.members, // Membres par défaut (vous)
|
|
userPairingId, // S'assurer que vous y êtes
|
|
...selectedMembers // Ajoute les nouveaux membres
|
|
]);
|
|
|
|
// Met à jour la liste des membres pour le rôle 'owner'
|
|
// (Vous pouvez ajuster "owner" pour un autre rôle si nécessaire)
|
|
roles.owner.members = Array.from(allOwnerMembers);
|
|
console.log(roles);
|
|
|
|
MessageBus.getInstance(iframeUrl).createFolder(folderData, folderPrivateFields, roles).then((_folderCreated: FolderCreated) => {
|
|
MessageBus.getInstance(iframeUrl).notifyProcessUpdate(_folderCreated.processId, _folderCreated.process.states[0].state_id).then(() => {
|
|
MessageBus.getInstance(iframeUrl).validateState(_folderCreated.processId, _folderCreated.process.states[0].state_id).then((_updatedProcess: any) => {
|
|
const { processId, process } = _folderCreated;
|
|
|
|
setFolderProcesses((prevProcesses: any) => ({ ...prevProcesses, [processId]: process }));
|
|
setMyFolderProcesses((prevMyProcesses: string[]) => {
|
|
if (prevMyProcesses.includes(processId)) return prevMyProcesses;
|
|
return [...prevMyProcesses, processId];
|
|
});
|
|
setFolderPrivateData((prevData) => ({ ...prevData, [_folderCreated.process.states[0].state_id]: folderData }));
|
|
|
|
showNotification("success", "Dossier créé avec succès !");
|
|
handleCloseModal();
|
|
});
|
|
});
|
|
})
|
|
.catch((error: any) => {
|
|
console.error('Erreur lors de la création du dossier:', error);
|
|
showNotification("error", "Erreur lors de la création du dossier");
|
|
});
|
|
},
|
|
[isConnected, userPairingId, setFolderProcesses, setMyFolderProcesses, setFolderPrivateData]
|
|
);
|
|
|
|
if (loadingFolders) {
|
|
return <DashboardLoadingSkeleton />;
|
|
}
|
|
|
|
return (
|
|
<div className="flex h-full text-gray-100 p-6 space-x-6">
|
|
|
|
{/* --- COLONNE 1: LISTE DES DOSSIERS (Largeur fixe) --- */}
|
|
<div className="w-80 flex-shrink-0 flex flex-col h-full">
|
|
{/* Header Colonne 1 */}
|
|
<div className="flex-shrink-0">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h2 className="text-xl font-semibold text-gray-100">Dossiers</h2>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handleOpenModal("autre")}
|
|
disabled={!isConnected}
|
|
className="text-gray-400 hover:text-gray-100 hover:bg-gray-700"
|
|
>
|
|
<FolderPlus className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
<div className="relative mb-4">
|
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
|
<Input
|
|
placeholder="Rechercher..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
className="pl-10 bg-gray-800 border-gray-700"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Liste scrollable */}
|
|
<div className="flex-1 overflow-y-auto -mr-3 pr-3">
|
|
{loadingFolders ? (
|
|
<p className="text-gray-400">Chargement...</p>
|
|
) : filteredFolders.length === 0 ? (
|
|
<p className="text-gray-500 text-sm">Aucun dossier trouvé.</p>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{filteredFolders.map((folder) => (
|
|
<Card
|
|
key={folder.folderNumber}
|
|
className={`transition-shadow bg-gray-800 border border-gray-700 cursor-pointer ${selectedFolder?.folderNumber === folder.folderNumber
|
|
? 'border-blue-500' // Dossier sélectionné
|
|
: 'hover:border-gray-600'
|
|
}`}
|
|
onClick={() => setSelectedFolder(folder)}
|
|
>
|
|
<CardContent className="p-3">
|
|
<div className="flex items-center space-x-3">
|
|
<Folder className="h-5 w-5 text-blue-500 flex-shrink-0" />
|
|
<div className="min-w-0">
|
|
<h3 className="font-medium text-gray-100 truncate">{folder.name}</h3>
|
|
{/* Texte sous le nom du dossier */}
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* --- COLONNE 2: RÉSUMÉ DU DOSSIER (Largeur fixe) --- */}
|
|
<div className="w-[600px] flex-shrink-0 flex flex-col h-full overflow-y-auto">
|
|
{!selectedFolder ? (
|
|
<div className="flex h-full items-center justify-center">
|
|
<p className="text-gray-500">Sélectionnez un dossier pour voir le résumé</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Header Colonne 2 */}
|
|
<div className="flex-shrink-0">
|
|
<h1 className="text-2xl font-semibold">{selectedFolder.name}</h1>
|
|
<p className="text-gray-400 mt-2">{selectedFolder.description}</p>
|
|
<div className="flex items-center space-x-4 mt-3 text-xs text-gray-500 dark:text-gray-400">
|
|
<div className="flex items-center space-x-1" title={selectedFolder.created_at}>
|
|
<Clock className="h-3 w-3" />
|
|
<span>
|
|
Créé le: {new Date(selectedFolder.created_at).toLocaleString('fr-FR', {
|
|
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit'
|
|
})}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center space-x-1" title={selectedFolder.updated_at}>
|
|
<Clock className="h-3 w-3" />
|
|
<span>
|
|
Modifié le: {new Date(selectedFolder.updated_at).toLocaleString('fr-FR', {
|
|
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit'
|
|
})}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Contenu Colonne 2 */}
|
|
<div className="flex-1 mt-6 space-y-6">
|
|
<Card className="bg-gray-800 border-gray-700">
|
|
<CardHeader>
|
|
<CardTitle className="text-lg font-medium text-gray-100 flex items-center">
|
|
<StickyNote className="h-5 w-5 mr-2 text-yellow-400" />
|
|
Notes du dossier
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{selectedFolder.notes && selectedFolder.notes.length > 0 ? (
|
|
<ul className="list-disc pl-5 space-y-2">
|
|
{selectedFolder.notes.map((note, index) => (
|
|
<li key={index} className="text-gray-300">
|
|
{note}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
) : (
|
|
<p className="text-gray-500">Aucune note pour ce dossier.</p>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="bg-gray-800 border-gray-700">
|
|
<CardHeader>
|
|
<CardTitle className="text-lg font-medium text-gray-100 flex items-center">
|
|
<FileText className="h-5 w-5 mr-2 text-blue-400" />
|
|
Fichiers
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{(() => {
|
|
const files = selectedFolder?.attachedFiles;
|
|
if (!files) return false;
|
|
|
|
if (typeof files === 'object') {
|
|
return Object.keys(files).length > 0;
|
|
}
|
|
return false;
|
|
})() ? (
|
|
<div className="space-y-3">
|
|
{Object.entries(selectedFolder.attachedFiles || {}).map(([key, file]: [string, any]) => {
|
|
|
|
return (
|
|
<div key={key} className="flex items-center justify-between p-3 bg-gray-700 rounded-lg">
|
|
<div className="flex items-center space-x-3 min-w-0">
|
|
<div className="flex-shrink-0">
|
|
{(file instanceof Map ? file.get('type') : 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 instanceof Map ? file.get('type') : 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="min-w-0">
|
|
<p className="text-sm font-medium text-gray-100 truncate">
|
|
{(file instanceof Map ? file.get('name') : file?.name) || 'Fichier'}
|
|
</p>
|
|
<p className="text-xs text-gray-400">
|
|
{(() => {
|
|
const size = file instanceof Map ? file.get('size') : file?.size;
|
|
return size ? formatBytes(size) : 'Taille inconnue';
|
|
})()}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => {
|
|
const name = file instanceof Map ? file.get('name') : file?.name;
|
|
const type = file instanceof Map ? file.get('type') : file?.type;
|
|
const base64Data = file instanceof Map ? file.get('base64Data') : file?.base64Data;
|
|
|
|
if (base64Data && type && name) {
|
|
const link = document.createElement('a');
|
|
link.href = `data:${type};base64,${base64Data}`;
|
|
link.download = name;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
}
|
|
}}
|
|
className="text-blue-400 hover:text-blue-300 hover:bg-gray-600"
|
|
disabled={!(file instanceof Map ? file.get('base64Data') : file?.base64Data)}
|
|
>
|
|
<UploadCloud className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
) : (
|
|
<p className="text-gray-500">Aucun fichier dans ce dossier.</p>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* --- COLONNE 3: CHAT (flex-1) --- */}
|
|
<div className="flex-1 bg-gray-800 border border-gray-700 rounded-lg flex flex-col overflow-hidden h-full">
|
|
<FolderChat
|
|
folder={selectedFolder} // Passe le dossier sélectionné (ou null)
|
|
/>
|
|
</div>
|
|
|
|
{/* --- MODALS (hors layout) --- */}
|
|
{isModalOpen && (
|
|
<FolderModal
|
|
isOpen={isModalOpen}
|
|
onClose={handleCloseModal}
|
|
onSave={handleSaveNewFolder}
|
|
onCancel={handleCloseModal}
|
|
folderType={folderType || "autre"}
|
|
members={members}
|
|
/>
|
|
)}
|
|
{notification && (
|
|
<div className="fixed top-4 right-4 z-50">
|
|
<div className={`p-4 rounded-md shadow-lg ${notification.type === "success" ? "bg-green-50 text-green-800 border border-green-200" :
|
|
notification.type === "error" ? "bg-red-50 text-red-800 border border-red-200" :
|
|
"bg-blue-50 text-blue-800 border border-blue-200"
|
|
}`}>
|
|
<div className="flex items-center justify-between">
|
|
<span>{notification.message}</span>
|
|
<Button variant="ghost" size="sm" onClick={() => setNotification(null)}>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
} |