1401 lines
No EOL
61 KiB
TypeScript
1401 lines
No EOL
61 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useMemo, useState, useRef } from "react";
|
|
import { supabase } from "@/lib/supabaseClient";
|
|
import { Plus, Edit, Trash2, FileText, Save, X, CheckCircle2, XCircle } from "lucide-react";
|
|
|
|
// Utility function to format dates as DD/MM/YYYY
|
|
function formatDate(dateString: string | null | undefined): string {
|
|
if (!dateString) return "—";
|
|
try {
|
|
const date = new Date(dateString);
|
|
return date.toLocaleDateString('fr-FR', {
|
|
day: '2-digit',
|
|
month: '2-digit',
|
|
year: 'numeric'
|
|
});
|
|
} catch {
|
|
return "—";
|
|
}
|
|
}
|
|
|
|
// Utility function to format amounts
|
|
function formatAmount(amount: string | number | null | undefined): string {
|
|
if (!amount) return "—";
|
|
try {
|
|
const num = typeof amount === 'string' ? parseFloat(amount) : amount;
|
|
return new Intl.NumberFormat('fr-FR', {
|
|
style: 'currency',
|
|
currency: 'EUR'
|
|
}).format(num);
|
|
} catch {
|
|
return String(amount) || "—";
|
|
}
|
|
}
|
|
|
|
type SalaryTransfer = {
|
|
id: string;
|
|
org_id?: string | null;
|
|
period_month?: string | null;
|
|
period_label?: string | null;
|
|
mode?: string | null;
|
|
deadline?: string | null;
|
|
total_net?: string | null;
|
|
num_appel?: string | null;
|
|
callsheet_url?: string | null;
|
|
notification_sent?: boolean | null;
|
|
notification_ok?: boolean | null;
|
|
client_wire_received_at?: string | null;
|
|
notes?: string | null;
|
|
created_at?: string | null;
|
|
updated_at?: string | null;
|
|
};
|
|
|
|
type Organization = {
|
|
id: string;
|
|
name: string;
|
|
};
|
|
|
|
export default function SalaryTransfersGrid({
|
|
initialData,
|
|
activeOrgId,
|
|
organizations = []
|
|
}: {
|
|
initialData: SalaryTransfer[];
|
|
activeOrgId?: string | null;
|
|
organizations?: Organization[];
|
|
}) {
|
|
const [rows, setRows] = useState<SalaryTransfer[]>(initialData || []);
|
|
const [showRaw, setShowRaw] = useState(false);
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
// Debug log pour vérifier que les organisations sont bien passées
|
|
useEffect(() => {
|
|
console.log("[SalaryTransfersGrid] Organizations received:", organizations?.length, organizations);
|
|
}, [organizations]);
|
|
|
|
// filters / sorting / pagination
|
|
const [q, setQ] = useState("");
|
|
const [orgFilter, setOrgFilter] = useState<string | null>(null);
|
|
const [modeFilter, setModeFilter] = useState<string | null>(null);
|
|
const [notificationSentFilter, setNotificationSentFilter] = useState<string | null>(null);
|
|
const [notificationOkFilter, setNotificationOkFilter] = useState<string | null>(null);
|
|
const [hasClientWireFilter, setHasClientWireFilter] = useState<string | null>(null);
|
|
const [deadlineFrom, setDeadlineFrom] = useState<string | null>(null);
|
|
const [deadlineTo, setDeadlineTo] = useState<string | null>(null);
|
|
const [sortField, setSortField] = useState<string>("period_month");
|
|
const [sortOrder, setSortOrder] = useState<'asc'|'desc'>('desc');
|
|
const [page, setPage] = useState(0);
|
|
const [limit, setLimit] = useState(50);
|
|
const [showFilters, setShowFilters] = useState(false);
|
|
const totalCountRef = useRef<number | null>(null);
|
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
|
|
// Modal de création
|
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
|
const [createForm, setCreateForm] = useState({
|
|
org_id: "",
|
|
period_month: "",
|
|
period_label: "",
|
|
deadline: "",
|
|
mode: "SEPA",
|
|
num_appel: "",
|
|
total_net: "",
|
|
notes: "",
|
|
});
|
|
const [creating, setCreating] = useState(false);
|
|
|
|
// PDF generation
|
|
const [generatingPdfForId, setGeneratingPdfForId] = useState<string | null>(null);
|
|
const [pdfError, setPdfError] = useState(false);
|
|
|
|
// Modal de détails/édition
|
|
const [showDetailsModal, setShowDetailsModal] = useState(false);
|
|
const [selectedTransfer, setSelectedTransfer] = useState<SalaryTransfer | null>(null);
|
|
const [isEditing, setIsEditing] = useState(false);
|
|
const [editForm, setEditForm] = useState<SalaryTransfer | null>(null);
|
|
const [updating, setUpdating] = useState(false);
|
|
const [deleting, setDeleting] = useState(false);
|
|
|
|
// Notification client
|
|
const [sendingNotification, setSendingNotification] = useState(false);
|
|
|
|
// optimistic update helper
|
|
const selectedRow = useMemo(() => rows.find((r) => r.id === selectedId) ?? null, [rows, selectedId]);
|
|
|
|
// Realtime subscription: listen to INSERT / UPDATE / DELETE on salary_transfers
|
|
useEffect(() => {
|
|
// Debug: log incoming initialData when component mounts/hydrates
|
|
try {
|
|
console.log("SalaryTransfersGrid initialData (client):", Array.isArray(initialData) ? initialData.length : typeof initialData, initialData?.slice?.(0, 3));
|
|
} catch (err) {
|
|
console.log("SalaryTransfersGrid initialData (client) - could not log:", err);
|
|
}
|
|
|
|
let channel: any = null;
|
|
let mounted = true;
|
|
|
|
(async () => {
|
|
try {
|
|
channel = supabase.channel("public:salary_transfers");
|
|
channel.on(
|
|
"postgres_changes",
|
|
{ event: "*", schema: "public", table: "salary_transfers" },
|
|
(payload: any) => {
|
|
try {
|
|
const event = payload.event || payload.eventType || payload.type;
|
|
const record = payload.new ?? payload.record ?? payload.payload ?? payload;
|
|
|
|
if (event === "INSERT") {
|
|
const newRec = record as SalaryTransfer;
|
|
setRows((rs) => {
|
|
if (rs.find((r) => r.id === newRec.id)) return rs;
|
|
return [newRec, ...rs];
|
|
});
|
|
} else if (event === "UPDATE") {
|
|
setRows((rs) => rs.map((r) => (r.id === record.id ? { ...r, ...(record as SalaryTransfer) } : r)));
|
|
} else if (event === "DELETE") {
|
|
const id = record?.id ?? payload.old?.id;
|
|
if (id) setRows((rs) => rs.filter((r) => r.id !== id));
|
|
}
|
|
} catch (err) {
|
|
console.error("Realtime handler error", err);
|
|
}
|
|
}
|
|
);
|
|
|
|
// subscribe and await result (some SDKs return a promise)
|
|
const sub = await channel.subscribe();
|
|
// If subscribe returned an object with error info, warn and stop
|
|
if (!mounted) return;
|
|
if (sub && (sub.status === "timed_out" || sub.status === "closed" || sub?.error)) {
|
|
console.warn("Realtime subscribe returned unexpected status", sub);
|
|
}
|
|
} catch (err: any) {
|
|
// Common cause: table not published for realtime / not in schema cache
|
|
console.warn("Realtime subscription failed for public.salary_transfers — falling back to polling.", err?.message ?? err);
|
|
}
|
|
})();
|
|
|
|
return () => {
|
|
mounted = false;
|
|
try {
|
|
if (channel) {
|
|
// supabase.removeChannel exists in v2
|
|
// @ts-ignore
|
|
if (supabase.removeChannel) supabase.removeChannel(channel);
|
|
else channel.unsubscribe && channel.unsubscribe();
|
|
}
|
|
} catch (err) {
|
|
console.warn("Error unsubscribing realtime channel", err);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
// Helper: fetch server-side with current filters
|
|
async function fetchServer(pageIndex = 0) {
|
|
setLoading(true);
|
|
try {
|
|
const params = new URLSearchParams();
|
|
if (q) params.set('q', q);
|
|
if (orgFilter) params.set('org_id', orgFilter);
|
|
if (modeFilter) params.set('mode', modeFilter);
|
|
if (notificationSentFilter) params.set('notification_sent', notificationSentFilter);
|
|
if (notificationOkFilter) params.set('notification_ok', notificationOkFilter);
|
|
if (hasClientWireFilter) params.set('has_client_wire', hasClientWireFilter);
|
|
if (deadlineFrom) params.set('deadline_from', deadlineFrom);
|
|
if (deadlineTo) params.set('deadline_to', deadlineTo);
|
|
params.set('sort', sortField);
|
|
params.set('order', sortOrder === 'asc' ? 'asc' : 'desc');
|
|
params.set('limit', String(limit));
|
|
params.set('offset', String(pageIndex * limit));
|
|
|
|
const res = await fetch(`/api/staff/salary-transfers/search?${params.toString()}`);
|
|
if (!res.ok) throw new Error('Search failed');
|
|
const j = await res.json();
|
|
totalCountRef.current = j.count ?? (j.rows ? j.rows.length : 0);
|
|
setRows(j.rows ?? []);
|
|
setPage(pageIndex);
|
|
} catch (err) {
|
|
console.error('Search error', err);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
// Debounce searches when filters change
|
|
useEffect(() => {
|
|
// if no filters applied, prefer initial data
|
|
const noFilters = !q && !orgFilter && !modeFilter && !notificationSentFilter && !notificationOkFilter && !hasClientWireFilter && !deadlineFrom && !deadlineTo && sortField === 'period_month' && sortOrder === 'desc';
|
|
if (noFilters) {
|
|
setRows(initialData || []);
|
|
return;
|
|
}
|
|
|
|
const t = setTimeout(() => fetchServer(0), 300);
|
|
return () => clearTimeout(t);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [q, orgFilter, modeFilter, notificationSentFilter, notificationOkFilter, hasClientWireFilter, deadlineFrom, deadlineTo, sortField, sortOrder, limit]);
|
|
|
|
// derive options from initialData for simple selects
|
|
const modes = useMemo(() => Array.from(new Set((initialData || []).map((r) => r.mode).filter(Boolean) as string[])).slice(0,50), [initialData]);
|
|
|
|
// Function to create a new salary transfer
|
|
async function handleCreateTransfer() {
|
|
if (!createForm.org_id || !createForm.period_month || !createForm.deadline || !createForm.mode || !createForm.num_appel) {
|
|
alert("Veuillez remplir tous les champs obligatoires");
|
|
return;
|
|
}
|
|
|
|
setCreating(true);
|
|
try {
|
|
// Convert period_month from "YYYY-MM" to "YYYY-MM-01" (first day of month)
|
|
const periodDate = createForm.period_month.includes('-') && createForm.period_month.length === 7
|
|
? `${createForm.period_month}-01`
|
|
: createForm.period_month;
|
|
|
|
const payload = {
|
|
...createForm,
|
|
period_month: periodDate,
|
|
};
|
|
|
|
const res = await fetch("/api/staff/virements-salaires/create", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
credentials: "include",
|
|
body: JSON.stringify(payload),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const error = await res.json();
|
|
console.error("Create error response:", error);
|
|
throw new Error(error.details || error.error || "Erreur lors de la création");
|
|
}
|
|
|
|
const result = await res.json();
|
|
console.log("Create success:", result);
|
|
|
|
// Add to local state (realtime will also update it)
|
|
setRows((prev) => [result.data, ...prev]);
|
|
|
|
// Reset form and close modal
|
|
setCreateForm({
|
|
org_id: "",
|
|
period_month: "",
|
|
period_label: "",
|
|
deadline: "",
|
|
mode: "SEPA",
|
|
num_appel: "",
|
|
total_net: "",
|
|
notes: "",
|
|
});
|
|
setShowCreateModal(false);
|
|
|
|
alert("Virement créé avec succès");
|
|
} catch (err: any) {
|
|
console.error("Create error:", err);
|
|
alert(err.message || "Erreur lors de la création");
|
|
} finally {
|
|
setCreating(false);
|
|
}
|
|
}
|
|
|
|
// Function to generate PDF for a transfer
|
|
async function handleGeneratePdf(transferId: string) {
|
|
if (!confirm("Générer la feuille d'appel pour ce virement ?")) return;
|
|
|
|
setGeneratingPdfForId(transferId);
|
|
try {
|
|
const res = await fetch("/api/staff/virements-salaires/generate-pdf", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
credentials: "include",
|
|
body: JSON.stringify({ salary_transfer_id: transferId }),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const error = await res.json();
|
|
throw new Error(error.error || "Erreur lors de la génération");
|
|
}
|
|
|
|
const result = await res.json();
|
|
|
|
// Update local state with the new URL
|
|
setRows((prev) =>
|
|
prev.map((r) =>
|
|
r.id === transferId
|
|
? { ...r, callsheet_url: result.callsheet_url, updated_at: new Date().toISOString() }
|
|
: r
|
|
)
|
|
);
|
|
|
|
alert(`PDF généré avec succès (${result.contracts_count} contrats)`);
|
|
} catch (err: any) {
|
|
console.error("Generate PDF error:", err);
|
|
alert(err.message || "Erreur lors de la génération du PDF");
|
|
} finally {
|
|
setGeneratingPdfForId(null);
|
|
}
|
|
}
|
|
|
|
// Function to open details modal
|
|
function handleOpenDetails(transfer: SalaryTransfer) {
|
|
setSelectedTransfer(transfer);
|
|
setEditForm(transfer);
|
|
setIsEditing(false);
|
|
setPdfError(false);
|
|
setShowDetailsModal(true);
|
|
|
|
// Log pour debug
|
|
if (transfer.callsheet_url) {
|
|
console.log("[SalaryTransfersGrid] Opening PDF:", transfer.callsheet_url);
|
|
} else {
|
|
console.log("[SalaryTransfersGrid] No PDF URL available");
|
|
}
|
|
}
|
|
|
|
// Function to handle PDF error
|
|
const handlePdfError = () => {
|
|
setPdfError(true);
|
|
};
|
|
|
|
// Function to open PDF in new tab
|
|
const openPdfInNewTab = () => {
|
|
if (selectedTransfer?.callsheet_url) {
|
|
window.open(selectedTransfer.callsheet_url, '_blank');
|
|
}
|
|
};
|
|
|
|
// Function to download PDF
|
|
const downloadPdf = () => {
|
|
if (selectedTransfer?.callsheet_url) {
|
|
const link = document.createElement('a');
|
|
link.href = selectedTransfer.callsheet_url;
|
|
link.download = `appel-virement-${selectedTransfer.num_appel || selectedTransfer.id}.pdf`;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
}
|
|
};
|
|
|
|
// Function to update a transfer
|
|
async function handleUpdateTransfer() {
|
|
if (!editForm || !editForm.id) return;
|
|
|
|
setUpdating(true);
|
|
try {
|
|
// Convert period_month if needed
|
|
const periodDate = editForm.period_month && editForm.period_month.length === 7
|
|
? `${editForm.period_month}-01`
|
|
: editForm.period_month;
|
|
|
|
const payload = {
|
|
period_month: periodDate,
|
|
period_label: editForm.period_label,
|
|
deadline: editForm.deadline,
|
|
mode: editForm.mode,
|
|
num_appel: editForm.num_appel,
|
|
total_net: editForm.total_net,
|
|
notes: editForm.notes,
|
|
};
|
|
|
|
console.log("[handleUpdateTransfer] Payload:", payload);
|
|
|
|
const res = await fetch(`/api/staff/virements-salaires/${editForm.id}`, {
|
|
method: "PATCH",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
credentials: "include",
|
|
body: JSON.stringify(payload),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const error = await res.json();
|
|
console.error("Update error response:", error);
|
|
throw new Error(error.details || error.error || "Erreur lors de la modification");
|
|
}
|
|
|
|
const result = await res.json();
|
|
console.log("Update success:", result);
|
|
|
|
// Update local state
|
|
setRows((prev) =>
|
|
prev.map((r) => (r.id === editForm.id ? result.data : r))
|
|
);
|
|
|
|
setSelectedTransfer(result.data);
|
|
setEditForm(result.data);
|
|
setIsEditing(false);
|
|
|
|
alert("Virement modifié avec succès");
|
|
} catch (err: any) {
|
|
console.error("Update error:", err);
|
|
alert(err.message || "Erreur lors de la modification");
|
|
} finally {
|
|
setUpdating(false);
|
|
}
|
|
}
|
|
|
|
// Function to delete a transfer
|
|
async function handleDeleteTransfer() {
|
|
if (!selectedTransfer || !selectedTransfer.id) return;
|
|
|
|
if (!confirm("Êtes-vous sûr de vouloir supprimer ce virement ? Cette action est irréversible.")) {
|
|
return;
|
|
}
|
|
|
|
setDeleting(true);
|
|
try {
|
|
const res = await fetch(`/api/staff/virements-salaires/${selectedTransfer.id}`, {
|
|
method: "DELETE",
|
|
credentials: "include",
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const error = await res.json();
|
|
console.error("Delete error response:", error);
|
|
throw new Error(error.details || error.error || "Erreur lors de la suppression");
|
|
}
|
|
|
|
// Remove from local state
|
|
setRows((prev) => prev.filter((r) => r.id !== selectedTransfer.id));
|
|
|
|
setShowDetailsModal(false);
|
|
setSelectedTransfer(null);
|
|
setEditForm(null);
|
|
|
|
alert("Virement supprimé avec succès");
|
|
} catch (err: any) {
|
|
console.error("Delete error:", err);
|
|
alert(err.message || "Erreur lors de la suppression");
|
|
} finally {
|
|
setDeleting(false);
|
|
}
|
|
}
|
|
|
|
// Function to send client notification
|
|
async function handleNotifyClient() {
|
|
if (!selectedTransfer || !selectedTransfer.id) return;
|
|
|
|
if (!confirm("Envoyer la notification au client pour ce virement ?")) {
|
|
return;
|
|
}
|
|
|
|
setSendingNotification(true);
|
|
try {
|
|
const res = await fetch(`/api/staff/virements-salaires/${selectedTransfer.id}/notify-client`, {
|
|
method: "POST",
|
|
credentials: "include",
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const error = await res.json();
|
|
console.error("Notification error response:", error);
|
|
throw new Error(error.details || error.error || "Erreur lors de l'envoi de la notification");
|
|
}
|
|
|
|
const result = await res.json();
|
|
|
|
// Update local state
|
|
setRows((prev) =>
|
|
prev.map((r) =>
|
|
r.id === selectedTransfer.id
|
|
? { ...r, notification_sent: true, notification_ok: true, updated_at: new Date().toISOString() }
|
|
: r
|
|
)
|
|
);
|
|
|
|
// Update selected transfer
|
|
setSelectedTransfer((prev) =>
|
|
prev ? { ...prev, notification_sent: true, notification_ok: true } : null
|
|
);
|
|
|
|
alert(`Notification envoyée avec succès à ${result.emailSentTo}${result.emailCc ? ` (CC: ${result.emailCc})` : ''}`);
|
|
} catch (err: any) {
|
|
console.error("Notification error:", err);
|
|
alert(err.message || "Erreur lors de l'envoi de la notification");
|
|
} finally {
|
|
setSendingNotification(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="relative">
|
|
{/* Header avec bouton de création */}
|
|
<div className="mb-4 flex items-center justify-between">
|
|
<h2 className="text-lg font-semibold text-slate-800">Virements de salaires</h2>
|
|
<button
|
|
onClick={() => setShowCreateModal(true)}
|
|
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
<span>Créer un virement</span>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<div className="mb-3">
|
|
{/* Ligne du haut: recherche + bouton filtres */}
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-3">
|
|
<div className="flex-1">
|
|
<input
|
|
value={q}
|
|
onChange={(e) => setQ(e.target.value)}
|
|
placeholder="Recherche (période, feuille d'appel, notes)"
|
|
className="w-full rounded border px-2 py-1 text-sm"
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{/* Filtres rapides toujours visibles */}
|
|
<div className="flex items-center gap-1">
|
|
<label className="text-xs font-medium text-slate-700">Organisation:</label>
|
|
<select
|
|
value={orgFilter ?? ""}
|
|
onChange={(e) => setOrgFilter(e.target.value || null)}
|
|
className="rounded border px-3 py-2 text-sm bg-white min-w-[200px]"
|
|
disabled={!organizations || organizations.length === 0}
|
|
>
|
|
<option value="">
|
|
{!organizations || organizations.length === 0
|
|
? "Chargement..."
|
|
: "Toutes les organisations"}
|
|
</option>
|
|
{organizations && organizations.map((org) => (
|
|
<option key={org.id} value={org.id}>{org.name}</option>
|
|
))}
|
|
</select>
|
|
{organizations && organizations.length > 0 && (
|
|
<span className="text-xs text-slate-500">({organizations.length})</span>
|
|
)}
|
|
</div>
|
|
|
|
<select value={modeFilter ?? ""} onChange={(e) => setModeFilter(e.target.value || null)} className="rounded border px-2 py-1 text-sm">
|
|
<option value="">Tous modes</option>
|
|
{modes.map((m) => (<option key={m} value={m}>{m}</option>))}
|
|
</select>
|
|
|
|
<button
|
|
onClick={() => setShowFilters(!showFilters)}
|
|
className="inline-flex items-center gap-1 text-sm text-blue-600 hover:text-blue-800 px-2 py-1 rounded border"
|
|
>
|
|
{showFilters ? "Masquer" : "Plus de"} filtres
|
|
</button>
|
|
|
|
<button
|
|
className="rounded border px-3 py-1 text-sm"
|
|
onClick={() => {
|
|
setQ('');
|
|
setOrgFilter(null);
|
|
setModeFilter(null);
|
|
setNotificationSentFilter(null);
|
|
setNotificationOkFilter(null);
|
|
setHasClientWireFilter(null);
|
|
setDeadlineFrom(null);
|
|
setDeadlineTo(null);
|
|
setSortField('period_month');
|
|
setSortOrder('desc');
|
|
setRows(initialData || []);
|
|
}}
|
|
>
|
|
Réinitialiser
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filtres avancés (masquables) */}
|
|
{showFilters && (
|
|
<div className="border rounded-lg p-4 bg-slate-50">
|
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
{/* Filtre Notification envoyée */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-2">
|
|
Notification envoyée
|
|
</label>
|
|
<select
|
|
value={notificationSentFilter ?? ""}
|
|
onChange={(e) => setNotificationSentFilter(e.target.value || null)}
|
|
className="w-full px-3 py-2 border rounded-lg bg-white text-sm"
|
|
>
|
|
<option value="">Tous</option>
|
|
<option value="true">Oui</option>
|
|
<option value="false">Non</option>
|
|
</select>
|
|
</div>
|
|
|
|
{/* Filtre Notification OK */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-2">
|
|
Notification OK
|
|
</label>
|
|
<select
|
|
value={notificationOkFilter ?? ""}
|
|
onChange={(e) => setNotificationOkFilter(e.target.value || null)}
|
|
className="w-full px-3 py-2 border rounded-lg bg-white text-sm"
|
|
>
|
|
<option value="">Tous</option>
|
|
<option value="true">Oui</option>
|
|
<option value="false">Non</option>
|
|
</select>
|
|
</div>
|
|
|
|
{/* Filtre Virement client reçu */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-2">
|
|
Virement client reçu
|
|
</label>
|
|
<select
|
|
value={hasClientWireFilter ?? ""}
|
|
onChange={(e) => setHasClientWireFilter(e.target.value || null)}
|
|
className="w-full px-3 py-2 border rounded-lg bg-white text-sm"
|
|
>
|
|
<option value="">Tous</option>
|
|
<option value="true">Oui</option>
|
|
<option value="false">Non</option>
|
|
</select>
|
|
</div>
|
|
|
|
{/* Filtre date échéance - De */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-2">
|
|
Échéance - De
|
|
</label>
|
|
<input
|
|
type="date"
|
|
value={deadlineFrom ?? ""}
|
|
onChange={(e) => setDeadlineFrom(e.target.value || null)}
|
|
className="w-full px-3 py-2 border rounded-lg bg-white text-sm"
|
|
/>
|
|
</div>
|
|
|
|
{/* Filtre date échéance - À */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-2">
|
|
Échéance - À
|
|
</label>
|
|
<input
|
|
type="date"
|
|
value={deadlineTo ?? ""}
|
|
onChange={(e) => setDeadlineTo(e.target.value || null)}
|
|
className="w-full px-3 py-2 border rounded-lg bg-white text-sm"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="overflow-auto">
|
|
<table className="w-full text-sm">
|
|
<thead className="bg-slate-50 text-slate-600">
|
|
<tr>
|
|
<th className="text-left px-3 py-2 cursor-pointer" onClick={() => { setSortField('period_month'); setSortOrder((o) => o === 'asc' ? 'desc' : 'asc'); }}>
|
|
Période {sortField === 'period_month' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
|
|
</th>
|
|
<th className="text-left px-3 py-2">N° Appel</th>
|
|
<th className="text-left px-3 py-2">Mode</th>
|
|
<th className="text-left px-3 py-2 cursor-pointer" onClick={() => { setSortField('deadline'); setSortOrder((o) => o === 'asc' ? 'desc' : 'asc'); }}>
|
|
Échéance {sortField === 'deadline' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
|
|
</th>
|
|
<th className="text-left px-3 py-2 cursor-pointer" onClick={() => { setSortField('total_net'); setSortOrder((o) => o === 'asc' ? 'desc' : 'asc'); }}>
|
|
Total Net {sortField === 'total_net' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
|
|
</th>
|
|
<th className="text-left px-3 py-2">Statut PDF</th>
|
|
<th className="text-left px-3 py-2">Notification</th>
|
|
<th className="text-left px-3 py-2 cursor-pointer" onClick={() => { setSortField('client_wire_received_at'); setSortOrder((o) => o === 'asc' ? 'desc' : 'asc'); }}>
|
|
Virement reçu {sortField === 'client_wire_received_at' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
|
|
</th>
|
|
<th className="text-left px-3 py-2">Notes</th>
|
|
<th className="text-left px-3 py-2 cursor-pointer" onClick={() => { setSortField('created_at'); setSortOrder((o) => o === 'asc' ? 'desc' : 'asc'); }}>
|
|
Créé le {sortField === 'created_at' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
|
|
</th>
|
|
<th className="text-left px-3 py-2">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows.map((r) => (
|
|
<tr
|
|
key={r.id}
|
|
className="border-t hover:bg-slate-50 cursor-pointer transition-colors"
|
|
onClick={() => handleOpenDetails(r)}
|
|
>
|
|
{/* Période */}
|
|
<td className="px-3 py-2">
|
|
<div className="font-medium">{r.period_label || "—"}</div>
|
|
<div className="text-xs text-slate-500">{formatDate(r.period_month)}</div>
|
|
</td>
|
|
|
|
{/* Numéro d'appel */}
|
|
<td className="px-3 py-2">
|
|
<div className="font-mono text-sm">{r.num_appel || "—"}</div>
|
|
</td>
|
|
|
|
{/* Mode */}
|
|
<td className="px-3 py-2">
|
|
<span className={`inline-block px-2 py-1 rounded-full text-xs font-medium ${
|
|
r.mode === 'odentas_reverse' ? 'bg-blue-100 text-blue-800' : 'bg-slate-100 text-slate-700'
|
|
}`}>
|
|
{r.mode || "—"}
|
|
</span>
|
|
</td>
|
|
|
|
{/* Échéance */}
|
|
<td className="px-3 py-2">{formatDate(r.deadline)}</td>
|
|
|
|
{/* Total Net */}
|
|
<td className="px-3 py-2 font-medium">{formatAmount(r.total_net)}</td>
|
|
|
|
{/* Feuille d'appel */}
|
|
<td className="px-3 py-2">
|
|
{r.callsheet_url ? (
|
|
<div className="inline-flex items-center gap-2 px-3 py-1.5 bg-green-50 border border-green-200 rounded-lg">
|
|
<CheckCircle2 className="w-4 h-4 text-green-600" />
|
|
<span className="text-sm font-medium text-green-700">Disponible</span>
|
|
</div>
|
|
) : (
|
|
<div className="inline-flex items-center gap-2 px-3 py-1.5 bg-slate-50 border border-slate-200 rounded-lg">
|
|
<XCircle className="w-4 h-4 text-slate-400" />
|
|
<span className="text-sm font-medium text-slate-500">Non générée</span>
|
|
</div>
|
|
)}
|
|
</td>
|
|
|
|
{/* Notification */}
|
|
<td className="px-3 py-2">
|
|
<div className="flex flex-col gap-1">
|
|
<span className={`inline-block px-2 py-1 rounded-full text-xs font-medium ${
|
|
r.notification_sent ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
|
|
}`}>
|
|
{r.notification_sent ? 'Envoyée' : 'Non envoyée'}
|
|
</span>
|
|
{r.notification_sent && (
|
|
<span className={`inline-block px-2 py-1 rounded-full text-xs font-medium ${
|
|
r.notification_ok ? 'bg-green-100 text-green-800' : 'bg-orange-100 text-orange-800'
|
|
}`}>
|
|
{r.notification_ok ? 'OK' : 'Erreur'}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</td>
|
|
|
|
{/* Virement client reçu */}
|
|
<td className="px-3 py-2">
|
|
{r.client_wire_received_at ? (
|
|
<span className="inline-block px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
|
{formatDate(r.client_wire_received_at)}
|
|
</span>
|
|
) : (
|
|
<span className="inline-block px-2 py-1 rounded-full text-xs font-medium bg-red-100 text-red-800">
|
|
Non reçu
|
|
</span>
|
|
)}
|
|
</td>
|
|
|
|
{/* Notes */}
|
|
<td className="px-3 py-2 max-w-xs">
|
|
{r.notes ? (
|
|
<div className="truncate" title={r.notes}>
|
|
{r.notes}
|
|
</div>
|
|
) : "—"}
|
|
</td>
|
|
|
|
{/* Créé le */}
|
|
<td className="px-3 py-2">{formatDate(r.created_at)}</td>
|
|
|
|
{/* Actions */}
|
|
<td className="px-3 py-2" onClick={(e) => e.stopPropagation()}>
|
|
<button
|
|
onClick={() => handleGeneratePdf(r.id)}
|
|
disabled={generatingPdfForId === r.id}
|
|
className={`px-3 py-1 rounded text-xs font-medium transition-colors ${
|
|
generatingPdfForId === r.id
|
|
? "bg-slate-300 text-slate-500 cursor-not-allowed"
|
|
: r.callsheet_url
|
|
? "bg-green-100 text-green-800 hover:bg-green-200"
|
|
: "bg-blue-600 text-white hover:bg-blue-700"
|
|
}`}
|
|
>
|
|
{generatingPdfForId === r.id
|
|
? "Génération..."
|
|
: r.callsheet_url
|
|
? "Regénérer PDF"
|
|
: "Générer PDF"}
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
{rows.length === 0 && (
|
|
<div className="p-4 text-sm text-slate-600">
|
|
<div>Aucun virement de salaire trouvé.</div>
|
|
<div className="mt-2">
|
|
<button
|
|
className="text-xs underline"
|
|
onClick={() => setShowRaw((s) => !s)}
|
|
>
|
|
{showRaw ? "Cacher le payload" : "Voir le payload reçu"}
|
|
</button>
|
|
</div>
|
|
{showRaw && (
|
|
<pre className="mt-2 max-h-48 overflow-auto text-xs bg-slate-50 p-2 rounded border">{JSON.stringify(initialData, null, 2)}</pre>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Pagination / info */}
|
|
<div className="mt-3 flex items-center justify-between text-xs text-slate-600">
|
|
<div>{loading ? 'Chargement…' : `Affichage ${rows.length}${totalCountRef.current ? ` / ${totalCountRef.current}` : ''}`}</div>
|
|
<div className="flex items-center gap-2">
|
|
<button className="text-xs px-2 py-1 rounded border" onClick={() => { if (page > 0) fetchServer(page - 1); }}>Préc</button>
|
|
<button className="text-xs px-2 py-1 rounded border" onClick={() => { fetchServer(page + 1); }}>Suiv</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Modal de création */}
|
|
{showCreateModal && (
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
|
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
|
<div className="p-6">
|
|
<h3 className="text-xl font-semibold mb-4">Créer un nouveau virement de salaire</h3>
|
|
|
|
<div className="space-y-4">
|
|
{/* Organisation */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">
|
|
Organisation <span className="text-red-500">*</span>
|
|
</label>
|
|
<select
|
|
value={createForm.org_id}
|
|
onChange={(e) => setCreateForm({ ...createForm, org_id: e.target.value })}
|
|
className="w-full px-3 py-2 border rounded-lg bg-white"
|
|
disabled={!organizations || organizations.length === 0}
|
|
>
|
|
<option value="">Sélectionner une organisation</option>
|
|
{organizations && organizations.map((org) => (
|
|
<option key={org.id} value={org.id}>{org.name}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Période (mois) */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">
|
|
Période (mois) <span className="text-red-500">*</span>
|
|
</label>
|
|
<input
|
|
type="month"
|
|
value={createForm.period_month}
|
|
onChange={(e) => setCreateForm({
|
|
...createForm,
|
|
period_month: e.target.value,
|
|
// Auto-générer le label si vide
|
|
period_label: !createForm.period_label ? new Date(e.target.value + "-01").toLocaleDateString("fr-FR", { month: "long", year: "numeric" }) : createForm.period_label
|
|
})}
|
|
className="w-full px-3 py-2 border rounded-lg"
|
|
/>
|
|
</div>
|
|
|
|
{/* Libellé de la période */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">
|
|
Libellé de la période (optionnel)
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={createForm.period_label}
|
|
onChange={(e) => setCreateForm({ ...createForm, period_label: e.target.value })}
|
|
placeholder="Ex: Janvier 2025"
|
|
className="w-full px-3 py-2 border rounded-lg"
|
|
/>
|
|
</div>
|
|
|
|
{/* Date d'échéance */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">
|
|
Date d'é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>
|
|
|
|
{/* 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">Notifications</div>
|
|
<div className="flex gap-2 mt-2">
|
|
<span className={`inline-block px-3 py-1 rounded-full text-xs font-medium ${
|
|
selectedTransfer.notification_sent ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
|
|
}`}>
|
|
{selectedTransfer.notification_sent ? '✓ Envoyée' : '✗ Non envoyée'}
|
|
</span>
|
|
{selectedTransfer.notification_sent && (
|
|
<span className={`inline-block px-3 py-1 rounded-full text-xs font-medium ${
|
|
selectedTransfer.notification_ok ? 'bg-green-100 text-green-800' : 'bg-orange-100 text-orange-800'
|
|
}`}>
|
|
{selectedTransfer.notification_ok ? '✓ OK' : '⚠ Erreur'}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-slate-50 p-4 rounded-lg">
|
|
<div className="text-xs text-slate-600 mb-1">Virement client reçu</div>
|
|
<div className="font-medium text-slate-900">
|
|
{selectedTransfer.client_wire_received_at
|
|
? formatDate(selectedTransfer.client_wire_received_at)
|
|
: "Non reçu"}
|
|
</div>
|
|
</div>
|
|
|
|
{selectedTransfer.notes && (
|
|
<div className="bg-slate-50 p-4 rounded-lg">
|
|
<div className="text-xs text-slate-600 mb-1">Notes</div>
|
|
<div className="text-sm text-slate-700 whitespace-pre-wrap">{selectedTransfer.notes}</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="bg-slate-50 p-4 rounded-lg">
|
|
<div className="text-xs text-slate-600 mb-1">Dates</div>
|
|
<div className="text-xs text-slate-700 space-y-1">
|
|
<div>Créé le : {formatDate(selectedTransfer.created_at)}</div>
|
|
<div>Modifié le : {formatDate(selectedTransfer.updated_at)}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Colonne droite : PDF */}
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<h4 className="font-semibold text-lg text-slate-800">Feuille d'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>
|
|
)}
|
|
</div>
|
|
);
|
|
} |