espace-paie-odentas/app/(app)/vos-documents/page.tsx
odentas 266eb3598a feat: Implémenter store global Zustand + calcul total quantités + fix structure field + montants personnalisés virements
- Créer hook useStaffOrgSelection avec persistence localStorage
- Ajouter badge StaffOrgBadge dans Sidebar
- Synchroniser filtres org dans toutes les pages (contrats, cotisations, facturation, etc.)
- Fix calcul cachets: utiliser totalQuantities au lieu de dates.length
- Fix structure field bug: ne plus écraser avec production_name
- Ajouter création note lors modification contrat
- Implémenter montants personnalisés pour virements salaires
- Migrations SQL: custom_amount + fix_structure_field
- Réorganiser boutons ContractEditor en carte flottante droite
2025-12-01 21:51:57 +01:00

851 lines
29 KiB
TypeScript

'use client'
import React from 'react'
import { useQuery } 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 { Download, FileText, Upload, Folder, Building2, ChevronDown, ChevronRight, Loader2 } from 'lucide-react'
import { Textarea } from '@/components/ui/textarea'
import { usePageTitle } from '@/hooks/usePageTitle'
import { Tooltip } from '@/components/ui/tooltip'
import { supabase } from '@/lib/supabaseClient'
import { DocumentViewModal } from '@/components/DocumentViewModal'
import Cookies from 'js-cookie'
import { useStaffOrgSelection } from '@/hooks/useStaffOrgSelection'
type DocumentItem = {
id: string
title: string
url?: string
updatedAt?: string
sizeBytes?: number
meta?: Record<string, any>
period_label?: string | null
}
type Organization = {
id: string
name: string
key: string
}
type GeneralDocument = {
type: string
label: string
available: boolean
key?: string
name?: string
size?: number
lastModified?: string
downloadUrl?: string
}
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 formatDateLast(dateStr?: string) {
if (!dateStr) return ''
const date = new Date(dateStr)
return date.toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
})
}
function UploadPanel() {
const [description, setDescription] = React.useState('')
const handleSubmit = () => {
alert('Fonctionnalité de transmission de document en cours de développement')
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Upload className="h-4 w-4" />
Transmettre un document
</CardTitle>
<CardDescription>
Envoyez-nous directement vos documents
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Input type="file" />
<Textarea
placeholder="Description du document (optionnel)"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<Button onClick={handleSubmit} className="w-full">
Envoyer
</Button>
</CardContent>
</Card>
)
}
function SectionGeneraux({ selectedOrgId }: { selectedOrgId?: string }) {
const [selectedDoc, setSelectedDoc] = React.useState<GeneralDocument | null>(null);
const [isModalOpen, setIsModalOpen] = React.useState(false);
const [orgId, setOrgId] = React.useState<string | null>(null);
const [isLoadingOrgId, setIsLoadingOrgId] = React.useState(true);
// Récupérer l'orgId depuis les props (staff) ou depuis l'API /me (client)
React.useEffect(() => {
const fetchOrgId = async () => {
if (selectedOrgId) {
// Staff: utiliser l'org sélectionnée
setOrgId(selectedOrgId);
setIsLoadingOrgId(false);
} else {
// Client: récupérer depuis l'API /me
try {
const response = await fetch('/api/me');
if (response.ok) {
const data = await response.json();
setOrgId(data.active_org_id || null);
} else {
setOrgId(null);
}
} catch (error) {
console.error('Erreur récupération org_id:', error);
setOrgId(null);
} finally {
setIsLoadingOrgId(false);
}
}
};
fetchOrgId();
}, [selectedOrgId]);
const { data: documentsGeneraux, isLoading, error } = useQuery<GeneralDocument[]>({
queryKey: ['documents', 'generaux', orgId],
queryFn: async () => {
if (!orgId) {
throw new Error('Aucune organisation sélectionnée');
}
const res = await fetch(`/api/documents/generaux?org_id=${encodeURIComponent(orgId)}`);
if (!res.ok) {
const errorText = await res.text();
throw new Error('Erreur lors de la récupération des documents');
}
const data = await res.json();
return data.documents || [];
},
enabled: !!orgId
});
const handleViewDocument = (doc: GeneralDocument) => {
if (doc.available && doc.downloadUrl) {
setSelectedDoc(doc);
setIsModalOpen(true);
}
};
if (isLoadingOrgId) {
return (
<div className="text-center py-8">
<p className="text-muted-foreground">Chargement...</p>
</div>
);
}
if (!orgId) {
return (
<div className="text-center py-8">
<p className="text-muted-foreground">
Aucune organisation associée
</p>
</div>
);
}
if (isLoading) {
return (
<div className="text-center py-8">
<p className="text-muted-foreground">Chargement des documents...</p>
</div>
);
}
if (error) {
return (
<div className="text-center py-8">
<p className="text-red-500">Erreur: {String(error)}</p>
</div>
);
}
if (!documentsGeneraux || documentsGeneraux.length === 0) {
return (
<div className="text-center py-8">
<p className="text-muted-foreground">Aucun document trouvé</p>
</div>
);
}
return (
<>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{documentsGeneraux && documentsGeneraux.map((doc) => (
<Card
key={doc.type}
className={`cursor-pointer transition-all ${
doc.available
? 'hover:shadow-md hover:border-blue-300'
: 'opacity-60 cursor-not-allowed'
}`}
onClick={() => doc.available && handleViewDocument(doc)}
>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<FileText className="h-4 w-4" />
{doc.label}
</CardTitle>
</CardHeader>
<CardContent>
{doc.available ? (
<div className="space-y-1">
{doc.size && (
<p className="text-sm text-muted-foreground">
{formatBytes(doc.size)}
</p>
)}
<Button
size="sm"
variant="outline"
className="w-full mt-2"
onClick={(e) => {
e.stopPropagation();
handleViewDocument(doc);
}}
>
<FileText className="h-4 w-4 mr-2" />
Consulter
</Button>
</div>
) : (
<div className="text-sm text-muted-foreground">
Non concerné ou indisponible
</div>
)}
</CardContent>
</Card>
))}
</div>
{selectedDoc && (
<DocumentViewModal
isOpen={isModalOpen}
onClose={() => {
setIsModalOpen(false);
setSelectedDoc(null);
}}
document={selectedDoc.available && selectedDoc.downloadUrl ? {
name: selectedDoc.name || '',
label: selectedDoc.label,
downloadUrl: selectedDoc.downloadUrl,
size: selectedDoc.size
} : null}
/>
)}
</>
);
}
function SectionCaisses() {
const { data: documentsOrganismes, isLoading, error } = useQuery<DocumentItem[]>({
queryKey: ['documents', 'caisses'],
queryFn: async () => {
const res = await fetch('/api/documents?category=caisses')
const data = await res.json()
if (data && typeof data === 'object' && !Array.isArray(data)) {
if (data.documents) return data.documents
if (data.data) return data.data
if (data.items) return data.items
}
return Array.isArray(data) ? data : []
}
})
const handleDownload = (item: DocumentItem) => {
if (item.url) {
window.open(item.url, '_blank')
} else {
alert('Document non disponible')
}
}
if (isLoading) {
return <p className="text-center text-muted-foreground py-8">Chargement...</p>
}
if (error) {
return <p className="text-center text-red-500 py-8">Erreur: {String(error)}</p>
}
return (
<div className="space-y-2">
{documentsOrganismes && documentsOrganismes.length > 0 ? (
documentsOrganismes.map((item) => (
<div key={item.id} className="flex items-center justify-between p-3 border rounded-lg">
<div>
<h4 className="font-medium">{item.title}</h4>
<p className="text-sm text-muted-foreground">
{formatDateLast(item.updatedAt)} {formatBytes(item.sizeBytes)}
</p>
</div>
<Button
size="sm"
onClick={() => handleDownload(item)}
disabled={!item.url}
>
<Download className="h-4 w-4 mr-2" />
Télécharger
</Button>
</div>
))
) : (
<p className="text-center text-muted-foreground py-8">
Aucun document disponible
</p>
)}
</div>
)
}
function SectionComptables() {
const [expandedYears, setExpandedYears] = React.useState<Set<string>>(new Set())
const [expandedPeriods, setExpandedPeriods] = React.useState<Set<string>>(new Set())
const [loadedPeriods, setLoadedPeriods] = React.useState<Set<string>>(new Set())
// Récupérer uniquement les métadonnées (sans URLs pré-signées)
const { data: documentsCompta, isLoading, error } = useQuery<DocumentItem[]>({
queryKey: ['documents', 'comptables', 'metadata'],
queryFn: async () => {
const res = await fetch('/api/documents?category=docs_comptables&metadata_only=true')
const data = await res.json()
if (data && typeof data === 'object' && !Array.isArray(data)) {
if (data.documents) {
return data.documents
}
if (data.data) {
return data.data
}
if (data.items) {
return data.items
}
}
const result = Array.isArray(data) ? data : []
return result
}
})
// Charger les URLs pour une période spécifique
const { data: periodUrls = {} } = useQuery<Record<string, DocumentItem[]>>({
queryKey: ['documents', 'comptables', 'urls', Array.from(loadedPeriods)],
queryFn: async () => {
if (loadedPeriods.size === 0) return {}
const urls: Record<string, DocumentItem[]> = {}
for (const period of Array.from(loadedPeriods)) {
const res = await fetch(`/api/documents?category=docs_comptables&period=${encodeURIComponent(period)}`)
const data = await res.json()
let docs = []
if (data && typeof data === 'object' && !Array.isArray(data)) {
docs = data.documents || data.data || data.items || []
} else {
docs = Array.isArray(data) ? data : []
}
urls[period] = docs
}
return urls
},
enabled: loadedPeriods.size > 0
})
// Extraire l'année depuis le period_label (format: "2507-juillet-2025")
const extractYear = (periodLabel: string): string => {
const match = periodLabel.match(/-(\d{4})$/)
return match ? match[1] : 'Année inconnue'
}
// Grouper les documents par année puis par période
const documentsByYear = React.useMemo((): Map<string, Map<string, DocumentItem[]>> => {
if (!documentsCompta || documentsCompta.length === 0) return new Map()
const grouped = new Map<string, Map<string, DocumentItem[]>>()
documentsCompta.forEach(doc => {
const period = doc.period_label || 'Sans période'
const year = period === 'Sans période' ? 'Sans année' : extractYear(period)
if (!grouped.has(year)) {
grouped.set(year, new Map())
}
const yearGroup = grouped.get(year)!
if (!yearGroup.has(period)) {
yearGroup.set(period, [])
}
yearGroup.get(period)!.push(doc)
})
// Trier les années par ordre décroissant
const sortedYears = Array.from(grouped.entries()).sort((a, b) => {
if (a[0] === 'Sans année') return 1
if (b[0] === 'Sans année') return -1
return b[0].localeCompare(a[0])
})
// Pour chaque année, trier les périodes
sortedYears.forEach(([_, periods]) => {
const sortedPeriods = Array.from(periods.entries()).sort((a, b) => {
if (a[0] === 'Sans période') return 1
if (b[0] === 'Sans période') return -1
return b[0].localeCompare(a[0])
})
periods.clear()
sortedPeriods.forEach(([period, docs]) => periods.set(period, docs))
})
return new Map(sortedYears)
}, [documentsCompta])
const toggleYear = (year: string) => {
setExpandedYears(prev => {
const next = new Set(prev)
if (next.has(year)) {
next.delete(year)
} else {
next.add(year)
}
return next
})
}
const togglePeriod = (period: string) => {
setExpandedPeriods(prev => {
const next = new Set(prev)
if (next.has(period)) {
next.delete(period)
} else {
next.add(period)
// Charger les URLs pour cette période
setLoadedPeriods(loaded => new Set([...loaded, period]))
}
return next
})
}
const handleDownload = (item: DocumentItem, period: string) => {
// Chercher l'URL dans les données chargées
const docsWithUrls = periodUrls[period]
if (docsWithUrls) {
const docWithUrl = docsWithUrls.find(d => d.id === item.id)
if (docWithUrl?.url) {
window.open(docWithUrl.url, '_blank')
return
}
}
alert('Document non disponible')
}
if (isLoading) {
return <p className="text-center text-muted-foreground py-8">Chargement...</p>
}
if (error) {
return <p className="text-center text-red-500 py-8">Erreur: {String(error)}</p>
}
if (!documentsCompta || documentsCompta.length === 0) {
return (
<p className="text-center text-muted-foreground py-8">
Aucun document comptable disponible
</p>
)
}
return (
<div className="space-y-3">
{Array.from(documentsByYear.entries()).map(([year, periods]) => {
const isYearExpanded = expandedYears.has(year)
const totalDocs = Array.from(periods.values()).reduce((sum, docs: DocumentItem[]) => sum + docs.length, 0)
return (
<div key={year} className="border rounded-lg overflow-hidden">
{/* Header de l'année - cliquable */}
<button
onClick={() => toggleYear(year)}
className="w-full flex items-center justify-between p-4 bg-muted/50 hover:bg-muted/70 transition-colors"
>
<div className="flex items-center gap-3">
{isYearExpanded ? (
<ChevronDown className="h-5 w-5 text-muted-foreground" />
) : (
<ChevronRight className="h-5 w-5 text-muted-foreground" />
)}
<div className="text-left">
<h3 className="font-bold text-lg">{year}</h3>
<p className="text-sm text-muted-foreground">
{totalDocs} document{totalDocs > 1 ? 's' : ''} {periods.size} période{periods.size > 1 ? 's' : ''}
</p>
</div>
</div>
</button>
{/* Liste des périodes pour cette année */}
{isYearExpanded && (
<div className="bg-background">
{Array.from(periods.entries()).map(([period, docs]: [string, DocumentItem[]]) => {
const isPeriodExpanded = expandedPeriods.has(period)
const isLoading = isPeriodExpanded && loadedPeriods.has(period) && !periodUrls[period]
return (
<div key={period} className="border-t">
{/* Header de la période */}
<button
onClick={() => togglePeriod(period)}
className="w-full flex items-center justify-between p-3 pl-12 hover:bg-muted/30 transition-colors"
>
<div className="flex items-center gap-3">
{isPeriodExpanded ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
<div className="text-left">
<h4 className="font-semibold text-sm">{period}</h4>
<p className="text-xs text-muted-foreground">
{docs.length} document{docs.length > 1 ? 's' : ''}
</p>
</div>
</div>
</button>
{/* Liste des documents */}
{isPeriodExpanded && (
<div className="p-2 pl-12 space-y-2">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 text-blue-600 animate-spin" />
</div>
) : (
docs.map((item: DocumentItem) => (
<div
key={item.id}
className="flex items-center justify-between p-3 border rounded-lg hover:bg-muted/20 transition-colors"
>
<div className="flex-1 min-w-0">
<h5 className="font-medium text-sm truncate">{item.title}</h5>
<p className="text-xs text-muted-foreground">
{formatDateLast(item.updatedAt)} {formatBytes(item.sizeBytes)}
</p>
</div>
<Button
size="sm"
onClick={() => handleDownload(item, period)}
disabled={isLoading}
className="ml-4"
>
<Download className="h-4 w-4 mr-2" />
Télécharger
</Button>
</div>
))
)}
</div>
)}
</div>
)
})}
</div>
)}
</div>
)
})}
</div>
)
}
export default function VosDocumentsPage() {
usePageTitle("Vos documents");
// Helper pour valider les UUIDs
const isValidUUID = (str: string | null): boolean => {
if (!str) return false;
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(str);
};
// Zustand store pour la sélection d'organisation (staff)
const {
selectedOrgId: globalSelectedOrgId,
setSelectedOrg: setGlobalSelectedOrg
} = useStaffOrgSelection();
const [activeTab, setActiveTab] = React.useState('comptables');
// État local initialisé avec la valeur globale si elle est un UUID valide
const [selectedOrgId, setSelectedOrgId] = React.useState<string>(
isValidUUID(globalSelectedOrgId) ? globalSelectedOrgId : ''
);
const [isStaff, setIsStaff] = React.useState(false);
const [isCheckingStaff, setIsCheckingStaff] = React.useState(true);
// Récupérer les informations de l'utilisateur et vérifier s'il est staff via API
React.useEffect(() => {
const checkStaffStatus = async () => {
setIsCheckingStaff(true);
try {
const response = await fetch('/api/me');
if (response.ok) {
const data = await response.json();
setIsStaff(data.is_staff === true);
} else {
setIsStaff(false);
}
} catch (error) {
setIsStaff(false);
} finally {
setIsCheckingStaff(false);
}
};
checkStaffStatus();
}, []);
// Récupérer la liste des organisations (pour staff uniquement)
const { data: organizations, isLoading: isLoadingOrgs } = useQuery<Organization[]>({
queryKey: ['organizations', 'all'],
queryFn: async () => {
const response = await fetch('/api/organizations');
if (!response.ok) {
throw new Error('Failed to fetch organizations');
}
const data = await response.json();
// L'API retourne { items: [...] }
const items = data.items || [];
// Trier par nom et mapper au bon format
const sorted = items
.map((org: any) => ({
id: org.id,
name: org.name,
key: org.structure_api || org.key
}))
.sort((a: Organization, b: Organization) =>
a.name.localeCompare(b.name)
);
return sorted;
},
enabled: isStaff && !isCheckingStaff
});
// Synchronisation bidirectionnelle : global → local
React.useEffect(() => {
if (isStaff && isValidUUID(globalSelectedOrgId)) {
setSelectedOrgId(globalSelectedOrgId);
}
}, [globalSelectedOrgId, isStaff]);
// Mettre à jour le cookie active_org_id quand l'organisation sélectionnée change
React.useEffect(() => {
if (selectedOrgId && isStaff) {
const selectedOrg = organizations?.find(org => org.id === selectedOrgId);
if (selectedOrg) {
// Mettre à jour les cookies
document.cookie = `active_org_id=${selectedOrgId}; path=/; max-age=31536000`;
document.cookie = `active_org_name=${encodeURIComponent(selectedOrg.name)}; path=/; max-age=31536000`;
document.cookie = `active_org_key=${selectedOrg.key}; path=/; max-age=31536000`;
}
}
}, [selectedOrgId, isStaff, organizations]);
return (
<div className="space-y-6">
<header className="flex items-center justify-between">
<h2 className="text-2xl font-semibold tracking-tight">Vos documents</h2>
{isStaff && (
<span className="text-sm bg-blue-100 text-blue-800 px-3 py-1 rounded-full">
Mode Staff
</span>
)}
</header>
{/* Sélecteur d'organisation pour le staff */}
{isStaff && (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Building2 className="h-4 w-4" />
Sélectionner une organisation
</CardTitle>
<CardDescription>
Choisissez l'organisation dont vous souhaitez consulter les documents
</CardDescription>
</CardHeader>
<CardContent>
{isLoadingOrgs ? (
<p className="text-sm text-gray-500">Chargement des organisations...</p>
) : organizations && organizations.length > 0 ? (
<select
value={selectedOrgId}
onChange={(e) => {
const newOrgId = e.target.value;
setSelectedOrgId(newOrgId);
// Synchronisation bidirectionnelle : local → global
if (newOrgId) {
const selectedOrg = organizations.find(org => org.id === newOrgId);
if (selectedOrg) {
setGlobalSelectedOrg(newOrgId, selectedOrg.name);
}
} else {
setGlobalSelectedOrg(null, null);
}
}}
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
>
<option value="">-- Sélectionner une organisation --</option>
{organizations.map((org) => (
<option key={org.id} value={org.id}>
{org.name}
</option>
))}
</select>
) : (
<p className="text-sm text-amber-600">Aucune organisation trouvée</p>
)}
</CardContent>
</Card>
)}
{/* Message si staff sans organisation sélectionnée */}
{isStaff && !selectedOrgId && !isLoadingOrgs && (
<Card className="border-amber-200 bg-amber-50">
<CardContent className="pt-6">
<p className="text-sm text-amber-800">
⚠️ Veuillez sélectionner une organisation pour afficher ses documents
</p>
</CardContent>
</Card>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Colonne gauche : Documents disponibles */}
<div className="lg:col-span-2">
<Card>
<CardHeader>
<CardTitle>Documents disponibles</CardTitle>
<CardDescription>
{activeTab === 'generaux' && 'Téléchargez vos documents généraux'}
{activeTab === 'comptables' && 'Téléchargez vos documents comptables'}
</CardDescription>
</CardHeader>
<CardContent>
{activeTab === 'generaux' && <SectionGeneraux selectedOrgId={selectedOrgId} />}
{activeTab === 'comptables' && <SectionComptables />}
</CardContent>
</Card>
</div>
{/* Colonne droite : Onglets + Transmettre un document */}
<div className="lg:col-span-1 space-y-4">
<Card className="border-2 border-blue-200 shadow-md">
<CardHeader className="bg-gradient-to-r from-blue-50 to-indigo-50 pb-3">
<CardTitle className="text-lg font-bold text-blue-900 flex items-center gap-2">
<Folder className="h-5 w-5 text-blue-600" />
Catégories de documents
</CardTitle>
<CardDescription className="text-blue-700 font-medium">
Choisissez le type de document
</CardDescription>
</CardHeader>
<CardContent className="space-y-3 pt-5">
<Button
variant={activeTab === 'comptables' ? 'default' : 'outline'}
className={`w-full justify-start h-auto py-4 text-base font-semibold transition-all ${
activeTab === 'comptables'
? 'bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 shadow-lg scale-105'
: 'bg-gradient-to-r from-blue-50 to-indigo-50 border-blue-200 text-blue-900 hover:from-blue-100 hover:to-indigo-100 hover:border-blue-300 hover:scale-102'
}`}
onClick={() => setActiveTab('comptables')}
>
<Folder className="h-5 w-5 mr-3" />
<span className="flex-1 text-left">Documents comptables</span>
{activeTab === 'comptables' && (
<span className="ml-2 text-xs bg-white/20 px-2 py-1 rounded-full">✓ Actif</span>
)}
</Button>
<Button
variant={activeTab === 'generaux' ? 'default' : 'outline'}
className={`w-full justify-start h-auto py-4 text-base font-semibold transition-all ${
activeTab === 'generaux'
? 'bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 shadow-lg scale-105'
: 'bg-gradient-to-r from-blue-50 to-indigo-50 border-blue-200 text-blue-900 hover:from-blue-100 hover:to-indigo-100 hover:border-blue-300 hover:scale-102'
}`}
onClick={() => setActiveTab('generaux')}
>
<FileText className="h-5 w-5 mr-3" />
<span className="flex-1 text-left">Documents généraux</span>
{activeTab === 'generaux' && (
<span className="ml-2 text-xs bg-white/20 px-2 py-1 rounded-full">✓ Actif</span>
)}
</Button>
<Tooltip
content="Les documents des caisses seront de nouveau disponibles dans quelques jours"
side="left"
>
<Button
variant="outline"
className="w-full justify-start h-auto py-4 text-base font-semibold opacity-50 cursor-not-allowed bg-gradient-to-r from-gray-50 to-slate-50 border-gray-200"
disabled
>
<Building2 className="h-5 w-5 mr-3" />
<span className="flex-1 text-left">Caisses & organismes</span>
<span className="ml-2 text-xs bg-gray-100 px-2 py-1 rounded-full">Bientôt</span>
</Button>
</Tooltip>
</CardContent>
</Card>
<UploadPanel />
<Card>
<CardHeader>
<CardTitle className="text-base">Besoin d'aide ?</CardTitle>
<CardDescription>
N'hésitez pas à nous contacter si vous avez besoin d'une attestation spécifique.
</CardDescription>
</CardHeader>
</Card>
</div>
</div>
</div>
)
}