803 lines
No EOL
35 KiB
TypeScript
803 lines
No EOL
35 KiB
TypeScript
"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>
|
||
);
|
||
} |