espace-paie-odentas/app/(app)/staff/documents/page.tsx

907 lines
33 KiB
TypeScript

'use client'
import React from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Upload, FileText, Folder, Building2, Search, Plus,
Trash2, Edit3, Download, Loader2, X, Check, ChevronDown, ChevronRight
} from 'lucide-react'
import { usePageTitle } from '@/hooks/usePageTitle'
import { toast } from 'sonner'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
type Organization = {
id: string
name: string
structure_api: string
}
type GeneralDocument = {
type: string
label: string
available: boolean
key?: string
name?: string
size?: number
downloadUrl?: string
}
type DocumentItem = {
id: string
title: string
filename: string
category: string
period_label?: string | null
size_bytes: number
date_added: string
storage_path: string
download_url?: string
}
type Period = {
label: string
count: number
}
const DOC_TYPES = {
"contrat-odentas": "Contrat Odentas",
"licence-spectacles": "Licence de spectacles",
"rib": "RIB",
"kbis-jo": "KBIS / Journal Officiel",
"delegation-signature": "Délégation de signature"
}
function formatBytes(bytes?: number) {
if (!bytes && bytes !== 0) return ''
const sizes = ['o', 'Ko', 'Mo', 'Go', 'To']
const i = Math.floor(Math.log(bytes) / Math.log(1024))
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`
}
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
})
}
function slugify(text: string): string {
return text
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
}
// Modal pour uploader un document général
function UploadGeneralDocModal({
isOpen,
onClose,
orgId,
orgKey,
docType
}: {
isOpen: boolean
onClose: () => void
orgId: string
orgKey: string
docType: string
}) {
const [file, setFile] = React.useState<File | null>(null)
const [uploading, setUploading] = React.useState(false)
const queryClient = useQueryClient()
const handleUpload = async () => {
if (!file) return
setUploading(true)
try {
const formData = new FormData()
formData.append('file', file)
formData.append('org_id', orgId)
formData.append('org_key', slugify(orgKey))
formData.append('doc_type', docType)
formData.append('category', 'docs_generaux')
const response = await fetch('/api/staff/documents/upload', {
method: 'POST',
body: formData
})
if (!response.ok) throw new Error('Erreur lors de l\'upload')
toast.success('Document uploadé avec succès')
queryClient.invalidateQueries({ queryKey: ['documents', 'generaux', orgId] })
setFile(null)
onClose()
} catch (error) {
toast.error('Erreur lors de l\'upload du document')
console.error(error)
} finally {
setUploading(false)
}
}
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Uploader {DOC_TYPES[docType as keyof typeof DOC_TYPES]}</DialogTitle>
<DialogDescription>
Sélectionnez un fichier PDF à uploader pour ce type de document.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div>
<label className="block text-sm font-medium mb-2">Fichier PDF</label>
<Input
type="file"
accept=".pdf"
onChange={(e) => setFile(e.target.files?.[0] || null)}
/>
{file && (
<p className="text-sm text-muted-foreground mt-2">
{file.name} ({formatBytes(file.size)})
</p>
)}
</div>
<div className="flex gap-3">
<Button
onClick={handleUpload}
disabled={!file || uploading}
className="flex-1"
>
{uploading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Upload en cours...
</>
) : (
<>
<Upload className="h-4 w-4 mr-2" />
Uploader
</>
)}
</Button>
<Button onClick={onClose} variant="outline" disabled={uploading}>
Annuler
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}
// Modal pour uploader un ou plusieurs documents comptables
function UploadComptableModal({
isOpen,
onClose,
orgId,
orgKey
}: {
isOpen: boolean
onClose: () => void
orgId: string
orgKey: string
}) {
type FileEntry = { id: number; file: File | null }
const [files, setFiles] = React.useState<FileEntry[]>([{ id: 1, file: null }])
const [period, setPeriod] = React.useState('')
const [uploading, setUploading] = React.useState(false)
const queryClient = useQueryClient()
const nextId = React.useRef(2)
const addFileRow = () => {
setFiles(prev => [...prev, { id: nextId.current++, file: null }])
}
const removeFileRow = (id: number) => {
if (files.length === 1) return
setFiles(prev => prev.filter(f => f.id !== id))
}
const updateFile = (id: number, file: File | null) => {
setFiles(prev => prev.map(f => f.id === id ? { ...f, file } : f))
}
const handleUpload = async () => {
const validFiles = files.filter(f => f.file !== null).map(f => f.file!)
if (validFiles.length === 0 || !period) return
setUploading(true)
try {
// Upload chaque fichier séparément
for (const file of validFiles) {
const formData = new FormData()
formData.append('file', file)
formData.append('org_id', orgId)
formData.append('org_key', slugify(orgKey))
formData.append('period', period)
formData.append('category', 'docs_comptables')
const response = await fetch('/api/staff/documents/upload', {
method: 'POST',
body: formData
})
if (!response.ok) throw new Error(`Erreur lors de l'upload de ${file.name}`)
}
toast.success(`${validFiles.length} document(s) uploadé(s) avec succès`)
queryClient.invalidateQueries({ queryKey: ['documents', 'comptables'] })
setFiles([{ id: 1, file: null }])
setPeriod('')
onClose()
} catch (error) {
toast.error('Erreur lors de l\'upload des documents')
console.error(error)
} finally {
setUploading(false)
}
}
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Uploader des documents comptables</DialogTitle>
<DialogDescription>
Ajoutez un ou plusieurs fichiers pour une période donnée.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div>
<label className="block text-sm font-medium mb-2">
Période <span className="text-red-500">*</span>
</label>
<Input
type="text"
placeholder="ex: 2507-juillet-2025"
value={period}
onChange={(e) => setPeriod(e.target.value)}
/>
<p className="text-xs text-muted-foreground mt-1">
Format: YYMM-mois-YYYY
</p>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<label className="block text-sm font-medium">
Fichiers <span className="text-red-500">*</span>
</label>
<Button
type="button"
size="sm"
variant="outline"
onClick={addFileRow}
disabled={uploading}
>
<Plus className="h-4 w-4 mr-1" />
Ajouter un fichier
</Button>
</div>
{files.map((entry, index) => (
<div key={entry.id} className="flex gap-2 items-start">
<div className="flex-1">
<Input
type="file"
accept=".pdf,.xlsx,.xls,.doc,.docx"
onChange={(e) => updateFile(entry.id, e.target.files?.[0] || null)}
disabled={uploading}
/>
{entry.file && (
<p className="text-xs text-muted-foreground mt-1">
{entry.file.name} ({formatBytes(entry.file.size)})
</p>
)}
</div>
{files.length > 1 && (
<Button
type="button"
size="sm"
variant="outline"
onClick={() => removeFileRow(entry.id)}
disabled={uploading}
className="text-red-600 hover:text-red-700 hover:bg-red-50 px-2"
>
<X className="h-4 w-4" />
</Button>
)}
</div>
))}
</div>
<div className="flex gap-3 pt-4">
<Button
onClick={handleUpload}
disabled={!period || files.every(f => !f.file) || uploading}
className="flex-1"
>
{uploading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Upload en cours...
</>
) : (
<>
<Upload className="h-4 w-4 mr-2" />
Uploader {files.filter(f => f.file).length > 0 && `(${files.filter(f => f.file).length})`}
</>
)}
</Button>
<Button onClick={onClose} variant="outline" disabled={uploading}>
Annuler
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}
// Modal pour visualiser un document
function ViewDocumentModal({
isOpen,
onClose,
document
}: {
isOpen: boolean
onClose: () => void
document: { name: string; downloadUrl: string; size?: number } | null
}) {
if (!document) return null
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-4xl max-h-[90vh]">
<DialogHeader>
<DialogTitle>{document.name}</DialogTitle>
<DialogDescription>
{document.size && `Taille: ${formatBytes(document.size)}`}
</DialogDescription>
</DialogHeader>
<div className="w-full h-[70vh] bg-gray-100 rounded-lg overflow-hidden">
<iframe
src={document.downloadUrl}
className="w-full h-full border-0"
title={document.name}
/>
</div>
<div className="flex gap-3 justify-end">
<Button
onClick={() => window.open(document.downloadUrl, '_blank')}
variant="outline"
>
<Download className="h-4 w-4 mr-2" />
Télécharger
</Button>
<Button onClick={onClose}>
Fermer
</Button>
</div>
</DialogContent>
</Dialog>
)
}
export default function StaffDocumentsPage() {
usePageTitle("Gestion des documents")
const [selectedOrgId, setSelectedOrgId] = React.useState<string>('')
const [searchTerm, setSearchTerm] = React.useState('')
const [activeTab, setActiveTab] = React.useState<'generaux' | 'comptables'>('generaux')
const [uploadGeneralModal, setUploadGeneralModal] = React.useState<string | null>(null)
const [uploadComptableModal, setUploadComptableModal] = React.useState(false)
const [expandedPeriods, setExpandedPeriods] = React.useState<Set<string>>(new Set())
const [viewDocument, setViewDocument] = React.useState<{ name: string; downloadUrl: string; size?: number } | null>(null)
const [deleteConfirm, setDeleteConfirm] = React.useState<{ type: 'general' | 'comptable'; data: any } | null>(null)
const queryClient = useQueryClient()
// Récupérer les organisations
const { data: organizations, isLoading: isLoadingOrgs } = useQuery<Organization[]>({
queryKey: ['organizations', 'all'],
queryFn: async () => {
const response = await fetch('/api/organizations')
if (!response.ok) throw new Error('Erreur chargement organisations')
const data = await response.json()
const items = data.items || []
return items.sort((a: Organization, b: Organization) =>
a.name.localeCompare(b.name)
)
}
})
// Filtrer les organisations selon la recherche
const filteredOrgs = React.useMemo(() => {
if (!organizations) return []
if (!searchTerm) return organizations
return organizations.filter(org =>
org.name.toLowerCase().includes(searchTerm.toLowerCase())
)
}, [organizations, searchTerm])
const selectedOrg = organizations?.find(org => org.id === selectedOrgId)
// Récupérer les documents généraux
const { data: documentsGeneraux } = useQuery<GeneralDocument[]>({
queryKey: ['documents', 'generaux', selectedOrgId],
queryFn: async () => {
const res = await fetch(`/api/documents/generaux?org_id=${selectedOrgId}`)
if (!res.ok) throw new Error('Erreur chargement documents')
const data = await res.json()
return data.documents || []
},
enabled: !!selectedOrgId && activeTab === 'generaux'
})
// Récupérer les documents comptables
const { data: documentsComptables } = useQuery<DocumentItem[]>({
queryKey: ['documents', 'comptables', selectedOrgId],
queryFn: async () => {
const res = await fetch(`/api/staff/documents/list?org_id=${selectedOrgId}&category=docs_comptables`)
if (!res.ok) throw new Error('Erreur chargement documents')
const data = await res.json()
return data.documents || []
},
enabled: !!selectedOrgId && activeTab === 'comptables'
})
// Grouper par période
const documentsByPeriod = React.useMemo(() => {
if (!documentsComptables) return new Map()
const grouped = new Map<string, DocumentItem[]>()
documentsComptables.forEach(doc => {
const period = doc.period_label || 'Sans période'
if (!grouped.has(period)) grouped.set(period, [])
grouped.get(period)!.push(doc)
})
return new Map(
Array.from(grouped.entries()).sort((a, b) => b[0].localeCompare(a[0]))
)
}, [documentsComptables])
// Supprimer un document général
const deleteGeneralDoc = useMutation({
mutationFn: async ({ docType, orgKey }: { docType: string, orgKey: string }) => {
const response = await fetch('/api/staff/documents/delete-general', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ org_key: slugify(orgKey), doc_type: docType })
})
if (!response.ok) throw new Error('Erreur suppression')
},
onSuccess: () => {
toast.success('Document supprimé')
queryClient.invalidateQueries({ queryKey: ['documents', 'generaux'] })
},
onError: () => toast.error('Erreur lors de la suppression')
})
// Supprimer un document comptable
const deleteComptableDoc = useMutation({
mutationFn: async (docId: string) => {
const response = await fetch(`/api/staff/documents/delete?doc_id=${docId}`, {
method: 'DELETE'
})
if (!response.ok) throw new Error('Erreur suppression')
},
onSuccess: () => {
toast.success('Document supprimé')
queryClient.invalidateQueries({ queryKey: ['documents', 'comptables'] })
},
onError: () => toast.error('Erreur lors de la suppression')
})
return (
<div className="space-y-6">
<header>
<h1 className="text-3xl font-bold tracking-tight">Gestion des documents</h1>
<p className="text-muted-foreground mt-2">
Gérez les documents généraux et comptables de vos clients
</p>
</header>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Sidebar: Liste des organisations */}
<div className="lg:col-span-1">
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Building2 className="h-4 w-4" />
Organisations
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
placeholder="Rechercher..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-9"
/>
</div>
<div className="max-h-[600px] overflow-y-auto space-y-1">
{isLoadingOrgs ? (
<div className="flex justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-gray-400" />
</div>
) : filteredOrgs.length > 0 ? (
filteredOrgs.map((org) => (
<button
key={org.id}
onClick={() => setSelectedOrgId(org.id)}
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition-colors ${
selectedOrgId === org.id
? 'bg-blue-50 text-blue-700 font-medium'
: 'hover:bg-gray-50'
}`}
>
{org.name}
</button>
))
) : (
<p className="text-sm text-gray-500 text-center py-8">
Aucune organisation trouvée
</p>
)}
</div>
</div>
</CardContent>
</Card>
</div>
{/* Main content */}
<div className="lg:col-span-3">
{!selectedOrgId ? (
<Card>
<CardContent className="flex items-center justify-center py-16">
<div className="text-center">
<Building2 className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<p className="text-gray-500">
Sélectionnez une organisation pour gérer ses documents
</p>
</div>
</CardContent>
</Card>
) : (
<div className="space-y-4">
{/* Header avec nom organisation */}
<Card className="bg-gradient-to-r from-blue-50 to-indigo-50 border-blue-200">
<CardHeader>
<CardTitle className="text-xl">{selectedOrg?.name}</CardTitle>
<CardDescription>
Clé: {selectedOrg?.structure_api}
</CardDescription>
</CardHeader>
</Card>
{/* Onglets */}
<div className="flex gap-2 border-b">
<button
onClick={() => setActiveTab('generaux')}
className={`px-4 py-2 font-medium transition-colors border-b-2 ${
activeTab === 'generaux'
? 'border-blue-600 text-blue-600'
: 'border-transparent text-gray-600 hover:text-gray-900'
}`}
>
<FileText className="h-4 w-4 inline mr-2" />
Documents généraux
</button>
<button
onClick={() => setActiveTab('comptables')}
className={`px-4 py-2 font-medium transition-colors border-b-2 ${
activeTab === 'comptables'
? 'border-blue-600 text-blue-600'
: 'border-transparent text-gray-600 hover:text-gray-900'
}`}
>
<Folder className="h-4 w-4 inline mr-2" />
Documents comptables
</button>
</div>
{/* Documents généraux */}
{activeTab === 'generaux' && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{Object.entries(DOC_TYPES).map(([type, label]) => {
const doc = documentsGeneraux?.find(d => d.type === type)
return (
<Card key={type} className="hover:shadow-md transition-shadow">
<CardHeader>
<CardTitle className="text-base flex items-center justify-between">
<span className="flex items-center gap-2">
<FileText className="h-4 w-4" />
{label}
</span>
{doc?.available ? (
<span className="text-xs bg-green-100 text-green-700 px-2 py-1 rounded-full">
Disponible
</span>
) : (
<span className="text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded-full">
Non uploadé
</span>
)}
</CardTitle>
</CardHeader>
<CardContent>
{doc?.available ? (
<div className="space-y-2">
<p className="text-sm text-gray-600">
{formatBytes(doc.size)}
</p>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => setViewDocument({
name: doc.name || DOC_TYPES[type as keyof typeof DOC_TYPES],
downloadUrl: doc.downloadUrl!,
size: doc.size
})}
className="flex-1"
>
<FileText className="h-3 w-3 mr-1" />
Voir
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setUploadGeneralModal(type)}
className="flex-1"
>
<Edit3 className="h-3 w-3 mr-1" />
Remplacer
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
setDeleteConfirm({
type: 'general',
data: {
docType: type,
orgKey: selectedOrg?.structure_api || '',
label: DOC_TYPES[type as keyof typeof DOC_TYPES]
}
})
}}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
) : (
<Button
size="sm"
onClick={() => setUploadGeneralModal(type)}
className="w-full"
>
<Plus className="h-3 w-3 mr-1" />
Uploader
</Button>
)}
</CardContent>
</Card>
)
})}
</div>
)}
{/* Documents comptables */}
{activeTab === 'comptables' && (
<div className="space-y-4">
<div className="flex justify-end">
<Button onClick={() => setUploadComptableModal(true)}>
<Plus className="h-4 w-4 mr-2" />
Uploader un document
</Button>
</div>
{documentsByPeriod.size === 0 ? (
<Card>
<CardContent className="flex items-center justify-center py-16">
<div className="text-center">
<Folder className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<p className="text-gray-500">Aucun document comptable</p>
</div>
</CardContent>
</Card>
) : (
<div className="space-y-2">
{Array.from(documentsByPeriod.entries()).map(([period, docs]) => {
const isExpanded = expandedPeriods.has(period)
return (
<Card key={period}>
<button
onClick={() => {
const next = new Set(expandedPeriods)
if (next.has(period)) next.delete(period)
else next.add(period)
setExpandedPeriods(next)
}}
className="w-full p-4 flex items-center justify-between hover:bg-gray-50 transition-colors"
>
<div className="flex items-center gap-3">
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
<div className="text-left">
<h3 className="font-semibold">{period}</h3>
<p className="text-sm text-gray-500">
{docs.length} document{docs.length > 1 ? 's' : ''}
</p>
</div>
</div>
</button>
{isExpanded && (
<div className="border-t p-4 space-y-2">
{docs.map((doc: DocumentItem) => (
<div
key={doc.id}
className="flex items-center justify-between p-3 border rounded-lg hover:bg-gray-50"
>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{doc.filename}</p>
<p className="text-sm text-gray-500">
{formatDate(doc.date_added)} {formatBytes(doc.size_bytes)}
</p>
</div>
<div className="flex gap-2">
{doc.download_url && (
<Button
size="sm"
variant="outline"
onClick={() => setViewDocument({
name: doc.filename,
downloadUrl: doc.download_url!,
size: doc.size_bytes
})}
>
<FileText className="h-3 w-3 mr-1" />
Voir
</Button>
)}
<Button
size="sm"
variant="outline"
onClick={() => {
setDeleteConfirm({
type: 'comptable',
data: {
id: doc.id,
filename: doc.filename
}
})
}}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
))}
</div>
)}
</Card>
)
})}
</div>
)}
</div>
)}
</div>
)}
</div>
</div>
{/* Modals */}
{uploadGeneralModal && selectedOrg && (
<UploadGeneralDocModal
isOpen={true}
onClose={() => setUploadGeneralModal(null)}
orgId={selectedOrgId}
orgKey={selectedOrg.structure_api}
docType={uploadGeneralModal}
/>
)}
{uploadComptableModal && selectedOrg && (
<UploadComptableModal
isOpen={true}
onClose={() => setUploadComptableModal(false)}
orgId={selectedOrgId}
orgKey={selectedOrg.structure_api}
/>
)}
<ViewDocumentModal
isOpen={!!viewDocument}
onClose={() => setViewDocument(null)}
document={viewDocument}
/>
{/* Modale de confirmation de suppression */}
<Dialog open={!!deleteConfirm} onOpenChange={() => setDeleteConfirm(null)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-red-600">
<Trash2 className="h-5 w-5" />
Confirmer la suppression
</DialogTitle>
<DialogDescription>
Cette action est irréversible. Le document sera définitivement supprimé.
</DialogDescription>
</DialogHeader>
<div className="py-4">
<p className="text-sm text-muted-foreground">
{deleteConfirm?.type === 'general' ? (
<>Voulez-vous vraiment supprimer <strong>{deleteConfirm.data.label}</strong> ?</>
) : (
<>Voulez-vous vraiment supprimer <strong>{deleteConfirm?.data.filename}</strong> ?</>
)}
</p>
</div>
<div className="flex gap-3 justify-end">
<Button
variant="outline"
onClick={() => setDeleteConfirm(null)}
>
Annuler
</Button>
<Button
onClick={() => {
if (deleteConfirm?.type === 'general') {
deleteGeneralDoc.mutate({
docType: deleteConfirm.data.docType,
orgKey: deleteConfirm.data.orgKey
})
} else if (deleteConfirm?.type === 'comptable') {
deleteComptableDoc.mutate(deleteConfirm.data.id)
}
setDeleteConfirm(null)
}}
className="bg-red-600 text-white hover:bg-red-700 border-red-600"
>
<Trash2 className="h-4 w-4 mr-2" />
Supprimer
</Button>
</div>
</DialogContent>
</Dialog>
</div>
)
}