1905 lines
No EOL
82 KiB
TypeScript
1905 lines
No EOL
82 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";
|
|
import { ConfirmationModal } from "@/components/ui/confirmation-modal";
|
|
import { toast } from "sonner";
|
|
import NotifyClientModal from "./salary-transfers/NotifyClientModal";
|
|
|
|
// 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;
|
|
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 [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: "",
|
|
});
|
|
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);
|
|
const [showNotifyClientModal, setShowNotifyClientModal] = 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() {
|
|
if (!createForm.org_id || !createForm.period_month || !createForm.deadline || !createForm.mode || !createForm.num_appel) {
|
|
toast.error("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);
|
|
|
|
toast.success("Virement créé avec succès");
|
|
} 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);
|
|
}
|
|
}
|
|
|
|
// 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 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>
|
|
|
|
{/* 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) => 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);
|
|
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-sm">
|
|
<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 text-sm">{r.organizations?.name || "—"}</div>
|
|
</td>
|
|
|
|
{/* 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>
|
|
|
|
{/* É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">
|
|
<span className={`inline-block px-2 py-1 rounded-full text-xs font-medium ${
|
|
r.notification_sent ? 'bg-green-100 text-green-800' : 'bg-slate-100 text-slate-800'
|
|
}`}>
|
|
{r.notification_sent ? '✓ Envoyée' : 'Non envoyée'}
|
|
</span>
|
|
</td>
|
|
|
|
{/* Paiement client reçu */}
|
|
<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_ok ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
|
|
}`}>
|
|
{r.notification_ok ? '✓ Reçu' : 'Non reçu'}
|
|
</span>
|
|
{r.client_wire_received_at && (
|
|
<span className="text-xs text-slate-500">
|
|
{formatDate(r.client_wire_received_at)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</td>
|
|
|
|
{/* Salaires payés */}
|
|
<td className="px-3 py-2">
|
|
<span className={`inline-block px-2 py-1 rounded-full text-xs font-medium ${
|
|
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-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'é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'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'é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'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'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={`font-medium ${selectedTransfer.salaires_payes ? 'text-green-700' : 'text-slate-900'}`}>
|
|
{selectedTransfer.salaires_payes ? '✓ Payés' : '✗ Non payés'}
|
|
</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'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'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'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]
|
|
: []
|
|
}
|
|
/>
|
|
</div>
|
|
);
|
|
} |