espace-paie-odentas/components/staff/SalariesGridSimple.tsx

803 lines
No EOL
35 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useEffect, useMemo, useState, useRef } from "react";
import { supabase } from "@/lib/supabaseClient";
import { FileText, Briefcase, StickyNote, User, Mail, Phone, MapPin, Download, Loader2, Clock } from "lucide-react";
import SalarieModal from "./SalarieModal";
import UploadDocumentModal from "./UploadDocumentModal";
import DocumentViewerModal from "./DocumentViewerModal";
import ResendInvitationModal from "./ResendInvitationModal";
type Salarie = {
id: string;
code_salarie?: string | null;
num_salarie?: number | null;
nom?: string | null;
nom_de_naissance?: string | null;
prenom?: string | null;
salarie?: string | null;
civilite?: string | null;
pseudonyme?: string | null;
compte_transat?: string | null;
topaze?: string | null;
justificatifs_personnels?: string | null;
rf_au_sens_fiscal?: boolean | null;
intermittent_mineur_16?: boolean | null;
adresse_mail?: string | null;
nir?: string | null;
conges_spectacles?: string | null;
tel?: string | null;
adresse?: string | null;
date_naissance?: string | null;
lieu_de_naissance?: string | null;
iban?: string | null;
bic?: string | null;
abattement_2024?: string | null;
infos_caisses_organismes?: string | null;
notif_nouveau_salarie?: string | null;
notif_employeur?: string | null;
derniere_profession?: string | null;
employer_id?: string | null;
organization_name?: string | null;
notes?: string | null;
last_notif_justifs?: string | null;
created_at?: string | null;
updated_at?: string | null;
};
type S3Document = {
key: string;
name: string;
type: string;
size: number;
lastModified: string;
downloadUrl: string;
};
export default function SalariesGrid({ initialData, activeOrgId }: { initialData: Salarie[]; activeOrgId?: string | null }) {
const [rows, setRows] = useState<Salarie[]>(initialData || []);
const [showRaw, setShowRaw] = useState(false);
const [loading, setLoading] = useState(false);
// filters / sorting / pagination
const [q, setQ] = useState("");
const [professionFilter, setProfessionFilter] = useState<string | null>(null);
const [transatFilter, setTransatFilter] = useState<string | null>(null);
const [sortField, setSortField] = useState<string>("nom");
const [sortOrder, setSortOrder] = useState<'asc'|'desc'>('asc');
const [page, setPage] = useState(0);
const [limit, setLimit] = useState(50);
const totalCountRef = useRef<number | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [editing, setEditing] = useState<{ id: string; field: string } | null>(null);
const [draftValue, setDraftValue] = useState<string>("");
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedContracts, setSelectedContracts] = useState<any[]>([]);
const [loadingContracts, setLoadingContracts] = useState(false);
const [documents, setDocuments] = useState<S3Document[]>([]);
const [loadingDocuments, setLoadingDocuments] = useState(false);
const [isUploadModalOpen, setIsUploadModalOpen] = useState(false);
const [selectedDocument, setSelectedDocument] = useState<S3Document | null>(null);
const [isDocumentViewerOpen, setIsDocumentViewerOpen] = useState(false);
const [isEditingNote, setIsEditingNote] = useState(false);
const [noteValue, setNoteValue] = useState("");
const [savingNote, setSavingNote] = useState(false);
const [isResendInvitationOpen, setIsResendInvitationOpen] = useState(false);
// optimistic update helper
async function saveCell(id: string, field: string, value: string) {
// optimistic local update
setRows((r) => r.map((x) => (x.id === id ? { ...x, [field]: value } : x)));
try {
const body: any = { id };
body[field] = value;
const res = await fetch(`/api/staff/salaries/update`, { method: "POST", body: JSON.stringify(body), headers: { "Content-Type": "application/json" } });
if (!res.ok) {
const j = await res.json().catch(() => ({}));
throw new Error(j?.error || "Update failed");
}
} catch (err: any) {
console.error("Update failed", err);
// optionally refetch; for now we don't revert
}
}
const selectedRow = useMemo(() => rows.find((r) => r.id === selectedId) ?? null, [rows, selectedId]);
// Fonction pour récupérer les contrats du salarié sélectionné
const fetchSalarieContracts = async (salarieId: string) => {
setLoadingContracts(true);
try {
// Récupérer le matricule du salarié pour la recherche
const selectedSalarie = rows.find(r => r.id === salarieId);
if (!selectedSalarie || !selectedSalarie.code_salarie) {
setSelectedContracts([]);
return;
}
const res = await fetch(`/api/staff/contracts/search?employee_matricule=${encodeURIComponent(selectedSalarie.code_salarie)}&limit=3&sort=start_date&order=desc`);
if (!res.ok) throw new Error('Failed to fetch contracts');
const data = await res.json();
setSelectedContracts(data.rows || []);
} catch (error) {
console.error('Error fetching contracts:', error);
setSelectedContracts([]);
} finally {
setLoadingContracts(false);
}
};
// Fonction pour récupérer les documents du salarié depuis S3
const fetchSalarieDocuments = async (salarieId: string) => {
setLoadingDocuments(true);
try {
const selectedSalarie = rows.find(r => r.id === salarieId);
if (!selectedSalarie || !selectedSalarie.code_salarie) {
setDocuments([]);
return;
}
const res = await fetch(`/api/staff/salaries/documents?matricule=${encodeURIComponent(selectedSalarie.code_salarie)}`);
if (!res.ok) throw new Error('Failed to fetch documents');
const data = await res.json();
setDocuments(data.documents || []);
} catch (error) {
console.error('Error fetching documents:', error);
setDocuments([]);
} finally {
setLoadingDocuments(false);
}
};
// Fonction pour sauvegarder la note
const handleSaveNote = async () => {
if (!selectedRow) return;
setSavingNote(true);
try {
await saveCell(selectedRow.id, 'notes', noteValue);
setIsEditingNote(false);
} catch (error) {
console.error('Error saving note:', error);
} finally {
setSavingNote(false);
}
};
// Fonction pour commencer l'édition de la note
const handleStartEditNote = () => {
setNoteValue(selectedRow?.notes || '');
setIsEditingNote(true);
};
// Fonction pour annuler l'édition de la note
const handleCancelEditNote = () => {
setIsEditingNote(false);
setNoteValue('');
};
// Effect pour récupérer les contrats et documents quand un salarié est sélectionné
useEffect(() => {
if (selectedRow?.id) {
fetchSalarieContracts(selectedRow.id);
fetchSalarieDocuments(selectedRow.id);
// Réinitialiser l'état d'édition de la note
setIsEditingNote(false);
setNoteValue('');
} else {
setSelectedContracts([]);
setDocuments([]);
setIsEditingNote(false);
setNoteValue('');
}
}, [selectedRow?.id]);
// Realtime subscription: listen to INSERT / UPDATE / DELETE on salaries
useEffect(() => {
let channel: any = null;
let mounted = true;
(async () => {
try {
channel = supabase.channel("public:salaries");
channel.on(
"postgres_changes",
{ event: "*", schema: "public", table: "salaries" },
(payload: any) => {
try {
const event = payload.event || payload.eventType || payload.type;
const record = payload.new ?? payload.record ?? payload.payload ?? payload;
if (event === "INSERT") {
const newRec = record as Salarie;
setRows((rs) => {
if (rs.find((r) => r.id === newRec.id)) return rs;
return [newRec, ...rs];
});
} else if (event === "UPDATE") {
setRows((rs) => rs.map((r) => (r.id === record.id ? { ...r, ...(record as Salarie) } : r)));
} else if (event === "DELETE") {
const id = record?.id ?? payload.old?.id;
if (id) setRows((rs) => rs.filter((r) => r.id !== id));
}
} catch (err) {
console.error("Realtime handler error", err);
}
}
);
// subscribe and await result (some SDKs return a promise)
const sub = await channel.subscribe();
// If subscribe returned an object with error info, warn and stop
if (!mounted) return;
if (sub && (sub.status === "timed_out" || sub.status === "closed" || sub?.error)) {
console.warn("Realtime subscribe returned unexpected status", sub);
}
} catch (err: any) {
// Common cause: table not published for realtime / not in schema cache
console.warn("Realtime subscription failed for public.salaries — falling back to polling.", err?.message ?? err);
}
})();
return () => {
mounted = false;
try {
if (channel) {
// supabase.removeChannel exists in v2
// @ts-ignore
if (supabase.removeChannel) supabase.removeChannel(channel);
else channel.unsubscribe && channel.unsubscribe();
}
} catch (err) {
console.warn("Error unsubscribing realtime channel", err);
}
};
}, []);
// Helper: fetch server-side with current filters
async function fetchServer(pageIndex = 0) {
setLoading(true);
try {
const params = new URLSearchParams();
if (q) params.set('q', q);
if (professionFilter) params.set('profession', professionFilter);
if (transatFilter) params.set('compte_transat', transatFilter);
params.set('sort', sortField);
params.set('order', sortOrder === 'asc' ? 'asc' : 'desc');
params.set('limit', String(limit));
params.set('offset', String(pageIndex * limit));
const res = await fetch(`/api/staff/salaries/search?${params.toString()}`);
if (!res.ok) throw new Error('Search failed');
const j = await res.json();
totalCountRef.current = j.count ?? (j.rows ? j.rows.length : 0);
setRows(j.rows ?? []);
setPage(pageIndex);
} catch (err) {
console.error('Search error', err);
} finally {
setLoading(false);
}
}
// Debounce searches when filters change
useEffect(() => {
// if no filters applied, prefer initial data
const noFilters = !q && !professionFilter && !transatFilter && sortField === 'nom' && sortOrder === 'asc';
if (noFilters) {
setRows(initialData || []);
return;
}
const t = setTimeout(() => fetchServer(0), 300);
return () => clearTimeout(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [q, professionFilter, transatFilter, sortField, sortOrder, limit]);
// derive options from initialData for simple selects
const professions = useMemo(() => Array.from(new Set((initialData || []).map((r) => r.derniere_profession).filter(Boolean) as string[])).slice(0,50), [initialData]);
const transatStatuses = useMemo(() => Array.from(new Set((initialData || []).map((r) => r.compte_transat).filter(Boolean) as string[])).slice(0,50), [initialData]);
return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Colonne 1 & 2: Table des salariés */}
<div className="lg:col-span-2">
{/* Filters */}
<div className="mb-3 flex flex-col sm:flex-row sm:items-center sm:gap-3">
<div className="flex-1">
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Recherche (nom, prénom, email)" className="w-full rounded border px-2 py-1 text-sm" />
</div>
<div className="flex gap-2 mt-2 sm:mt-0">
<select value={professionFilter ?? ""} onChange={(e) => setProfessionFilter(e.target.value || null)} className="rounded border px-2 py-1 text-sm">
<option value="">Toutes professions</option>
{professions.map((p) => (<option key={p} value={p}>{p}</option>))}
</select>
<select value={transatFilter ?? ""} onChange={(e) => setTransatFilter(e.target.value || null)} className="rounded border px-2 py-1 text-sm">
<option value="">Tous statuts Transat</option>
{transatStatuses.map((t) => (<option key={t} value={t}>{t}</option>))}
</select>
<button className="rounded border px-3 py-1 text-sm" onClick={() => { setQ(''); setProfessionFilter(null); setTransatFilter(null); setSortField('nom'); setSortOrder('asc'); setRows(initialData || []); }}>Réinitialiser</button>
</div>
</div>
<div className="overflow-auto">
<table className="w-full text-sm">
<thead className="bg-slate-50 text-slate-600">
<tr>
<th className="text-left px-3 py-2">Statut Transat</th>
<th className="text-left px-3 py-2 cursor-pointer" onClick={() => { setSortField('nom'); setSortOrder((o) => o === 'asc' ? 'desc' : 'asc'); }}>
Nom {sortField === 'nom' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
</th>
<th className="text-left px-3 py-2 cursor-pointer" onClick={() => { setSortField('code_salarie'); setSortOrder((o) => o === 'asc' ? 'desc' : 'asc'); }}>
Matricule {sortField === 'code_salarie' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
</th>
<th className="text-left px-3 py-2">Email</th>
<th className="text-left px-3 py-2">Organisation</th>
</tr>
</thead>
<tbody>
{rows.map((r) => {
const fullName = r.salarie || [r.nom, r.prenom].filter(Boolean).join(" ").trim() || r.nom || "—";
const matricule = r.code_salarie || r.id;
const comp = (r.compte_transat || "").toString().toLowerCase();
const transatConnecte = comp.includes("connect") && !comp.includes("non");
return (
<tr key={r.id} className={`border-t hover:bg-slate-50 cursor-pointer ${selectedId === r.id ? 'bg-blue-50' : ''}`}
onClick={() => setSelectedId(selectedId === r.id ? null : r.id)}>
<td className="px-3 py-2">
<span className={`inline-block px-2 py-1 rounded-full text-xs font-medium ${
transatConnecte
? 'bg-emerald-100 text-emerald-800'
: 'bg-slate-100 text-slate-700'
}`}>
{transatConnecte ? 'Connecté' : 'Non connecté'}
</span>
</td>
<td className="px-3 py-2">
<span className="font-medium">{fullName}</span>
</td>
<td className="px-3 py-2">
<span className="text-slate-700 font-mono text-xs">{matricule}</span>
</td>
<td className="px-3 py-2">{r.adresse_mail || "—"}</td>
<td className="px-3 py-2">{r.organization_name || "—"}</td>
</tr>
);
})}
</tbody>
</table>
{rows.length === 0 && (
<div className="p-4 text-sm text-slate-600">
<div>Aucun salarié trouvé.</div>
<div className="mt-2">
<button
className="text-xs underline"
onClick={() => setShowRaw((s) => !s)}
>
{showRaw ? "Cacher le payload" : "Voir le payload reçu"}
</button>
</div>
{showRaw && (
<pre className="mt-2 max-h-48 overflow-auto text-xs bg-slate-50 p-2 rounded border">{JSON.stringify(initialData, null, 2)}</pre>
)}
</div>
)}
</div>
{/* Pagination / info */}
<div className="mt-3 flex items-center justify-between text-xs text-slate-600">
<div>{loading ? 'Chargement…' : `Affichage ${rows.length}${totalCountRef.current ? ` / ${totalCountRef.current}` : ''}`}</div>
<div className="flex items-center gap-2">
<button className="text-xs px-2 py-1 rounded border" onClick={() => { if (page > 0) fetchServer(page - 1); }}>Préc</button>
<button className="text-xs px-2 py-1 rounded border" onClick={() => { fetchServer(page + 1); }}>Suiv</button>
</div>
</div>
</div>
{/* Colonne 3: Cards mockup */}
<div className="space-y-4">
{selectedRow ? (
<>
<div className="text-sm font-medium text-slate-800 mb-4">
Sélectionné: {selectedRow.salarie || [selectedRow.nom, selectedRow.prenom].filter(Boolean).join(" ") || "Salarié"}{selectedRow.code_salarie ? ` (${selectedRow.code_salarie})` : ""}
</div>
{/* Bouton Relance */}
<button
onClick={() => setIsResendInvitationOpen(true)}
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 text-white rounded-xl transition-all shadow-sm hover:shadow-md"
>
<Mail className="size-4" />
<span className="font-medium text-sm">Envoyer une relance justifs</span>
</button>
{/* Card Date dernière notification */}
{selectedRow.last_notif_justifs ? (
<div className="rounded-xl border bg-gradient-to-br from-blue-50 to-indigo-50 p-4 shadow-sm">
<div className="flex items-center gap-2 mb-2">
<Clock className="size-5 text-blue-600" />
<h3 className="font-medium text-slate-800">Dernière relance</h3>
</div>
<div className="text-sm text-slate-700">
<div className="font-medium">
{new Date(selectedRow.last_notif_justifs).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric'
})}
</div>
<div className="text-xs text-slate-600 mt-1">
{new Date(selectedRow.last_notif_justifs).toLocaleTimeString('fr-FR', {
hour: '2-digit',
minute: '2-digit'
})}
</div>
</div>
</div>
) : (
<div className="rounded-xl border bg-slate-50 p-4 shadow-sm">
<div className="flex items-center gap-2 mb-2">
<Clock className="size-5 text-slate-400" />
<h3 className="font-medium text-slate-600">Dernière relance</h3>
</div>
<div className="text-sm text-slate-500">
Aucune relance envoyée pour le moment
</div>
</div>
)}
{/* Card Informations personnelles */}
<div className="rounded-xl border bg-white p-4 shadow-sm">
<div className="flex items-center gap-2 mb-3">
<User className="size-5 text-indigo-600" />
<h3 className="font-medium text-slate-800">Informations personnelles</h3>
</div>
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2">
<Mail className="size-4 text-slate-400" />
<span className="text-slate-600">{selectedRow.adresse_mail || "Email non renseigné"}</span>
</div>
<div className="flex items-center gap-2">
<Phone className="size-4 text-slate-400" />
<span className="text-slate-600">{selectedRow.tel || "Téléphone non renseigné"}</span>
</div>
<div className="flex items-center gap-2">
<MapPin className="size-4 text-slate-400" />
<span className="text-slate-600">{selectedRow.adresse || "Adresse non renseignée"}</span>
</div>
</div>
<button
onClick={() => setIsModalOpen(true)}
className="w-full mt-3 px-3 py-2 text-sm bg-indigo-50 hover:bg-indigo-100 text-indigo-700 rounded-lg transition-colors"
>
Modifier les informations
</button>
</div>
{/* Card Note interne */}
<div className="rounded-xl border bg-white p-4 shadow-sm">
<div className="flex items-center gap-2 mb-3">
<StickyNote className="size-5 text-yellow-600" />
<h3 className="font-medium text-slate-800">Note interne</h3>
</div>
{isEditingNote ? (
<div className="space-y-3">
<textarea
value={noteValue}
onChange={(e) => setNoteValue(e.target.value)}
placeholder="Saisissez une note interne (visible uniquement par le staff)"
rows={5}
disabled={savingNote}
className="w-full px-3 py-2 border rounded-lg text-sm focus:ring-2 focus:ring-yellow-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed resize-none"
/>
<div className="flex items-center gap-2">
<button
onClick={handleSaveNote}
disabled={savingNote}
className="flex-1 px-3 py-2 text-sm font-medium text-white bg-yellow-600 hover:bg-yellow-700 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{savingNote ? (
<>
<Loader2 className="size-4 animate-spin" />
Enregistrement...
</>
) : (
'Enregistrer'
)}
</button>
<button
onClick={handleCancelEditNote}
disabled={savingNote}
className="px-3 py-2 text-sm font-medium text-slate-700 hover:bg-slate-100 rounded-lg transition-colors disabled:opacity-50"
>
Annuler
</button>
</div>
</div>
) : (
<>
{selectedRow.notes ? (
<>
<div className="text-sm text-slate-700 mb-3 whitespace-pre-wrap bg-yellow-50 p-3 rounded-lg border border-yellow-200">
{selectedRow.notes}
</div>
<button
onClick={handleStartEditNote}
className="w-full px-3 py-2 text-sm bg-yellow-50 hover:bg-yellow-100 text-yellow-700 rounded-lg transition-colors"
>
Modifier la note
</button>
</>
) : (
<>
<p className="text-sm text-slate-600 mb-3">
Aucune note enregistrée pour ce salarié.
</p>
<button
onClick={handleStartEditNote}
className="w-full px-3 py-2 text-sm bg-yellow-50 hover:bg-yellow-100 text-yellow-700 rounded-lg transition-colors"
>
+ Ajouter une note
</button>
</>
)}
</>
)}
</div>
{/* Card Documents */}
<div className="rounded-xl border bg-white p-4 shadow-sm">
<div className="flex items-center gap-2 mb-3">
<FileText className="size-5 text-blue-600" />
<h3 className="font-medium text-slate-800">Documents</h3>
</div>
{loadingDocuments ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="size-6 text-blue-600 animate-spin" />
</div>
) : documents.length > 0 ? (
<>
<p className="text-sm text-slate-600 mb-3">
{documents.length} document{documents.length > 1 ? 's' : ''} disponible{documents.length > 1 ? 's' : ''}
</p>
<div className="space-y-2 mb-3">
{documents.map((doc) => {
const sizeKB = (doc.size / 1024).toFixed(1);
const date = new Date(doc.lastModified).toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
return (
<button
key={doc.key}
onClick={() => {
setSelectedDocument(doc);
setIsDocumentViewerOpen(true);
}}
className="w-full flex items-center gap-3 p-3 border-2 border-blue-200 bg-blue-50 rounded-lg hover:bg-blue-100 transition-colors cursor-pointer text-left"
>
<FileText className="size-5 text-blue-600 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="font-medium text-blue-800 truncate">
{doc.type}
</p>
<p className="text-xs text-blue-600">
{date} {sizeKB} Ko
</p>
</div>
</button>
);
})}
</div>
<button
onClick={() => setIsUploadModalOpen(true)}
className="w-full px-3 py-2 text-sm bg-blue-50 hover:bg-blue-100 text-blue-700 rounded-lg transition-colors"
>
+ Ajouter un document
</button>
</>
) : (
<>
<p className="text-sm text-slate-600 mb-3">
Aucun document uploadé par ce salarié.
</p>
<div className="text-xs text-slate-500 bg-slate-50 p-2 rounded mb-3">
Les documents uploadés via la page d'auto-déclaration apparaîtront ici (CNI, attestation Sécu, RIB, etc.)
</div>
<button
onClick={() => setIsUploadModalOpen(true)}
className="w-full px-3 py-2 text-sm bg-blue-50 hover:bg-blue-100 text-blue-700 rounded-lg transition-colors"
>
+ Ajouter un document
</button>
</>
)}
</div>
{/* Card Contrats */}
<div className="rounded-xl border bg-white p-4 shadow-sm">
<div className="flex items-center gap-2 mb-3">
<Briefcase className="size-5 text-green-600" />
<h3 className="font-medium text-slate-800">Contrats pour ce salarié</h3>
</div>
{loadingContracts ? (
<div className="flex items-center justify-center py-4">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-green-600"></div>
</div>
) : selectedContracts.length > 0 ? (
<>
<p className="text-sm text-slate-600 mb-3">
{selectedContracts.length} contrat{selectedContracts.length > 1 ? 's' : ''} récent{selectedContracts.length > 1 ? 's' : ''}
</p>
<div className="space-y-2">
{selectedContracts.map((contrat, index) => {
const dateDebut = contrat.start_date ? new Date(contrat.start_date).toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: '2-digit' }) : '';
const dateFin = contrat.end_date ? new Date(contrat.end_date).toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: '2-digit' }) : '';
const periode = dateDebut && dateFin ? `${dateDebut} - ${dateFin}` : 'Dates non définies';
const isActive = contrat.etat_de_la_demande === 'VALIDE' || contrat.etat_de_la_demande === 'EN_COURS';
return (
<button
key={contrat.id}
onClick={() => window.open(`/staff/contrats/${contrat.id}`, '_blank')}
className={`w-full text-left text-xs p-2 rounded border-l-2 hover:opacity-80 transition-opacity cursor-pointer ${
isActive
? 'bg-green-50 border-green-300 hover:bg-green-100'
: 'bg-slate-50 border-slate-300 hover:bg-slate-100'
}`}
>
<div className={`font-medium ${
isActive
? 'text-green-800'
: 'text-slate-700'
}`}>
{contrat.type_de_contrat || 'CDDU'} #{contrat.contract_number || contrat.id.slice(0, 8)}
</div>
<div className={`${
isActive
? 'text-green-600'
: 'text-slate-600'
}`}>
{periode} • {contrat.structure || 'Non spécifié'}
</div>
{contrat.etat_de_la_demande && (
<div className={`text-xs mt-1 ${
isActive
? 'text-green-700'
: 'text-slate-600'
}`}>
État: {contrat.etat_de_la_demande.replace('_', ' ')}
</div>
)}
{contrat.brut && (
<div className={`text-xs ${
isActive
? 'text-green-700'
: 'text-slate-600'
}`}>
Brut: {contrat.brut}€
</div>
)}
</button>
);
})}
<button
onClick={() => window.open(`/staff/contrats?employee_matricule=${encodeURIComponent(selectedRow.code_salarie || "")}`, '_blank')}
className="block w-full px-3 py-2 text-sm bg-green-50 hover:bg-green-100 text-green-700 rounded-lg transition-colors text-center"
>
Voir tous les contrats →
</button>
</div>
</>
) : !selectedRow?.code_salarie ? (
<>
<p className="text-sm text-slate-600 mb-3">
Matricule manquant
</p>
<div className="text-center py-4">
<div className="text-slate-400 text-sm">
Ce salarié n'a pas de matricule renseigné.<br/>
Veuillez modifier ses informations pour rechercher ses contrats.
</div>
<button
onClick={() => setIsModalOpen(true)}
className="mt-2 px-3 py-2 text-sm bg-blue-50 hover:bg-blue-100 text-blue-700 rounded-lg transition-colors"
>
Modifier le salarié
</button>
</div>
</>
) : (
<>
<p className="text-sm text-slate-600 mb-3">
Aucun contrat trouvé
</p>
<div className="text-center py-4">
<div className="text-slate-400 text-sm">
Ce salarié n'a pas encore de contrats enregistrés
</div>
<button
onClick={() => window.open(`/staff/contrats/new?employee_matricule=${selectedRow?.code_salarie}`, '_blank')}
className="mt-2 px-3 py-2 text-sm bg-green-50 hover:bg-green-100 text-green-700 rounded-lg transition-colors"
>
Créer un contrat
</button>
</div>
</>
)}
</div>
</>
) : (
<div className="rounded-xl border bg-slate-50 p-8 text-center">
<div className="text-slate-400 mb-2">
<FileText className="size-8 mx-auto mb-2" />
</div>
<p className="text-sm text-slate-600">
Sélectionnez un salarié dans le tableau pour voir ses informations détaillées
</p>
</div>
)}
{/* Modal d'édition */}
<SalarieModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
salarie={selectedRow}
onUpdate={(updatedSalarie) => {
setRows(prev => prev.map(r => r.id === updatedSalarie.id ? updatedSalarie : r));
}}
/>
{/* Modal d'upload de document */}
{selectedRow && (
<UploadDocumentModal
isOpen={isUploadModalOpen}
onClose={() => setIsUploadModalOpen(false)}
matricule={selectedRow.code_salarie || ''}
salarieName={selectedRow.salarie || [selectedRow.nom, selectedRow.prenom].filter(Boolean).join(" ") || "Salarié"}
onSuccess={() => {
// Recharger les documents après l'upload
if (selectedRow?.id) {
fetchSalarieDocuments(selectedRow.id);
}
}}
/>
)}
{/* Modal de visualisation de document */}
<DocumentViewerModal
isOpen={isDocumentViewerOpen}
onClose={() => {
setIsDocumentViewerOpen(false);
setSelectedDocument(null);
}}
document={selectedDocument}
onDocumentUpdated={() => {
// Recharger les documents après modification/suppression
if (selectedRow?.id) {
fetchSalarieDocuments(selectedRow.id);
}
}}
/>
{/* Modal de relance invitation */}
{selectedRow && (
<ResendInvitationModal
isOpen={isResendInvitationOpen}
onClose={() => setIsResendInvitationOpen(false)}
salarie={selectedRow}
onSuccess={async () => {
// Rafraîchir la liste pour obtenir la nouvelle valeur de last_notif_justifs
await fetchServer(page);
}}
/>
)}
</div>
</div>
);
}