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

1401 lines
No EOL
61 KiB
TypeScript

"use client";
import { useEffect, useMemo, useState, useRef } from "react";
import { supabase } from "@/lib/supabaseClient";
import { Plus, Edit, Trash2, FileText, Save, X, CheckCircle2, XCircle } from "lucide-react";
// Utility function to format dates as DD/MM/YYYY
function formatDate(dateString: string | null | undefined): string {
if (!dateString) return "—";
try {
const date = new Date(dateString);
return date.toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
} catch {
return "—";
}
}
// Utility function to format amounts
function formatAmount(amount: string | number | null | undefined): string {
if (!amount) return "—";
try {
const num = typeof amount === 'string' ? parseFloat(amount) : amount;
return new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR'
}).format(num);
} catch {
return String(amount) || "—";
}
}
type SalaryTransfer = {
id: string;
org_id?: string | null;
period_month?: string | null;
period_label?: string | null;
mode?: string | null;
deadline?: string | null;
total_net?: string | null;
num_appel?: string | null;
callsheet_url?: string | null;
notification_sent?: boolean | null;
notification_ok?: boolean | null;
client_wire_received_at?: string | null;
notes?: string | null;
created_at?: string | null;
updated_at?: string | null;
};
type Organization = {
id: string;
name: string;
};
export default function SalaryTransfersGrid({
initialData,
activeOrgId,
organizations = []
}: {
initialData: SalaryTransfer[];
activeOrgId?: string | null;
organizations?: Organization[];
}) {
const [rows, setRows] = useState<SalaryTransfer[]>(initialData || []);
const [showRaw, setShowRaw] = useState(false);
const [loading, setLoading] = useState(false);
// Debug log pour vérifier que les organisations sont bien passées
useEffect(() => {
console.log("[SalaryTransfersGrid] Organizations received:", organizations?.length, organizations);
}, [organizations]);
// filters / sorting / pagination
const [q, setQ] = useState("");
const [orgFilter, setOrgFilter] = useState<string | null>(null);
const [modeFilter, setModeFilter] = useState<string | null>(null);
const [notificationSentFilter, setNotificationSentFilter] = useState<string | null>(null);
const [notificationOkFilter, setNotificationOkFilter] = useState<string | null>(null);
const [hasClientWireFilter, setHasClientWireFilter] = useState<string | null>(null);
const [deadlineFrom, setDeadlineFrom] = useState<string | null>(null);
const [deadlineTo, setDeadlineTo] = useState<string | null>(null);
const [sortField, setSortField] = useState<string>("period_month");
const [sortOrder, setSortOrder] = useState<'asc'|'desc'>('desc');
const [page, setPage] = useState(0);
const [limit, setLimit] = useState(50);
const [showFilters, setShowFilters] = useState(false);
const totalCountRef = useRef<number | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
// Modal de création
const [showCreateModal, setShowCreateModal] = useState(false);
const [createForm, setCreateForm] = useState({
org_id: "",
period_month: "",
period_label: "",
deadline: "",
mode: "SEPA",
num_appel: "",
total_net: "",
notes: "",
});
const [creating, setCreating] = useState(false);
// PDF generation
const [generatingPdfForId, setGeneratingPdfForId] = useState<string | null>(null);
const [pdfError, setPdfError] = useState(false);
// Modal de détails/édition
const [showDetailsModal, setShowDetailsModal] = useState(false);
const [selectedTransfer, setSelectedTransfer] = useState<SalaryTransfer | null>(null);
const [isEditing, setIsEditing] = useState(false);
const [editForm, setEditForm] = useState<SalaryTransfer | null>(null);
const [updating, setUpdating] = useState(false);
const [deleting, setDeleting] = useState(false);
// Notification client
const [sendingNotification, setSendingNotification] = useState(false);
// optimistic update helper
const selectedRow = useMemo(() => rows.find((r) => r.id === selectedId) ?? null, [rows, selectedId]);
// Realtime subscription: listen to INSERT / UPDATE / DELETE on salary_transfers
useEffect(() => {
// Debug: log incoming initialData when component mounts/hydrates
try {
console.log("SalaryTransfersGrid initialData (client):", Array.isArray(initialData) ? initialData.length : typeof initialData, initialData?.slice?.(0, 3));
} catch (err) {
console.log("SalaryTransfersGrid initialData (client) - could not log:", err);
}
let channel: any = null;
let mounted = true;
(async () => {
try {
channel = supabase.channel("public:salary_transfers");
channel.on(
"postgres_changes",
{ event: "*", schema: "public", table: "salary_transfers" },
(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 SalaryTransfer;
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 SalaryTransfer) } : 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.salary_transfers — 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 (orgFilter) params.set('org_id', orgFilter);
if (modeFilter) params.set('mode', modeFilter);
if (notificationSentFilter) params.set('notification_sent', notificationSentFilter);
if (notificationOkFilter) params.set('notification_ok', notificationOkFilter);
if (hasClientWireFilter) params.set('has_client_wire', hasClientWireFilter);
if (deadlineFrom) params.set('deadline_from', deadlineFrom);
if (deadlineTo) params.set('deadline_to', deadlineTo);
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/salary-transfers/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 && !orgFilter && !modeFilter && !notificationSentFilter && !notificationOkFilter && !hasClientWireFilter && !deadlineFrom && !deadlineTo && sortField === 'period_month' && sortOrder === 'desc';
if (noFilters) {
setRows(initialData || []);
return;
}
const t = setTimeout(() => fetchServer(0), 300);
return () => clearTimeout(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [q, orgFilter, modeFilter, notificationSentFilter, notificationOkFilter, hasClientWireFilter, deadlineFrom, deadlineTo, sortField, sortOrder, limit]);
// derive options from initialData for simple selects
const modes = useMemo(() => Array.from(new Set((initialData || []).map((r) => r.mode).filter(Boolean) as string[])).slice(0,50), [initialData]);
// Function to create a new salary transfer
async function handleCreateTransfer() {
if (!createForm.org_id || !createForm.period_month || !createForm.deadline || !createForm.mode || !createForm.num_appel) {
alert("Veuillez remplir tous les champs obligatoires");
return;
}
setCreating(true);
try {
// Convert period_month from "YYYY-MM" to "YYYY-MM-01" (first day of month)
const periodDate = createForm.period_month.includes('-') && createForm.period_month.length === 7
? `${createForm.period_month}-01`
: createForm.period_month;
const payload = {
...createForm,
period_month: periodDate,
};
const res = await fetch("/api/staff/virements-salaires/create", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify(payload),
});
if (!res.ok) {
const error = await res.json();
console.error("Create error response:", error);
throw new Error(error.details || error.error || "Erreur lors de la création");
}
const result = await res.json();
console.log("Create success:", result);
// Add to local state (realtime will also update it)
setRows((prev) => [result.data, ...prev]);
// Reset form and close modal
setCreateForm({
org_id: "",
period_month: "",
period_label: "",
deadline: "",
mode: "SEPA",
num_appel: "",
total_net: "",
notes: "",
});
setShowCreateModal(false);
alert("Virement créé avec succès");
} catch (err: any) {
console.error("Create error:", err);
alert(err.message || "Erreur lors de la création");
} finally {
setCreating(false);
}
}
// Function to generate PDF for a transfer
async function handleGeneratePdf(transferId: string) {
if (!confirm("Générer la feuille d'appel pour ce virement ?")) return;
setGeneratingPdfForId(transferId);
try {
const res = await fetch("/api/staff/virements-salaires/generate-pdf", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify({ salary_transfer_id: transferId }),
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.error || "Erreur lors de la génération");
}
const result = await res.json();
// Update local state with the new URL
setRows((prev) =>
prev.map((r) =>
r.id === transferId
? { ...r, callsheet_url: result.callsheet_url, updated_at: new Date().toISOString() }
: r
)
);
alert(`PDF généré avec succès (${result.contracts_count} contrats)`);
} catch (err: any) {
console.error("Generate PDF error:", err);
alert(err.message || "Erreur lors de la génération du PDF");
} finally {
setGeneratingPdfForId(null);
}
}
// Function to open details modal
function handleOpenDetails(transfer: SalaryTransfer) {
setSelectedTransfer(transfer);
setEditForm(transfer);
setIsEditing(false);
setPdfError(false);
setShowDetailsModal(true);
// Log pour debug
if (transfer.callsheet_url) {
console.log("[SalaryTransfersGrid] Opening PDF:", transfer.callsheet_url);
} else {
console.log("[SalaryTransfersGrid] No PDF URL available");
}
}
// Function to handle PDF error
const handlePdfError = () => {
setPdfError(true);
};
// Function to open PDF in new tab
const openPdfInNewTab = () => {
if (selectedTransfer?.callsheet_url) {
window.open(selectedTransfer.callsheet_url, '_blank');
}
};
// Function to download PDF
const downloadPdf = () => {
if (selectedTransfer?.callsheet_url) {
const link = document.createElement('a');
link.href = selectedTransfer.callsheet_url;
link.download = `appel-virement-${selectedTransfer.num_appel || selectedTransfer.id}.pdf`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
};
// Function to update a transfer
async function handleUpdateTransfer() {
if (!editForm || !editForm.id) return;
setUpdating(true);
try {
// Convert period_month if needed
const periodDate = editForm.period_month && editForm.period_month.length === 7
? `${editForm.period_month}-01`
: editForm.period_month;
const payload = {
period_month: periodDate,
period_label: editForm.period_label,
deadline: editForm.deadline,
mode: editForm.mode,
num_appel: editForm.num_appel,
total_net: editForm.total_net,
notes: editForm.notes,
};
console.log("[handleUpdateTransfer] Payload:", payload);
const res = await fetch(`/api/staff/virements-salaires/${editForm.id}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify(payload),
});
if (!res.ok) {
const error = await res.json();
console.error("Update error response:", error);
throw new Error(error.details || error.error || "Erreur lors de la modification");
}
const result = await res.json();
console.log("Update success:", result);
// Update local state
setRows((prev) =>
prev.map((r) => (r.id === editForm.id ? result.data : r))
);
setSelectedTransfer(result.data);
setEditForm(result.data);
setIsEditing(false);
alert("Virement modifié avec succès");
} catch (err: any) {
console.error("Update error:", err);
alert(err.message || "Erreur lors de la modification");
} finally {
setUpdating(false);
}
}
// Function to delete a transfer
async function handleDeleteTransfer() {
if (!selectedTransfer || !selectedTransfer.id) return;
if (!confirm("Êtes-vous sûr de vouloir supprimer ce virement ? Cette action est irréversible.")) {
return;
}
setDeleting(true);
try {
const res = await fetch(`/api/staff/virements-salaires/${selectedTransfer.id}`, {
method: "DELETE",
credentials: "include",
});
if (!res.ok) {
const error = await res.json();
console.error("Delete error response:", error);
throw new Error(error.details || error.error || "Erreur lors de la suppression");
}
// Remove from local state
setRows((prev) => prev.filter((r) => r.id !== selectedTransfer.id));
setShowDetailsModal(false);
setSelectedTransfer(null);
setEditForm(null);
alert("Virement supprimé avec succès");
} catch (err: any) {
console.error("Delete error:", err);
alert(err.message || "Erreur lors de la suppression");
} finally {
setDeleting(false);
}
}
// Function to send client notification
async function handleNotifyClient() {
if (!selectedTransfer || !selectedTransfer.id) return;
if (!confirm("Envoyer la notification au client pour ce virement ?")) {
return;
}
setSendingNotification(true);
try {
const res = await fetch(`/api/staff/virements-salaires/${selectedTransfer.id}/notify-client`, {
method: "POST",
credentials: "include",
});
if (!res.ok) {
const error = await res.json();
console.error("Notification error response:", error);
throw new Error(error.details || error.error || "Erreur lors de l'envoi de la notification");
}
const result = await res.json();
// Update local state
setRows((prev) =>
prev.map((r) =>
r.id === selectedTransfer.id
? { ...r, notification_sent: true, notification_ok: true, updated_at: new Date().toISOString() }
: r
)
);
// Update selected transfer
setSelectedTransfer((prev) =>
prev ? { ...prev, notification_sent: true, notification_ok: true } : null
);
alert(`Notification envoyée avec succès à ${result.emailSentTo}${result.emailCc ? ` (CC: ${result.emailCc})` : ''}`);
} catch (err: any) {
console.error("Notification error:", err);
alert(err.message || "Erreur lors de l'envoi de la notification");
} finally {
setSendingNotification(false);
}
}
return (
<div className="relative">
{/* Header avec bouton de création */}
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-slate-800">Virements de salaires</h2>
<button
onClick={() => setShowCreateModal(true)}
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Plus className="w-4 h-4" />
<span>Créer un virement</span>
</button>
</div>
{/* Filters */}
<div className="mb-3">
{/* Ligne du haut: recherche + bouton filtres */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-3">
<div className="flex-1">
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Recherche (période, feuille d'appel, notes)"
className="w-full rounded border px-2 py-1 text-sm"
/>
</div>
<div className="flex items-center gap-2">
{/* Filtres rapides toujours visibles */}
<div className="flex items-center gap-1">
<label className="text-xs font-medium text-slate-700">Organisation:</label>
<select
value={orgFilter ?? ""}
onChange={(e) => setOrgFilter(e.target.value || null)}
className="rounded border px-3 py-2 text-sm bg-white min-w-[200px]"
disabled={!organizations || organizations.length === 0}
>
<option value="">
{!organizations || organizations.length === 0
? "Chargement..."
: "Toutes les organisations"}
</option>
{organizations && organizations.map((org) => (
<option key={org.id} value={org.id}>{org.name}</option>
))}
</select>
{organizations && organizations.length > 0 && (
<span className="text-xs text-slate-500">({organizations.length})</span>
)}
</div>
<select value={modeFilter ?? ""} onChange={(e) => setModeFilter(e.target.value || null)} className="rounded border px-2 py-1 text-sm">
<option value="">Tous modes</option>
{modes.map((m) => (<option key={m} value={m}>{m}</option>))}
</select>
<button
onClick={() => setShowFilters(!showFilters)}
className="inline-flex items-center gap-1 text-sm text-blue-600 hover:text-blue-800 px-2 py-1 rounded border"
>
{showFilters ? "Masquer" : "Plus de"} filtres
</button>
<button
className="rounded border px-3 py-1 text-sm"
onClick={() => {
setQ('');
setOrgFilter(null);
setModeFilter(null);
setNotificationSentFilter(null);
setNotificationOkFilter(null);
setHasClientWireFilter(null);
setDeadlineFrom(null);
setDeadlineTo(null);
setSortField('period_month');
setSortOrder('desc');
setRows(initialData || []);
}}
>
Réinitialiser
</button>
</div>
</div>
{/* Filtres avancés (masquables) */}
{showFilters && (
<div className="border rounded-lg p-4 bg-slate-50">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{/* Filtre Notification envoyée */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Notification envoyée
</label>
<select
value={notificationSentFilter ?? ""}
onChange={(e) => setNotificationSentFilter(e.target.value || null)}
className="w-full px-3 py-2 border rounded-lg bg-white text-sm"
>
<option value="">Tous</option>
<option value="true">Oui</option>
<option value="false">Non</option>
</select>
</div>
{/* Filtre Notification OK */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Notification OK
</label>
<select
value={notificationOkFilter ?? ""}
onChange={(e) => setNotificationOkFilter(e.target.value || null)}
className="w-full px-3 py-2 border rounded-lg bg-white text-sm"
>
<option value="">Tous</option>
<option value="true">Oui</option>
<option value="false">Non</option>
</select>
</div>
{/* Filtre Virement client reçu */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Virement client reçu
</label>
<select
value={hasClientWireFilter ?? ""}
onChange={(e) => setHasClientWireFilter(e.target.value || null)}
className="w-full px-3 py-2 border rounded-lg bg-white text-sm"
>
<option value="">Tous</option>
<option value="true">Oui</option>
<option value="false">Non</option>
</select>
</div>
{/* Filtre date échéance - De */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Échéance - De
</label>
<input
type="date"
value={deadlineFrom ?? ""}
onChange={(e) => setDeadlineFrom(e.target.value || null)}
className="w-full px-3 py-2 border rounded-lg bg-white text-sm"
/>
</div>
{/* Filtre date échéance - À */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Échéance - À
</label>
<input
type="date"
value={deadlineTo ?? ""}
onChange={(e) => setDeadlineTo(e.target.value || null)}
className="w-full px-3 py-2 border rounded-lg bg-white text-sm"
/>
</div>
</div>
</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 cursor-pointer" onClick={() => { setSortField('period_month'); setSortOrder((o) => o === 'asc' ? 'desc' : 'asc'); }}>
Période {sortField === 'period_month' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
</th>
<th className="text-left px-3 py-2">N° Appel</th>
<th className="text-left px-3 py-2">Mode</th>
<th className="text-left px-3 py-2 cursor-pointer" onClick={() => { setSortField('deadline'); setSortOrder((o) => o === 'asc' ? 'desc' : 'asc'); }}>
Échéance {sortField === 'deadline' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
</th>
<th className="text-left px-3 py-2 cursor-pointer" onClick={() => { setSortField('total_net'); setSortOrder((o) => o === 'asc' ? 'desc' : 'asc'); }}>
Total Net {sortField === 'total_net' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
</th>
<th className="text-left px-3 py-2">Statut PDF</th>
<th className="text-left px-3 py-2">Notification</th>
<th className="text-left px-3 py-2 cursor-pointer" onClick={() => { setSortField('client_wire_received_at'); setSortOrder((o) => o === 'asc' ? 'desc' : 'asc'); }}>
Virement reçu {sortField === 'client_wire_received_at' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
</th>
<th className="text-left px-3 py-2">Notes</th>
<th className="text-left px-3 py-2 cursor-pointer" onClick={() => { setSortField('created_at'); setSortOrder((o) => o === 'asc' ? 'desc' : 'asc'); }}>
Créé le {sortField === 'created_at' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
</th>
<th className="text-left px-3 py-2">Actions</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr
key={r.id}
className="border-t hover:bg-slate-50 cursor-pointer transition-colors"
onClick={() => handleOpenDetails(r)}
>
{/* Période */}
<td className="px-3 py-2">
<div className="font-medium">{r.period_label || "—"}</div>
<div className="text-xs text-slate-500">{formatDate(r.period_month)}</div>
</td>
{/* Numéro d'appel */}
<td className="px-3 py-2">
<div className="font-mono text-sm">{r.num_appel || "—"}</div>
</td>
{/* Mode */}
<td className="px-3 py-2">
<span className={`inline-block px-2 py-1 rounded-full text-xs font-medium ${
r.mode === 'odentas_reverse' ? 'bg-blue-100 text-blue-800' : 'bg-slate-100 text-slate-700'
}`}>
{r.mode || "—"}
</span>
</td>
{/* Échéance */}
<td className="px-3 py-2">{formatDate(r.deadline)}</td>
{/* Total Net */}
<td className="px-3 py-2 font-medium">{formatAmount(r.total_net)}</td>
{/* Feuille d'appel */}
<td className="px-3 py-2">
{r.callsheet_url ? (
<div className="inline-flex items-center gap-2 px-3 py-1.5 bg-green-50 border border-green-200 rounded-lg">
<CheckCircle2 className="w-4 h-4 text-green-600" />
<span className="text-sm font-medium text-green-700">Disponible</span>
</div>
) : (
<div className="inline-flex items-center gap-2 px-3 py-1.5 bg-slate-50 border border-slate-200 rounded-lg">
<XCircle className="w-4 h-4 text-slate-400" />
<span className="text-sm font-medium text-slate-500">Non générée</span>
</div>
)}
</td>
{/* Notification */}
<td className="px-3 py-2">
<div className="flex flex-col gap-1">
<span className={`inline-block px-2 py-1 rounded-full text-xs font-medium ${
r.notification_sent ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}>
{r.notification_sent ? 'Envoyée' : 'Non envoyée'}
</span>
{r.notification_sent && (
<span className={`inline-block px-2 py-1 rounded-full text-xs font-medium ${
r.notification_ok ? 'bg-green-100 text-green-800' : 'bg-orange-100 text-orange-800'
}`}>
{r.notification_ok ? 'OK' : 'Erreur'}
</span>
)}
</div>
</td>
{/* Virement client reçu */}
<td className="px-3 py-2">
{r.client_wire_received_at ? (
<span className="inline-block px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">
{formatDate(r.client_wire_received_at)}
</span>
) : (
<span className="inline-block px-2 py-1 rounded-full text-xs font-medium bg-red-100 text-red-800">
Non reçu
</span>
)}
</td>
{/* Notes */}
<td className="px-3 py-2 max-w-xs">
{r.notes ? (
<div className="truncate" title={r.notes}>
{r.notes}
</div>
) : "—"}
</td>
{/* Créé le */}
<td className="px-3 py-2">{formatDate(r.created_at)}</td>
{/* Actions */}
<td className="px-3 py-2" onClick={(e) => e.stopPropagation()}>
<button
onClick={() => handleGeneratePdf(r.id)}
disabled={generatingPdfForId === r.id}
className={`px-3 py-1 rounded text-xs font-medium transition-colors ${
generatingPdfForId === r.id
? "bg-slate-300 text-slate-500 cursor-not-allowed"
: r.callsheet_url
? "bg-green-100 text-green-800 hover:bg-green-200"
: "bg-blue-600 text-white hover:bg-blue-700"
}`}
>
{generatingPdfForId === r.id
? "Génération..."
: r.callsheet_url
? "Regénérer PDF"
: "Générer PDF"}
</button>
</td>
</tr>
))}
</tbody>
</table>
{rows.length === 0 && (
<div className="p-4 text-sm text-slate-600">
<div>Aucun virement de salaire 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>
{/* Modal de création */}
{showCreateModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6">
<h3 className="text-xl font-semibold mb-4">Créer un nouveau virement de salaire</h3>
<div className="space-y-4">
{/* Organisation */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Organisation <span className="text-red-500">*</span>
</label>
<select
value={createForm.org_id}
onChange={(e) => setCreateForm({ ...createForm, org_id: e.target.value })}
className="w-full px-3 py-2 border rounded-lg bg-white"
disabled={!organizations || organizations.length === 0}
>
<option value="">Sélectionner une organisation</option>
{organizations && organizations.map((org) => (
<option key={org.id} value={org.id}>{org.name}</option>
))}
</select>
</div>
{/* Période (mois) */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Période (mois) <span className="text-red-500">*</span>
</label>
<input
type="month"
value={createForm.period_month}
onChange={(e) => setCreateForm({
...createForm,
period_month: e.target.value,
// Auto-générer le label si vide
period_label: !createForm.period_label ? new Date(e.target.value + "-01").toLocaleDateString("fr-FR", { month: "long", year: "numeric" }) : createForm.period_label
})}
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
{/* Libellé de la période */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Libellé de la période (optionnel)
</label>
<input
type="text"
value={createForm.period_label}
onChange={(e) => setCreateForm({ ...createForm, period_label: e.target.value })}
placeholder="Ex: Janvier 2025"
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
{/* Date d'échéance */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Date d&apos;échéance <span className="text-red-500">*</span>
</label>
<input
type="date"
value={createForm.deadline}
onChange={(e) => setCreateForm({ ...createForm, deadline: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
{/* Mode */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Mode <span className="text-red-500">*</span>
</label>
<select
value={createForm.mode}
onChange={(e) => setCreateForm({ ...createForm, mode: e.target.value })}
className="w-full px-3 py-2 border rounded-lg bg-white"
>
<option value="SEPA">SEPA</option>
<option value="VIREMENT">VIREMENT</option>
<option value="odentas_reverse">Odentas Reverse</option>
</select>
</div>
{/* Numéro d'appel */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Numéro d&apos;appel <span className="text-red-500">*</span>
</label>
<input
type="text"
value={createForm.num_appel}
onChange={(e) => setCreateForm({ ...createForm, num_appel: e.target.value })}
placeholder="Ex: 00001"
className="w-full px-3 py-2 border rounded-lg"
/>
<p className="mt-1 text-xs text-slate-500">
Ce numéro sera utilisé pour générer la référence du virement (code_employeur-numéro)
</p>
</div>
{/* Total Net */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Total Net (optionnel, sera calculé lors de la génération PDF)
</label>
<input
type="number"
step="0.01"
value={createForm.total_net}
onChange={(e) => setCreateForm({ ...createForm, total_net: e.target.value })}
placeholder="0.00"
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
{/* Notes */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Notes (optionnel)
</label>
<textarea
value={createForm.notes}
onChange={(e) => setCreateForm({ ...createForm, notes: e.target.value })}
rows={3}
className="w-full px-3 py-2 border rounded-lg"
placeholder="Notes internes..."
/>
</div>
</div>
<div className="mt-6 flex items-center justify-end gap-3">
<button
onClick={() => {
setShowCreateModal(false);
setCreateForm({
org_id: "",
period_month: "",
period_label: "",
deadline: "",
mode: "SEPA",
num_appel: "",
total_net: "",
notes: "",
});
}}
className="px-4 py-2 text-slate-700 border rounded-lg hover:bg-slate-50 transition-colors"
disabled={creating}
>
Annuler
</button>
<button
onClick={handleCreateTransfer}
disabled={creating || !createForm.org_id || !createForm.period_month || !createForm.deadline || !createForm.mode || !createForm.num_appel}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:bg-slate-300 disabled:cursor-not-allowed"
>
{creating ? "Création..." : "Créer"}
</button>
</div>
</div>
</div>
</div>
)}
{/* Modal de détails/édition */}
{showDetailsModal && selectedTransfer && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4" onClick={() => {
setShowDetailsModal(false);
setSelectedTransfer(null);
setEditForm(null);
setIsEditing(false);
setPdfError(false);
}}>
<div className="bg-white rounded-2xl shadow-2xl max-w-6xl w-full max-h-[90vh] flex flex-col" onClick={(e) => e.stopPropagation()}>
{/* Header */}
<div className="px-6 py-4 border-b border-slate-200 flex items-center justify-between bg-gradient-to-r from-blue-50 to-slate-50">
<div>
<h3 className="text-2xl font-bold text-slate-800">
{selectedTransfer.period_label || "Virement de salaire"}
</h3>
<p className="text-sm text-slate-600 mt-1">
ID: {selectedTransfer.id.substring(0, 8)}...
</p>
</div>
<button
onClick={() => {
setShowDetailsModal(false);
setSelectedTransfer(null);
setEditForm(null);
setIsEditing(false);
}}
className="text-slate-400 hover:text-slate-600 transition-colors"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="flex-1 overflow-y-auto">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 p-6">
{/* Colonne gauche : Informations */}
<div className="space-y-6">
{/* Actions en haut */}
{!isEditing && (
<div className="space-y-2">
<div className="flex gap-2">
<button
onClick={() => setIsEditing(true)}
className="flex-1 inline-flex items-center justify-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium"
>
<Edit className="w-4 h-4" />
<span>Modifier</span>
</button>
<button
onClick={handleDeleteTransfer}
disabled={deleting}
className="flex-1 inline-flex items-center justify-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors font-medium disabled:bg-slate-300"
>
<Trash2 className="w-4 h-4" />
<span>{deleting ? "Suppression..." : "Supprimer"}</span>
</button>
</div>
{/* Bouton Notifier le client */}
<button
onClick={handleNotifyClient}
disabled={sendingNotification || !selectedTransfer?.callsheet_url}
className="w-full inline-flex items-center justify-center gap-2 px-4 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors font-medium disabled:bg-slate-300 disabled:cursor-not-allowed"
title={!selectedTransfer?.callsheet_url ? "Veuillez d'abord générer la feuille d'appel" : ""}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
<span>{sendingNotification ? "Envoi en cours..." : "Notifier le client"}</span>
</button>
</div>
)}
{/* Formulaire d'édition ou affichage */}
{isEditing && editForm ? (
<div className="space-y-4">
<h4 className="font-semibold text-lg text-slate-800">Modifier le virement</h4>
{/* Période */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Période (mois)
</label>
<input
type="month"
value={editForm.period_month?.substring(0, 7) || ""}
onChange={(e) => setEditForm({ ...editForm, period_month: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
{/* Libellé */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Libellé de la période
</label>
<input
type="text"
value={editForm.period_label || ""}
onChange={(e) => setEditForm({ ...editForm, period_label: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
{/* Échéance */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Date d&apos;échéance
</label>
<input
type="date"
value={editForm.deadline || ""}
onChange={(e) => setEditForm({ ...editForm, deadline: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
{/* Mode */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Mode
</label>
<select
value={editForm.mode || ""}
onChange={(e) => setEditForm({ ...editForm, mode: e.target.value })}
className="w-full px-3 py-2 border rounded-lg bg-white"
>
<option value="SEPA">SEPA</option>
<option value="VIREMENT">VIREMENT</option>
<option value="odentas_reverse">Odentas Reverse</option>
</select>
</div>
{/* Numéro d'appel */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Numéro d&apos;appel
</label>
<input
type="text"
value={editForm.num_appel || ""}
onChange={(e) => setEditForm({ ...editForm, num_appel: e.target.value })}
placeholder="Ex: 00001"
className="w-full px-3 py-2 border rounded-lg"
/>
<p className="mt-1 text-xs text-slate-500">
Ce numéro sera utilisé pour générer la référence du virement
</p>
</div>
{/* Total Net */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Total Net
</label>
<input
type="number"
step="0.01"
value={editForm.total_net || ""}
onChange={(e) => setEditForm({ ...editForm, total_net: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
{/* Notes */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Notes
</label>
<textarea
value={editForm.notes || ""}
onChange={(e) => setEditForm({ ...editForm, notes: e.target.value })}
rows={4}
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
{/* Boutons d'action */}
<div className="flex gap-2 pt-4">
<button
onClick={() => {
setIsEditing(false);
setEditForm(selectedTransfer);
}}
className="flex-1 inline-flex items-center justify-center gap-2 px-4 py-2 border rounded-lg hover:bg-slate-50 transition-colors"
disabled={updating}
>
<X className="w-4 h-4" />
<span>Annuler</span>
</button>
<button
onClick={handleUpdateTransfer}
disabled={updating}
className="flex-1 inline-flex items-center justify-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:bg-slate-300"
>
<Save className="w-4 h-4" />
<span>{updating ? "Enregistrement..." : "Enregistrer"}</span>
</button>
</div>
</div>
) : (
<div className="space-y-4">
<h4 className="font-semibold text-lg text-slate-800">Informations</h4>
<div className="grid grid-cols-2 gap-4">
<div className="bg-slate-50 p-4 rounded-lg">
<div className="text-xs text-slate-600 mb-1">Période</div>
<div className="font-medium text-slate-900">{selectedTransfer.period_label || "—"}</div>
<div className="text-xs text-slate-500 mt-1">{formatDate(selectedTransfer.period_month)}</div>
</div>
<div className="bg-slate-50 p-4 rounded-lg">
<div className="text-xs text-slate-600 mb-1">N° d&apos;appel</div>
<div className="font-mono font-medium text-slate-900">{selectedTransfer.num_appel || "—"}</div>
</div>
<div className="bg-slate-50 p-4 rounded-lg">
<div className="text-xs text-slate-600 mb-1">Mode</div>
<div className="font-medium text-slate-900">{selectedTransfer.mode || "—"}</div>
</div>
<div className="bg-slate-50 p-4 rounded-lg">
<div className="text-xs text-slate-600 mb-1">Échéance</div>
<div className="font-medium text-slate-900">{formatDate(selectedTransfer.deadline)}</div>
</div>
<div className="bg-slate-50 p-4 rounded-lg col-span-2">
<div className="text-xs text-slate-600 mb-1">Total Net</div>
<div className="font-medium text-slate-900 text-lg">{formatAmount(selectedTransfer.total_net)}</div>
</div>
</div>
<div className="bg-slate-50 p-4 rounded-lg">
<div className="text-xs text-slate-600 mb-1">Notifications</div>
<div className="flex gap-2 mt-2">
<span className={`inline-block px-3 py-1 rounded-full text-xs font-medium ${
selectedTransfer.notification_sent ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}>
{selectedTransfer.notification_sent ? '✓ Envoyée' : '✗ Non envoyée'}
</span>
{selectedTransfer.notification_sent && (
<span className={`inline-block px-3 py-1 rounded-full text-xs font-medium ${
selectedTransfer.notification_ok ? 'bg-green-100 text-green-800' : 'bg-orange-100 text-orange-800'
}`}>
{selectedTransfer.notification_ok ? '✓ OK' : '⚠ Erreur'}
</span>
)}
</div>
</div>
<div className="bg-slate-50 p-4 rounded-lg">
<div className="text-xs text-slate-600 mb-1">Virement client reçu</div>
<div className="font-medium text-slate-900">
{selectedTransfer.client_wire_received_at
? formatDate(selectedTransfer.client_wire_received_at)
: "Non reçu"}
</div>
</div>
{selectedTransfer.notes && (
<div className="bg-slate-50 p-4 rounded-lg">
<div className="text-xs text-slate-600 mb-1">Notes</div>
<div className="text-sm text-slate-700 whitespace-pre-wrap">{selectedTransfer.notes}</div>
</div>
)}
<div className="bg-slate-50 p-4 rounded-lg">
<div className="text-xs text-slate-600 mb-1">Dates</div>
<div className="text-xs text-slate-700 space-y-1">
<div>Créé le : {formatDate(selectedTransfer.created_at)}</div>
<div>Modifié le : {formatDate(selectedTransfer.updated_at)}</div>
</div>
</div>
</div>
)}
</div>
{/* Colonne droite : PDF */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<h4 className="font-semibold text-lg text-slate-800">Feuille d&apos;appel</h4>
{selectedTransfer.callsheet_url && !pdfError && (
<div className="flex gap-2">
<button
onClick={openPdfInNewTab}
className="inline-flex items-center gap-1 px-3 py-1 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
title="Ouvrir dans un nouvel onglet"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
Ouvrir
</button>
<button
onClick={downloadPdf}
className="inline-flex items-center gap-1 px-3 py-1 text-sm bg-slate-600 text-white rounded-lg hover:bg-slate-700 transition-colors"
title="Télécharger"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Télécharger
</button>
</div>
)}
</div>
{pdfError ? (
<div className="bg-slate-100 rounded-lg p-8 text-center" style={{ height: '600px' }}>
<div className="flex flex-col items-center justify-center h-full">
<svg className="w-24 h-24 text-red-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<p className="text-red-600 font-medium mb-2 text-lg">Erreur de chargement</p>
<p className="text-sm text-slate-600 mb-4">Le PDF n&apos;a pas pu être affiché dans le navigateur</p>
<div className="flex gap-2">
<button
onClick={openPdfInNewTab}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Ouvrir dans un nouvel onglet
</button>
<button
onClick={downloadPdf}
className="px-4 py-2 bg-slate-600 text-white rounded-lg hover:bg-slate-700 transition-colors"
>
Télécharger le PDF
</button>
</div>
</div>
</div>
) : selectedTransfer.callsheet_url ? (
<div className="bg-slate-100 rounded-lg p-4" style={{ height: '600px' }}>
<iframe
src={`https://docs.google.com/viewer?url=${encodeURIComponent(selectedTransfer.callsheet_url)}&embedded=true`}
className="w-full h-full rounded-lg bg-white shadow"
title="Feuille d'appel PDF"
allow="fullscreen"
/>
</div>
) : (
<div className="bg-slate-100 rounded-lg p-8 text-center" style={{ height: '600px' }}>
<div className="flex flex-col items-center justify-center h-full">
<svg className="w-24 h-24 text-slate-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
<p className="text-slate-600 font-medium mb-2">Aucun PDF généré</p>
<p className="text-sm text-slate-500 mb-4">Générez la feuille d&apos;appel pour visualiser le PDF</p>
<button
onClick={(e) => {
e.stopPropagation();
setShowDetailsModal(false);
handleGeneratePdf(selectedTransfer.id);
}}
disabled={generatingPdfForId === selectedTransfer.id}
className="inline-flex items-center gap-2 px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:bg-slate-300"
>
<FileText className="w-5 h-5" />
<span>{generatingPdfForId === selectedTransfer.id ? "Génération..." : "Générer le PDF"}</span>
</button>
</div>
</div>
)}
</div>
</div>
</div>
</div>
</div>
)}
</div>
);
}