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

2219 lines
No EOL
95 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, ListChecks } from "lucide-react";
import { ConfirmationModal } from "@/components/ui/confirmation-modal";
import { toast } from "sonner";
import NotifyClientModal from "./salary-transfers/NotifyClientModal";
import NotifyPaymentSentModal from "./salary-transfers/NotifyPaymentSentModal";
import PayslipsSelectionModal from "./salary-transfers/PayslipsSelectionModal";
import { useStaffOrgSelection } from "@/hooks/useStaffOrgSelection";
// 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;
organizations?: { name: 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;
salaires_payes?: boolean | null;
payment_notification_sent?: boolean | null;
payment_notification_sent_at?: string | null;
notes?: string | null;
selection_mode?: 'period' | 'manual' | 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);
// Zustand store pour la sélection d'organisation
const { selectedOrgId, setSelectedOrg } = useStaffOrgSelection();
// Debug log pour vérifier que les organisations sont bien passées
useEffect(() => {
console.log("[SalaryTransfersGrid] Organizations received:", organizations?.length, organizations);
}, [organizations]);
// Synchroniser le filtre local avec le store global quand selectedOrgId change
useEffect(() => {
setOrgFilter(selectedOrgId);
}, [selectedOrgId]);
// filters / sorting / pagination
const [q, setQ] = useState("");
const [orgFilter, setOrgFilter] = useState<string | null>(selectedOrgId); // Initialiser avec la sélection globale
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 [salariesPayesFilter, setSalariesPayesFilter] = 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: "",
selection_mode: "period" as "period" | "manual",
payslip_ids: [] as string[],
custom_amounts: {} as Record<string, number>,
});
const [creating, setCreating] = useState(false);
// Modal de sélection des paies (mode manuel)
const [showPayslipsModal, setShowPayslipsModal] = useState(false);
const [selectedOrgForPayslips, setSelectedOrgForPayslips] = useState<{ id: string; name: string } | null>(null);
// 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);
const [showNotifyClientModal, setShowNotifyClientModal] = useState(false);
const [showNotifyPaymentSentModal, setShowNotifyPaymentSentModal] = useState(false);
const [organizationDetails, setOrganizationDetails] = useState<{
email_notifs?: string | null;
email_notifs_cc?: string | null;
} | null>(null);
// Selection state
const [selectedTransferIds, setSelectedTransferIds] = useState<Set<string>>(new Set());
const [showBulkActionsMenu, setShowBulkActionsMenu] = useState(false);
// Confirmation modals
const [showGeneratePdfConfirm, setShowGeneratePdfConfirm] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [pendingPdfTransferId, setPendingPdfTransferId] = useState<string | null>(null);
// 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" },
async (payload: any) => {
try {
const event = payload.event || payload.eventType || payload.type;
const record = payload.new ?? payload.record ?? payload.payload ?? payload;
if (event === "INSERT") {
// Enrichir avec le nom de l'organisation
let enrichedRecord = { ...record } as SalaryTransfer;
if (record.org_id && organizations.length > 0) {
const org = organizations.find(o => o.id === record.org_id);
if (org) {
enrichedRecord.organizations = { name: org.name };
}
}
setRows((rs) => {
if (rs.find((r) => r.id === enrichedRecord.id)) return rs;
return [enrichedRecord, ...rs];
});
} else if (event === "UPDATE") {
// Enrichir avec le nom de l'organisation si nécessaire
let enrichedRecord = { ...record } as SalaryTransfer;
if (record.org_id && organizations.length > 0) {
const org = organizations.find(o => o.id === record.org_id);
if (org) {
enrichedRecord.organizations = { name: org.name };
}
}
setRows((rs) => rs.map((r) => (r.id === record.id ? { ...r, ...enrichedRecord } : 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);
}
};
}, [organizations]);
// 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 (salariesPayesFilter) params.set('salaires_payes', salariesPayesFilter);
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 && !salariesPayesFilter && !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, salariesPayesFilter, 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() {
// Validation différente selon le mode
if (createForm.selection_mode === 'period') {
if (!createForm.org_id || !createForm.period_month || !createForm.deadline || !createForm.mode || !createForm.num_appel) {
toast.error("Veuillez remplir tous les champs obligatoires");
return;
}
} else {
// Mode manuel
if (!createForm.org_id || !createForm.deadline || !createForm.mode || !createForm.num_appel) {
toast.error("Veuillez remplir tous les champs obligatoires");
return;
}
if (createForm.payslip_ids.length === 0) {
toast.error("Veuillez sélectionner au moins une paie");
return;
}
}
setCreating(true);
try {
// Convert period_month from "YYYY-MM" to "YYYY-MM-01" (first day of month) only for period mode
const periodDate = createForm.selection_mode === 'period' && createForm.period_month.includes('-') && createForm.period_month.length === 7
? `${createForm.period_month}-01`
: createForm.period_month;
const payload = {
...createForm,
period_month: createForm.selection_mode === 'period' ? periodDate : null,
};
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: "",
selection_mode: "period",
payslip_ids: [],
custom_amounts: {},
});
setShowCreateModal(false);
const modeLabel = createForm.selection_mode === 'manual'
? `avec ${result.payslips_count} paie(s)`
: 'avec succès';
toast.success(`Virement créé ${modeLabel}`);
} catch (err: any) {
console.error("Create error:", err);
toast.error(err.message || "Erreur lors de la création");
} finally {
setCreating(false);
}
}
// Function to generate PDF for a transfer
async function handleGeneratePdf(transferId: string) {
setPendingPdfTransferId(transferId);
setShowGeneratePdfConfirm(true);
}
async function confirmGeneratePdf() {
if (!pendingPdfTransferId) return;
const transferId = pendingPdfTransferId;
setShowGeneratePdfConfirm(false);
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
)
);
toast.success(`PDF généré avec succès (${result.contracts_count} contrats)`);
} catch (err: any) {
console.error("Generate PDF error:", err);
toast.error(err.message || "Erreur lors de la génération du PDF");
} finally {
setGeneratingPdfForId(null);
setPendingPdfTransferId(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,
client_wire_received_at: editForm.client_wire_received_at,
notification_ok: editForm.notification_ok,
salaires_payes: editForm.salaires_payes,
};
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);
toast.success("Virement modifié avec succès");
} catch (err: any) {
console.error("Update error:", err);
toast.error(err.message || "Erreur lors de la modification");
} finally {
setUpdating(false);
}
}
// Function to delete a transfer
async function handleDeleteTransfer() {
setShowDeleteConfirm(true);
}
async function confirmDeleteTransfer() {
if (!selectedTransfer || !selectedTransfer.id) return;
setShowDeleteConfirm(false);
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);
toast.success("Virement supprimé avec succès");
} catch (err: any) {
console.error("Delete error:", err);
toast.error(err.message || "Erreur lors de la suppression");
} finally {
setDeleting(false);
}
}
// Function to send client notification
async function handleNotifyClient() {
if (!selectedTransfer || !selectedTransfer.id || !selectedTransfer.org_id) return;
// Charger les détails de l'organisation via l'API (pour éviter les problèmes RLS)
try {
console.log("[handleNotifyClient] Chargement des emails pour org_id:", selectedTransfer.org_id);
const response = await fetch(`/api/staff/organizations/${selectedTransfer.org_id}/emails`, {
method: 'GET',
credentials: 'include',
headers: {
'Accept': 'application/json',
'Cache-Control': 'no-cache',
},
});
if (!response.ok) {
const errorText = await response.text();
console.error("[handleNotifyClient] API Error:", response.status, errorText);
throw new Error(`Erreur ${response.status}: ${errorText}`);
}
const data = await response.json();
console.log("[handleNotifyClient] Emails récupérés:", data);
if (data.email_notifs || data.email_notifs_cc) {
setOrganizationDetails({
email_notifs: data.email_notifs,
email_notifs_cc: data.email_notifs_cc
});
} else {
console.warn("[handleNotifyClient] Aucun email configuré pour org_id:", selectedTransfer.org_id);
setOrganizationDetails(null);
}
// Ouvrir le modal après avoir chargé les données
setShowNotifyClientModal(true);
} catch (err) {
console.error("Error loading organization emails:", err);
setOrganizationDetails(null);
// Ouvrir quand même le modal pour afficher l'erreur
setShowNotifyClientModal(true);
}
}
async function confirmNotifyClient() {
if (!selectedTransfer || !selectedTransfer.id) return;
setShowNotifyClientModal(false);
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
);
toast.success(`Notification envoyée avec succès à ${result.emailSentTo}${result.emailCc ? ` (CC: ${result.emailCc})` : ''}`);
} catch (err: any) {
console.error("Notification error:", err);
toast.error(err.message || "Erreur lors de l'envoi de la notification");
} finally {
setSendingNotification(false);
}
}
// Notification de paiement effectué
async function handleNotifyPaymentSent() {
if (!selectedTransfer || !selectedTransfer.id) return;
console.log("[handleNotifyPaymentSent] Chargement des emails pour org_id:", selectedTransfer.org_id);
try {
const response = await fetch(`/api/staff/organizations/${selectedTransfer.org_id}/emails`, {
method: 'GET',
credentials: 'include',
headers: {
'Accept': 'application/json',
'Cache-Control': 'no-cache',
},
});
if (!response.ok) {
const errorText = await response.text();
console.error("[handleNotifyPaymentSent] API Error:", response.status, errorText);
throw new Error(`Erreur ${response.status}: ${errorText}`);
}
const data = await response.json();
console.log("[handleNotifyPaymentSent] Emails récupérés:", data);
if (data.email_notifs || data.email_notifs_cc) {
setOrganizationDetails({
email_notifs: data.email_notifs,
email_notifs_cc: data.email_notifs_cc
});
} else {
console.warn("[handleNotifyPaymentSent] Aucun email configuré pour org_id:", selectedTransfer.org_id);
setOrganizationDetails(null);
}
// Ouvrir le modal après avoir chargé les données
setShowNotifyPaymentSentModal(true);
} catch (err) {
console.error("Error loading organization emails:", err);
setOrganizationDetails(null);
// Ouvrir quand même le modal pour afficher l'erreur
setShowNotifyPaymentSentModal(true);
}
}
async function confirmNotifyPaymentSent() {
if (!selectedTransfer || !selectedTransfer.id) return;
setShowNotifyPaymentSentModal(false);
setSendingNotification(true);
try {
const res = await fetch(`/api/staff/virements-salaires/${selectedTransfer.id}/notify-payment-sent`, {
method: "POST",
credentials: "include",
});
if (!res.ok) {
const error = await res.json();
console.error("Notification payment sent error response:", error);
throw new Error(error.details || error.error || "Erreur lors de l'envoi de la notification");
}
const result = await res.json();
// Mettre à jour l'état local
const now = new Date().toISOString();
setRows((prev) =>
prev.map((r) =>
r.id === selectedTransfer.id
? { ...r, payment_notification_sent: true, payment_notification_sent_at: now, updated_at: now }
: r
)
);
// Mettre à jour le transfert sélectionné
setSelectedTransfer((prev) =>
prev ? { ...prev, payment_notification_sent: true, payment_notification_sent_at: now } : null
);
toast.success(`Notification de paiement envoyée avec succès à ${result.emailSentTo}${result.emailCc ? ` (CC: ${result.emailCc})` : ''}`);
} catch (err: any) {
console.error("Notification payment sent error:", err);
toast.error(err.message || "Erreur lors de l'envoi de la notification");
} finally {
setSendingNotification(false);
}
}
// Selection functions
const isAllSelected = selectedTransferIds.size > 0 && selectedTransferIds.size === rows.length;
const isSomeSelected = selectedTransferIds.size > 0 && selectedTransferIds.size < rows.length;
const toggleSelectAll = () => {
if (isAllSelected) {
setSelectedTransferIds(new Set());
} else {
setSelectedTransferIds(new Set(rows.map(r => r.id)));
}
};
const toggleSelectTransfer = (transferId: string) => {
setSelectedTransferIds(prev => {
const newSet = new Set(prev);
if (newSet.has(transferId)) {
newSet.delete(transferId);
} else {
newSet.add(transferId);
}
return newSet;
});
};
// Bulk actions
async function handleBulkUpdateNotificationSent(value: boolean) {
if (selectedTransferIds.size === 0) return;
try {
const updates = Array.from(selectedTransferIds).map(id => ({
id,
notification_sent: value
}));
// Update via API
for (const update of updates) {
await fetch(`/api/staff/virements-salaires/${update.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ notification_sent: value })
});
}
// Update local state
setRows(prev => prev.map(r =>
selectedTransferIds.has(r.id) ? { ...r, notification_sent: value } : r
));
toast.success(`${selectedTransferIds.size} virement(s) mis à jour`);
setSelectedTransferIds(new Set());
setShowBulkActionsMenu(false);
} catch (err: any) {
console.error("Bulk update error:", err);
toast.error("Erreur lors de la mise à jour groupée");
}
}
async function handleBulkUpdatePaymentReceived(value: boolean) {
if (selectedTransferIds.size === 0) return;
try {
const updates = Array.from(selectedTransferIds).map(id => ({
id,
notification_ok: value,
client_wire_received_at: value ? new Date().toISOString() : null
}));
// Update via API
for (const update of updates) {
await fetch(`/api/staff/virements-salaires/${update.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
notification_ok: value,
client_wire_received_at: value ? new Date().toISOString() : null
})
});
}
// Update local state
setRows(prev => prev.map(r =>
selectedTransferIds.has(r.id)
? { ...r, notification_ok: value, client_wire_received_at: value ? new Date().toISOString() : r.client_wire_received_at }
: r
));
toast.success(`${selectedTransferIds.size} virement(s) mis à jour`);
setSelectedTransferIds(new Set());
setShowBulkActionsMenu(false);
} catch (err: any) {
console.error("Bulk update error:", err);
toast.error("Erreur lors de la mise à jour groupée");
}
}
async function handleBulkUpdateSalariesPaid(value: boolean) {
if (selectedTransferIds.size === 0) return;
try {
// Update via API
for (const id of Array.from(selectedTransferIds)) {
await fetch(`/api/staff/virements-salaires/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ salaires_payes: value })
});
}
// Update local state
setRows(prev => prev.map(r =>
selectedTransferIds.has(r.id) ? { ...r, salaires_payes: value } : r
));
toast.success(`${selectedTransferIds.size} virement(s) mis à jour`);
setSelectedTransferIds(new Set());
setShowBulkActionsMenu(false);
} catch (err: any) {
console.error("Bulk update error:", err);
toast.error("Erreur lors de la mise à jour groupée");
}
}
return (
<div className="relative">
{/* Header avec boutons 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>
<div className="flex items-center gap-2">
<button
onClick={() => {
setCreateForm({
...createForm,
selection_mode: "period",
payslip_ids: [],
});
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 par période</span>
</button>
<button
onClick={() => {
setCreateForm({
...createForm,
selection_mode: "manual",
period_month: "",
period_label: "",
payslip_ids: [],
});
setShowCreateModal(true);
}}
className="inline-flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
>
<ListChecks className="w-4 h-4" />
<span>Créer personnalisé</span>
</button>
</div>
</div>
{/* Barre d'actions groupées */}
{selectedTransferIds.size > 0 && (
<div className="mb-4 p-4 bg-blue-50 border border-blue-200 rounded-lg flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-sm font-medium text-blue-900">
{selectedTransferIds.size} virement(s) sélectionné(s)
</span>
<button
onClick={() => setSelectedTransferIds(new Set())}
className="text-xs text-blue-600 hover:text-blue-800 underline"
>
Désélectionner tout
</button>
</div>
<div className="relative">
<button
onClick={() => setShowBulkActionsMenu(!showBulkActionsMenu)}
className="inline-flex items-center gap-2 px-4 py-2 bg-white border border-blue-300 rounded-lg hover:bg-blue-50 transition-colors text-sm font-medium text-blue-900"
>
Actions groupées
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{showBulkActionsMenu && (
<div className="absolute right-0 mt-2 w-64 bg-white border border-slate-200 rounded-lg shadow-lg z-10">
<div className="p-2">
<div className="px-3 py-2 text-xs font-semibold text-slate-500 uppercase">
Notification
</div>
<button
onClick={() => handleBulkUpdateNotificationSent(true)}
className="w-full text-left px-3 py-2 text-sm hover:bg-slate-50 rounded"
>
Marquer comme notifiés
</button>
<button
onClick={() => handleBulkUpdateNotificationSent(false)}
className="w-full text-left px-3 py-2 text-sm hover:bg-slate-50 rounded"
>
Marquer comme non notifiés
</button>
<div className="my-2 border-t border-slate-200"></div>
<div className="px-3 py-2 text-xs font-semibold text-slate-500 uppercase">
Paiement client
</div>
<button
onClick={() => handleBulkUpdatePaymentReceived(true)}
className="w-full text-left px-3 py-2 text-sm hover:bg-slate-50 rounded"
>
Marquer comme reçus
</button>
<button
onClick={() => handleBulkUpdatePaymentReceived(false)}
className="w-full text-left px-3 py-2 text-sm hover:bg-slate-50 rounded"
>
Marquer comme non reçus
</button>
<div className="my-2 border-t border-slate-200"></div>
<div className="px-3 py-2 text-xs font-semibold text-slate-500 uppercase">
Salaires
</div>
<button
onClick={() => handleBulkUpdateSalariesPaid(true)}
className="w-full text-left px-3 py-2 text-sm hover:bg-slate-50 rounded"
>
Marquer comme payés
</button>
<button
onClick={() => handleBulkUpdateSalariesPaid(false)}
className="w-full text-left px-3 py-2 text-sm hover:bg-slate-50 rounded"
>
Marquer comme non payés
</button>
</div>
</div>
)}
</div>
</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) => {
const value = e.target.value || null;
setOrgFilter(value);
// Synchroniser avec le store global
if (value) {
const org = organizations?.find(o => o.id === value);
setSelectedOrg(value, org?.name || null);
} else {
setSelectedOrg(null, 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);
setSalariesPayesFilter(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">
Paiement client reçu
</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 - OBSOLETE, gardé pour compatibilité */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Date virement client
</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">Avec date</option>
<option value="false">Sans date</option>
</select>
</div>
{/* Filtre Salaires payés */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Salaires payés
</label>
<select
value={salariesPayesFilter ?? ""}
onChange={(e) => setSalariesPayesFilter(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-xs">
<thead className="bg-slate-50 text-slate-600">
<tr>
<th className="px-3 py-2 w-12">
<input
type="checkbox"
checked={isAllSelected}
ref={(input) => {
if (input) input.indeterminate = isSomeSelected;
}}
onChange={toggleSelectAll}
className="w-4 h-4 text-blue-600 rounded focus:ring-blue-500"
/>
</th>
<th className="text-left px-3 py-2">Organisation</th>
<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 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('notification_ok'); setSortOrder((o) => o === 'asc' ? 'desc' : 'asc'); }}>
Paiement client {sortField === 'notification_ok' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
</th>
<th className="text-left px-3 py-2 cursor-pointer" onClick={() => { setSortField('salaires_payes'); setSortOrder((o) => o === 'asc' ? 'desc' : 'asc'); }}>
Salaires payés {sortField === 'salaires_payes' ? (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)}
>
{/* Checkbox */}
<td className="px-3 py-2" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={selectedTransferIds.has(r.id)}
onChange={() => toggleSelectTransfer(r.id)}
className="w-4 h-4 text-blue-600 rounded focus:ring-blue-500"
/>
</td>
{/* Organisation */}
<td className="px-3 py-2">
<div className="font-medium">{r.organizations?.name || "—"}</div>
</td>
{/* Période */}
<td className="px-3 py-2">
<div className="font-medium">{r.period_label || "—"}</div>
<div className="text-[10px] text-slate-500">{formatDate(r.period_month)}</div>
</td>
{/* Numéro d'appel */}
<td className="px-3 py-2">
<div className="font-mono">{r.num_appel || "—"}</div>
</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-1 px-2 py-0.5 bg-green-50 border border-green-200 rounded">
<CheckCircle2 className="w-3 h-3 text-green-600" />
<span className="font-medium text-green-700 whitespace-nowrap">Dispo</span>
</div>
) : (
<div className="inline-flex items-center gap-1 px-2 py-0.5 bg-slate-50 border border-slate-200 rounded">
<XCircle className="w-3 h-3 text-slate-400" />
<span className="font-medium text-slate-500 whitespace-nowrap">Non gén.</span>
</div>
)}
</td>
{/* Notification */}
<td className="px-3 py-2">
<span className={`inline-block px-2 py-0.5 rounded-full text-[10px] font-medium whitespace-nowrap ${
r.notification_sent ? 'bg-green-100 text-green-800' : 'bg-slate-100 text-slate-800'
}`}>
{r.notification_sent ? '✓ Envoyée' : 'Non env.'}
</span>
</td>
{/* Paiement client reçu */}
<td className="px-3 py-2">
<span className={`inline-block px-2 py-0.5 rounded-full text-[10px] font-medium whitespace-nowrap ${
r.notification_ok ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`} title={r.client_wire_received_at ? formatDate(r.client_wire_received_at) : ''}>
{r.notification_ok ? '✓ Reçu' : 'Non reçu'}
</span>
</td>
{/* Salaires payés */}
<td className="px-3 py-2">
<span className={`inline-block px-2 py-0.5 rounded-full text-[10px] font-medium whitespace-nowrap ${
r.salaires_payes ? 'bg-blue-100 text-blue-800' : 'bg-slate-100 text-slate-600'
}`}>
{r.salaires_payes ? '✓ Payés' : 'Non payés'}
</span>
</td>
{/* Actions */}
<td className="px-3 py-2" onClick={(e) => e.stopPropagation()}>
<button
onClick={() => handleGeneratePdf(r.id)}
disabled={generatingPdfForId === r.id}
className={`px-2 py-0.5 rounded text-[10px] font-medium transition-colors whitespace-nowrap ${
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ér..."
: r.callsheet_url
? "Régénérer"
: "Générer"}
</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">
<div className="flex items-center justify-between mb-4">
<h3 className="text-xl font-semibold">
{createForm.selection_mode === 'period'
? 'Créer un virement par période'
: 'Créer un virement personnalisé'}
</h3>
<div className="px-3 py-1 bg-slate-100 rounded-lg text-sm text-slate-700">
Mode: {createForm.selection_mode === 'period' ? 'Période' : 'Manuel'}
</div>
</div>
<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>
{/* Sélection des paies (mode manuel uniquement) */}
{createForm.selection_mode === 'manual' && (
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Paies à inclure <span className="text-red-500">*</span>
</label>
<button
type="button"
onClick={() => {
if (!createForm.org_id) {
toast.error("Veuillez d'abord sélectionner une organisation");
return;
}
console.log("[SalaryTransfersGrid] Opening payslips modal for org_id:", createForm.org_id);
const org = organizations?.find(o => o.id === createForm.org_id);
console.log("[SalaryTransfersGrid] Found organization:", org);
if (org) {
setSelectedOrgForPayslips({ id: org.id, name: org.name });
setShowPayslipsModal(true);
} else {
toast.error("Organisation introuvable");
}
}}
disabled={!createForm.org_id}
className="w-full px-4 py-3 border-2 border-dashed rounded-lg text-sm text-slate-600 hover:border-indigo-400 hover:text-indigo-600 hover:bg-indigo-50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{createForm.payslip_ids.length > 0
? `${createForm.payslip_ids.length} paie(s) sélectionnée(s) - Cliquer pour modifier`
: 'Cliquer pour sélectionner les paies'}
</button>
{createForm.total_net && createForm.payslip_ids.length > 0 && (
<div className="mt-2 text-sm text-slate-600">
Total net: <span className="font-semibold text-slate-800">{createForm.total_net} </span>
</div>
)}
</div>
)}
{/* Période (mois) - uniquement pour mode période */}
{createForm.selection_mode === 'period' && (
<>
<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>
</>
)}
{/* Libellé manuel pour mode personnalisé */}
{createForm.selection_mode === 'manual' && (
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Libellé du virement <span className="text-red-500">*</span>
</label>
<input
type="text"
value={createForm.period_label}
onChange={(e) => setCreateForm({ ...createForm, period_label: e.target.value })}
placeholder="Ex: Virements sélectionnés - Décembre 2024"
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.deadline ||
!createForm.mode ||
!createForm.num_appel ||
(createForm.selection_mode === 'period' && !createForm.period_month) ||
(createForm.selection_mode === 'manual' && createForm.payslip_ids.length === 0)
}
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 (demande de virement) */}
<button
onClick={handleNotifyClient}
disabled={sendingNotification || !selectedTransfer?.callsheet_url}
className="w-full inline-flex items-center justify-center gap-2 px-4 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-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 (demande virement)"}</span>
</button>
{/* Bouton Notifier paiement effectué */}
{selectedTransfer?.salaires_payes && selectedTransfer?.client_wire_received_at && (
<button
onClick={handleNotifyPaymentSent}
disabled={sendingNotification}
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"
>
<CheckCircle2 className="w-5 h-5" />
<span>{sendingNotification ? "Envoi en cours..." : "Notifier paiement effectué"}</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>
{/* Date de réception du virement client */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Paiement client
</label>
{/* Checkbox pour marquer comme reçu/non reçu */}
<div className="mb-3">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={editForm.notification_ok || false}
onChange={(e) => setEditForm({
...editForm,
notification_ok: e.target.checked,
client_wire_received_at: e.target.checked && !editForm.client_wire_received_at
? new Date().toISOString()
: editForm.client_wire_received_at
})}
className="w-4 h-4 text-green-600 rounded focus:ring-green-500"
/>
<span className="text-sm text-slate-700">Paiement client reçu</span>
</label>
</div>
{/* Date de réception (optionnelle) */}
<div>
<label className="block text-xs text-slate-600 mb-1">
Date de réception (optionnelle)
</label>
<div className="flex gap-2">
<input
type="date"
value={editForm.client_wire_received_at?.substring(0, 10) || ""}
onChange={(e) => setEditForm({
...editForm,
client_wire_received_at: e.target.value ? `${e.target.value}T00:00:00` : null
})}
className="flex-1 px-3 py-2 border rounded-lg text-sm"
/>
{editForm.client_wire_received_at && (
<button
type="button"
onClick={() => setEditForm({ ...editForm, client_wire_received_at: null })}
className="px-3 py-2 text-sm text-red-600 border border-red-300 rounded-lg hover:bg-red-50 transition-colors"
title="Effacer la date"
>
<X className="w-4 h-4" />
</button>
)}
</div>
</div>
</div>
{/* Salaires payés */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Salaires
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={editForm.salaires_payes || false}
onChange={(e) => setEditForm({
...editForm,
salaires_payes: e.target.checked
})}
className="w-4 h-4 text-blue-600 rounded focus:ring-blue-500"
/>
<span className="text-sm text-slate-700">Salaires payés aux salariés</span>
</label>
<p className="mt-1 text-xs text-slate-500">
Cochez cette case lorsque vous avez effectué les virements aux salariés
</p>
</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">Notification client</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-slate-100 text-slate-800'
}`}>
{selectedTransfer.notification_sent ? '✓ Envoyée' : '✗ Non envoyée'}
</span>
</div>
</div>
<div className="bg-slate-50 p-4 rounded-lg">
<div className="flex items-center justify-between mb-2">
<div className="text-xs text-slate-600">Paiement client reçu</div>
{!selectedTransfer.notification_ok && !isEditing && (
<button
onClick={() => {
setEditForm({
...selectedTransfer,
notification_ok: true,
client_wire_received_at: new Date().toISOString()
});
setIsEditing(true);
}}
className="text-xs px-2 py-1 bg-green-600 text-white rounded hover:bg-green-700 transition-colors"
>
Marquer comme reçu
</button>
)}
</div>
<div className="space-y-1">
<div className={`font-medium ${selectedTransfer.notification_ok ? 'text-green-700' : 'text-slate-900'}`}>
{selectedTransfer.notification_ok ? '✓ Reçu' : '✗ Non reçu'}
</div>
{selectedTransfer.client_wire_received_at && (
<div className="text-xs text-slate-600">
Le {formatDate(selectedTransfer.client_wire_received_at)}
</div>
)}
</div>
</div>
<div className="bg-slate-50 p-4 rounded-lg">
<div className="flex items-center justify-between mb-2">
<div className="text-xs text-slate-600">Salaires payés</div>
{!selectedTransfer.salaires_payes && !isEditing && (
<button
onClick={() => {
setEditForm({
...selectedTransfer,
salaires_payes: true
});
setIsEditing(true);
}}
className="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
>
Marquer comme payés
</button>
)}
</div>
<div className="space-y-2">
<div className={`font-medium ${selectedTransfer.salaires_payes ? 'text-green-700' : 'text-slate-900'}`}>
{selectedTransfer.salaires_payes ? '✓ Payés' : '✗ Non payés'}
</div>
{/* Statut de notification de paiement */}
{selectedTransfer.salaires_payes && (
<div className="pt-2 border-t border-slate-200">
<div className="text-xs text-slate-500 mb-1">Client notifié du paiement</div>
<div className={`text-sm font-medium ${selectedTransfer.payment_notification_sent ? 'text-green-700' : 'text-slate-600'}`}>
{selectedTransfer.payment_notification_sent ? '✓ Notifié' : '✗ Non notifié'}
</div>
{selectedTransfer.payment_notification_sent_at && (
<div className="text-xs text-slate-500 mt-1">
Le {formatDate(selectedTransfer.payment_notification_sent_at)}
</div>
)}
</div>
)}
</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>
)}
{/* Confirmation Modals */}
<ConfirmationModal
isOpen={showGeneratePdfConfirm}
title="Générer la feuille d'appel"
description="Voulez-vous générer la feuille d'appel PDF pour ce virement ? Cette action compilera tous les contrats associés à la période."
confirmText="Générer"
cancelText="Annuler"
onConfirm={confirmGeneratePdf}
onCancel={() => {
setShowGeneratePdfConfirm(false);
setPendingPdfTransferId(null);
}}
confirmButtonVariant="gradient"
isLoading={generatingPdfForId !== null}
/>
<ConfirmationModal
isOpen={showDeleteConfirm}
title="Supprimer ce virement"
description="Êtes-vous sûr de vouloir supprimer ce virement ? Cette action est irréversible et supprimera toutes les données associées."
confirmText="Supprimer"
cancelText="Annuler"
onConfirm={confirmDeleteTransfer}
onCancel={() => setShowDeleteConfirm(false)}
confirmButtonVariant="destructive"
isLoading={deleting}
/>
<NotifyClientModal
isOpen={showNotifyClientModal}
onClose={() => {
setShowNotifyClientModal(false);
setOrganizationDetails(null);
}}
onConfirm={confirmNotifyClient}
transfer={selectedTransfer || {
id: "",
num_appel: null,
period_month: null,
period_label: null,
total_net: null,
deadline: null,
}}
organizationName={
selectedTransfer?.org_id
? organizations.find(org => org.id === selectedTransfer.org_id)?.name
: undefined
}
clientEmail={organizationDetails?.email_notifs || undefined}
ccEmails={
organizationDetails?.email_notifs_cc
? [organizationDetails.email_notifs_cc]
: []
}
/>
<NotifyPaymentSentModal
isOpen={showNotifyPaymentSentModal}
onClose={() => {
setShowNotifyPaymentSentModal(false);
setOrganizationDetails(null);
}}
onConfirm={confirmNotifyPaymentSent}
transfer={selectedTransfer || {
id: "",
num_appel: null,
period_month: null,
period_label: null,
total_net: null,
client_wire_received_at: null,
}}
organizationName={
selectedTransfer?.org_id
? organizations.find(org => org.id === selectedTransfer.org_id)?.name
: undefined
}
clientEmail={organizationDetails?.email_notifs || undefined}
ccEmails={
organizationDetails?.email_notifs_cc
? [organizationDetails.email_notifs_cc]
: []
}
/>
{/* Modal de sélection des paies (mode manuel) */}
{showPayslipsModal && selectedOrgForPayslips && (
<PayslipsSelectionModal
organizationId={selectedOrgForPayslips.id}
organizationName={selectedOrgForPayslips.name}
onClose={() => {
setShowPayslipsModal(false);
setSelectedOrgForPayslips(null);
}}
onConfirm={(payslipIds, totalNet, customAmounts) => {
setCreateForm({
...createForm,
payslip_ids: payslipIds,
total_net: totalNet.toFixed(2),
custom_amounts: customAmounts || {},
});
setShowPayslipsModal(false);
setSelectedOrgForPayslips(null);
toast.success(`${payslipIds.length} paie(s) sélectionnée(s)`);
}}
/>
)}
</div>
);
}