espace-paie-odentas/app/(app)/virements-salaires/page.tsx
odentas 91e0919274 feat: Ajout export SEPA et marquage groupé des virements salaires
- Ajout export fichier SEPA XML (norme ISO 20022 pain.001.001.03)
  * Sélection multiple via checkboxes
  * Génération XML pour virements bancaires groupés
  * Validation IBAN et gestion des salariés sans RIB
  * Modal de succès/avertissements
  * Référence: Nom organisation - Période

- Ajout marquage groupé des paies comme payées
  * Sélection multiple des paies
  * Modal de confirmation
  * Actualisation automatique sans refresh

- Nouvelle route API /api/virements-salaires/[id] (PATCH)
  * Mise à jour transfer_done et transfer_done_at

- Amélioration UX
  * Card informative pour clients non-Odentas
  * Modal informatif dans 'En savoir plus'
  * Messages clairs et cohérents
2025-11-03 00:08:21 +01:00

1637 lines
68 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// app/(app)/virements-salaires/page.tsx
"use client";
import React, { useMemo, useState, useEffect } from "react";
import { useSearchParams, usePathname, useRouter } from "next/navigation";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2, Search, Download, ExternalLink, Info, Copy, Check, X } from "lucide-react";
import { usePageTitle } from "@/hooks/usePageTitle";
import { useDemoMode } from "@/hooks/useDemoMode";
// --- Types ---
type PeriodKey =
| "toute_annee"
| "premier_semestre"
| "second_semestre"
| "trimestre_1"
| "trimestre_2"
| "trimestre_3"
| "trimestre_4"
| `mois_${1|2|3|4|5|6|7|8|9|10|11|12}`;
type Filters = {
year: number;
period: PeriodKey;
from?: string; // YYYY-MM-DD
to?: string; // YYYY-MM-DD
};
type VirementItem = {
id: string;
periode_label?: string;
periode?: string;
callsheet?: string;
num_appel?: string;
date_mois?: string;
date?: string;
total_salaries_eur?: number;
total?: number;
virement_recu?: boolean | string;
virement_recu_date?: string | null; // Date de réception du virement
salaires_payes?: boolean | string;
pdf_url?: string;
};
type OrgSummary = {
structure_api: string | null;
virements_salaires: string | null;
iban: string | null;
bic: string | null;
gocardless?: string | null;
} | null;
// Vue client (virements gérés par le client)
type ClientVirementItem = {
id: string;
kind: 'CDDU_MONO' | 'CDDU_MULTI' | 'RG';
source: 'contrat' | 'paie_multi' | 'paie_rg' | 'payslip';
contract_id?: string;
salarie?: string | null;
salarie_matricule?: string | null;
reference?: string | null;
profession?: string | null;
date_debut?: string | null;
date_fin?: string | null;
periode?: string | null; // ex: "2025-08-01" ou "08/2025"
net_a_payer?: number | null;
virement_effectue?: 'oui' | 'non' | 'na';
};
type ClientInfo = {
id: string;
name: string;
api_name?: string;
} | null;
// --- Helpers ---
function pad2(n: number) { return String(n).padStart(2, "0"); }
function monthStartISO(y: number, m: number) { return `${y}-${pad2(m)}-01`; }
function monthEndISO(y: number, m: number) {
const d = new Date(y, m, 0); // last day of month
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
}
function quarterRange(y: number, q: 1 | 2 | 3 | 4) {
const startMonth = (q - 1) * 3 + 1;
const endMonth = startMonth + 2;
return { from: monthStartISO(y, startMonth), to: monthEndISO(y, endMonth) };
}
function formatFR(iso: string) {
if (!iso) return "—";
const d = new Date(iso);
if (isNaN(d.getTime())) return "—";
const dd = String(d.getDate()).padStart(2, '0');
const mm = String(d.getMonth() + 1).padStart(2, '0');
const yyyy = d.getFullYear();
return `${dd}/${mm}/${yyyy}`;
}
function maskEndDate(iso?: string | null): string | React.ReactNode {
if (!iso) return '—';
// Masquer la date 2099-01-01 utilisée comme fin indéterminée (CDI en cours)
if (iso.startsWith('2099-01-01')) {
return <span className="text-sm text-slate-400 italic">CDI en cours</span>;
}
return formatFR(iso);
}
function capitalize(str: string) {
if (!str) return str;
return str.charAt(0).toUpperCase() + str.slice(1);
}
// 🎭 Données fictives pour le mode démo (virements gérés par Odentas)
const DEMO_VIREMENTS: VirementItem[] = [
{
id: 'demo-vir-001',
periode_label: 'Septembre 2025',
periode: '2025-09',
callsheet: 'FDR-2025-09',
num_appel: 'FDR-2025-09',
date_mois: '2025-09-01',
date: '2025-09-01',
total_salaries_eur: 12450.50,
total: 12450.50,
virement_recu: true,
virement_recu_date: '2025-09-15',
salaires_payes: true,
},
{
id: 'demo-vir-002',
periode_label: 'Août 2025',
periode: '2025-08',
callsheet: 'FDR-2025-08',
num_appel: 'FDR-2025-08',
date_mois: '2025-08-01',
date: '2025-08-01',
total_salaries_eur: 15230.80,
total: 15230.80,
virement_recu: true,
virement_recu_date: '2025-08-12',
salaires_payes: true,
},
{
id: 'demo-vir-003',
periode_label: 'Juillet 2025',
periode: '2025-07',
callsheet: 'FDR-2025-07',
num_appel: 'FDR-2025-07',
date_mois: '2025-07-01',
date: '2025-07-01',
total_salaries_eur: 18765.25,
total: 18765.25,
virement_recu: true,
virement_recu_date: '2025-07-18',
salaires_payes: true,
},
{
id: 'demo-vir-004',
periode_label: 'Juin 2025',
periode: '2025-06',
callsheet: 'FDR-2025-06',
num_appel: 'FDR-2025-06',
date_mois: '2025-06-01',
date: '2025-06-01',
total_salaries_eur: 14890.00,
total: 14890.00,
virement_recu: true,
virement_recu_date: '2025-06-14',
salaires_payes: true,
},
{
id: 'demo-vir-005',
periode_label: 'Mai 2025',
periode: '2025-05',
callsheet: 'FDR-2025-05',
num_appel: 'FDR-2025-05',
date_mois: '2025-05-01',
date: '2025-05-01',
total_salaries_eur: 11250.75,
total: 11250.75,
virement_recu: true,
virement_recu_date: '2025-05-16',
salaires_payes: true,
},
];
// Affiche une période au format "Mois YYYY" (ex: "Septembre 2025")
function formatPeriode(per?: string | null) {
if (!per) return '—';
const s = String(per).trim();
// ISO: YYYY-MM or YYYY-MM-DD
const mIso = s.match(/^(\d{4})-(\d{2})(?:-(\d{2}))?$/);
if (mIso) {
const y = parseInt(mIso[1], 10);
const m = parseInt(mIso[2], 10);
const d = new Date(y, (isNaN(m) ? 1 : m) - 1, 1);
return capitalize(d.toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' }));
}
// MM/YYYY
const mMY = s.match(/^(\d{1,2})\/(\d{4})$/);
if (mMY) {
const m = parseInt(mMY[1], 10);
const y = parseInt(mMY[2], 10);
const d = new Date(y, (isNaN(m) ? 1 : m) - 1, 1);
return capitalize(d.toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' }));
}
// Fallback: essayer Date()
const d = new Date(s);
if (!isNaN(d.getTime())) {
return capitalize(d.toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' }));
}
return s;
}
function formatCurrency(amount?: number) {
if (amount == null) return "—";
return new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR'
}).format(amount);
}
function toBoolish(v: any): boolean | undefined {
if (v === true) return true;
if (v === false) return false;
if (typeof v === 'string') {
const s = v.trim().toLowerCase();
if (s === 'oui' || s === 'yes' || s === 'true') return true;
if (s === 'non' || s === 'no' || s === 'false') return false;
}
return undefined;
}
// --- Hook pour récupérer les infos utilisateur et les organisations (staff uniquement) ---
function useUserInfo() {
return useQuery({
queryKey: ["user-info"],
queryFn: async () => {
try {
const res = await fetch("/api/me", {
cache: "no-store",
headers: { Accept: "application/json" },
credentials: "include"
});
if (!res.ok) return null;
const me = await res.json();
return {
isStaff: Boolean(me.is_staff || me.isStaff),
orgId: me.orgId || me.active_org_id || null,
orgName: me.orgName || me.active_org_name || "Organisation",
api_name: me.active_org_api_name
};
} catch {
return null;
}
},
staleTime: 30_000,
});
}
function useOrganizations() {
const { data: userInfo, isSuccess: userInfoLoaded } = useUserInfo();
const query = useQuery({
queryKey: ["organizations"],
queryFn: async () => {
try {
const res = await fetch("/api/staff/organizations", {
cache: "no-store",
headers: { Accept: "application/json" },
credentials: "include"
});
if (!res.ok) return [];
const data = await res.json();
return data.organizations || [];
} catch {
return [];
}
},
enabled: false,
staleTime: 60_000,
});
React.useEffect(() => {
if (userInfoLoaded && userInfo?.isStaff && !query.data && !query.isFetching) {
query.refetch();
}
}, [userInfo, userInfoLoaded, query]);
return query;
}
// --- Hook pour récupérer les virements ---
function useVirements(filters: Filters, selectedOrgId?: string, isDemoMode: boolean = false) {
// Récupération dynamique des infos utilisateur via /api/me
const { data: userInfo } = useUserInfo();
const qs = new URLSearchParams();
qs.set("year", String(filters.year));
qs.set("period", filters.period);
if (filters.from) qs.set("from", filters.from);
if (filters.to) qs.set("to", filters.to);
// Si c'est un staff qui a sélectionné une organisation spécifique
if (selectedOrgId && userInfo?.isStaff) {
qs.set("org_id", selectedOrgId);
}
return useQuery<{ items: VirementItem[]; enabled?: boolean; org?: OrgSummary; client?: { unpaid: ClientVirementItem[]; recent: ClientVirementItem[] } }>({
queryKey: ["virements-salaires", filters.year, userInfo?.orgId, selectedOrgId, isDemoMode],
queryFn: async () => {
// 🎭 En mode démo, retourner les données fictives
if (isDemoMode) {
console.log('🎭 [VIREMENTS] Mode démo - retour de données fictives');
return {
items: DEMO_VIREMENTS,
enabled: true, // Mode Odentas activé en démo
org: {
structure_api: 'demo-org',
virements_salaires: 'odentas',
iban: 'FR76 1234 5678 9012 3456 7890 123',
bic: 'ABCDEFGH',
} as OrgSummary,
client: undefined,
};
}
// On récupère toutes les données de l'année, le filtrage par période se fera côté client
const simpleQs = new URLSearchParams();
simpleQs.set("year", String(filters.year));
simpleQs.set("period", "toute_annee");
// Si c'est un staff qui a sélectionné une organisation spécifique
if (selectedOrgId && userInfo?.isStaff) {
simpleQs.set("org_id", selectedOrgId);
}
const res = await fetch(`/api/virements-salaires?${simpleQs.toString()}`, {
credentials: "include",
headers: { Accept: "application/json" }
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || `Erreur ${res.status}`);
}
const data = await res.json();
return {
items: (data?.items || []) as VirementItem[],
enabled: Boolean(data?.enabled),
org: (data?.org ?? null) as OrgSummary,
client: data?.client as { unpaid: ClientVirementItem[]; recent: ClientVirementItem[] } | undefined,
};
},
staleTime: 15_000,
enabled: !!userInfo, // Ne pas exécuter si pas d'infos utilisateur
});
}
export default function VirementsPage() {
usePageTitle("Virements salaires");
// 🎭 Détection du mode démo
const { isDemoMode } = useDemoMode();
const now = new Date();
const [filters, setFilters] = useState<Filters>({
year: now.getFullYear(),
period: "toute_annee"
});
const [searchQuery, setSearchQuery] = useState("");
const [aboutOpen, setAboutOpen] = useState(false);
const [copiedField, setCopiedField] = useState<null | 'iban' | 'bic' | 'benef'>(null);
const [selectedOrgId, setSelectedOrgId] = useState<string>("");
const [pdfModalOpen, setPdfModalOpen] = useState(false);
const [pdfUrl, setPdfUrl] = useState<string>("");
const [undoModalOpen, setUndoModalOpen] = useState(false);
const [undoPayslipId, setUndoPayslipId] = useState<string | null>(null);
// États pour la sélection et l'export SEPA
const [selectedPayslips, setSelectedPayslips] = useState<Set<string>>(new Set());
const [showExportModal, setShowExportModal] = useState(false);
const [exportWarnings, setExportWarnings] = useState<string[]>([]);
const [exportSuccess, setExportSuccess] = useState<{
numberOfTransactions: number;
totalAmount: string;
includedPayments: number;
excludedPayments: number;
} | null>(null);
const [isExporting, setIsExporting] = useState(false);
const [isMarkingPaid, setIsMarkingPaid] = useState(false);
const [showBulkMarkPaidModal, setShowBulkMarkPaidModal] = useState(false);
const { data: userInfo, isLoading: isLoadingUser } = useUserInfo();
const { data: organizations, isLoading: isLoadingOrgs, error: orgsError } = useOrganizations();
const queryClient = useQueryClient();
const years = useMemo(() => {
const base = now.getFullYear();
return Array.from({ length: 7 }, (_, i) => base - i);
}, [now]);
// URL sync
const searchParams = useSearchParams();
const pathname = usePathname();
const router = useRouter();
useEffect(() => {
const sp = searchParams;
if (!sp) return;
const yParam = sp.get("year");
const pParam = sp.get("period");
const from = sp.get("from") || undefined;
const to = sp.get("to") || undefined;
const next: Filters = { ...filters };
if (yParam) {
const y = parseInt(yParam, 10);
if (!isNaN(y)) next.year = y;
}
if (pParam) {
const allowed = new Set<PeriodKey>([
"toute_annee", "premier_semestre", "second_semestre",
"trimestre_1", "trimestre_2", "trimestre_3", "trimestre_4",
"mois_1", "mois_2", "mois_3", "mois_4", "mois_5", "mois_6",
"mois_7", "mois_8", "mois_9", "mois_10", "mois_11", "mois_12",
] as PeriodKey[]);
if (allowed.has(pParam as PeriodKey)) {
next.period = pParam as PeriodKey;
if (!from && !to) {
const y = next.year;
if (pParam === "premier_semestre") {
next.from = monthStartISO(y, 1);
next.to = monthEndISO(y, 6);
}
else if (pParam === "second_semestre") {
next.from = monthStartISO(y, 7);
next.to = monthEndISO(y, 12);
}
else if (pParam === "trimestre_1") {
const r = quarterRange(y, 1);
next.from = r.from;
next.to = r.to;
}
else if (pParam === "trimestre_2") {
const r = quarterRange(y, 2);
next.from = r.from;
next.to = r.to;
}
else if (pParam === "trimestre_3") {
const r = quarterRange(y, 3);
next.from = r.from;
next.to = r.to;
}
else if (pParam === "trimestre_4") {
const r = quarterRange(y, 4);
next.from = r.from;
next.to = r.to;
}
else if (pParam.startsWith("mois_")) {
const m = parseInt(pParam.split("_")[1]!, 10);
if (!isNaN(m)) {
next.from = monthStartISO(y, m);
next.to = monthEndISO(y, m);
}
}
else if (pParam === "toute_annee") {
next.from = undefined;
next.to = undefined;
}
}
}
}
next.from = (from as any) ?? next.from;
next.to = (to as any) ?? next.to;
const changed = next.year !== filters.year || next.period !== filters.period || next.from !== filters.from || next.to !== filters.to;
if (changed) setFilters(next);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams?.toString()]);
useEffect(() => {
if (!pathname) return;
const qs = new URLSearchParams();
qs.set("year", String(filters.year));
qs.set("period", filters.period);
if (filters.from) qs.set("from", filters.from);
if (filters.to) qs.set("to", filters.to);
router.replace(`${pathname}?${qs.toString()}`, { scroll: false });
}, [filters, pathname, router]);
const { data, isLoading, isError, error, isFetching } = useVirements(filters, selectedOrgId, isDemoMode);
const loadingOrg = isLoading || isFetching || data === undefined;
const org: OrgSummary = (data?.org ?? null) as OrgSummary;
// Amélioration de la détection du mode Odentas (toujours actif en mode démo)
const isOdentas = isDemoMode || (!loadingOrg && (
Boolean(data?.enabled) ||
(typeof org?.virements_salaires === 'string' && org!.virements_salaires!.toLowerCase().includes('odentas'))
));
const gestionLabel = loadingOrg ? "Chargement…" : (isOdentas ? "Odentas" : "Votre structure");
const items: VirementItem[] = (data?.items ?? []) as VirementItem[];
const clientUnpaidAll: ClientVirementItem[] = (data?.client?.unpaid ?? []) as ClientVirementItem[];
const clientRecentAll: ClientVirementItem[] = (data?.client?.recent ?? []) as ClientVirementItem[];
const clientFilter = (arr: ClientVirementItem[]) => {
if (!searchQuery.trim()) return arr;
const q = searchQuery.toLowerCase();
return arr.filter(it =>
(it.salarie || '').toLowerCase().includes(q) ||
(it.reference || '').toLowerCase().includes(q) ||
(it.profession || '').toLowerCase().includes(q) ||
(it.periode || '').toLowerCase().includes(q)
);
};
const clientUnpaid = clientFilter(clientUnpaidAll);
const clientRecent = clientFilter(clientRecentAll);
// Calcul du total des nets à payer pour les salaires non payés
const totalNetAPayer = useMemo(() => {
return clientUnpaid.reduce((sum, item) => {
return sum + (item.net_a_payer ?? 0);
}, 0);
}, [clientUnpaid]);
// Mutation: marquer un payslip comme viré
async function markPayslipDone(payslipId: string) {
try {
// Optimistic UI: masquer l'élément avant refetch
// (on ne modifie pas le cache TanStack ici, on refetch directement après)
await fetch(`/api/virements-salaires/${encodeURIComponent(payslipId)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
credentials: 'include',
body: JSON.stringify({ transfer_done: true })
});
// Invalider les requêtes liées pour recharger la liste
await queryClient.invalidateQueries({ queryKey: ["virements-salaires"] });
} catch (e) {
console.error('Erreur marquage payslip:', e);
alert('Erreur lors du marquage du virement.');
}
}
// Mutation: marquer un payslip comme NON viré (annuler)
async function markPayslipUndone(payslipId: string) {
try {
await fetch(`/api/virements-salaires/${encodeURIComponent(payslipId)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
credentials: 'include',
body: JSON.stringify({ transfer_done: false })
});
// Invalider les requêtes liées pour recharger la liste
await queryClient.invalidateQueries({ queryKey: ["virements-salaires"] });
setUndoModalOpen(false);
setUndoPayslipId(null);
} catch (e) {
console.error('Erreur annulation marquage payslip:', e);
alert('Erreur lors de l\'annulation du marquage du virement.');
}
}
// Ouvrir la modale de confirmation pour annuler un marquage
function openUndoModal(payslipId: string) {
setUndoPayslipId(payslipId);
setUndoModalOpen(true);
}
// Gestion de la sélection de payslips
function togglePayslipSelection(payslipId: string) {
setSelectedPayslips(prev => {
const next = new Set(prev);
if (next.has(payslipId)) {
next.delete(payslipId);
} else {
next.add(payslipId);
}
return next;
});
}
function toggleSelectAll() {
if (selectedPayslips.size === clientUnpaid.length && clientUnpaid.length > 0) {
setSelectedPayslips(new Set());
} else {
const allIds = clientUnpaid
.filter(item => item.source === 'payslip' && item.id)
.map(item => item.id);
setSelectedPayslips(new Set(allIds));
}
}
// Export SEPA
async function handleExportSepa() {
if (selectedPayslips.size === 0) {
alert('Veuillez sélectionner au moins une fiche de paie');
return;
}
setIsExporting(true);
try {
const response = await fetch('/api/virements-salaires/export-sepa', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
payslipIds: Array.from(selectedPayslips),
organizationId: selectedOrgId
})
});
const result = await response.json();
if (!response.ok) {
if (result.employeesWithoutIBAN && result.employeesWithoutIBAN.length > 0) {
setExportWarnings(result.employeesWithoutIBAN);
setExportSuccess(null);
setShowExportModal(true);
} else {
throw new Error(result.message || 'Erreur lors de l\'export');
}
return;
}
// Préparer les données de succès
setExportSuccess({
numberOfTransactions: result.metadata.numberOfTransactions,
totalAmount: result.metadata.totalAmount,
includedPayments: result.metadata.includedPayments || result.metadata.numberOfTransactions,
excludedPayments: result.metadata.excludedPayments || 0
});
// Si des salariés n'ont pas d'IBAN mais que d'autres oui
if (result.metadata?.employeesWithoutIBAN && result.metadata.employeesWithoutIBAN.length > 0) {
setExportWarnings(result.metadata.employeesWithoutIBAN);
} else {
setExportWarnings([]);
}
// Afficher le modal de succès
setShowExportModal(true);
// Télécharger le fichier XML
const blob = new Blob([result.xml], { type: 'application/xml' });
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `virements_sepa_${result.metadata.messageId}.xml`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
// Décocher les payslips après export réussi
setSelectedPayslips(new Set());
} catch (error) {
console.error('Erreur export SEPA:', error);
alert(error instanceof Error ? error.message : 'Erreur lors de l\'export SEPA');
} finally {
setIsExporting(false);
}
}
// Marquage groupé des paies comme payées
async function handleBulkMarkPaid() {
if (selectedPayslips.size === 0) {
alert('Veuillez sélectionner au moins une fiche de paie');
return;
}
setShowBulkMarkPaidModal(true);
}
async function confirmBulkMarkPaid() {
setShowBulkMarkPaidModal(false);
setIsMarkingPaid(true);
try {
const promises = Array.from(selectedPayslips).map(payslipId =>
fetch(`/api/virements-salaires/${encodeURIComponent(payslipId)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
credentials: 'include',
body: JSON.stringify({ transfer_done: true })
})
);
await Promise.all(promises);
// Invalider les requêtes pour recharger la liste
await queryClient.invalidateQueries({ queryKey: ["virements-salaires"] });
// Réinitialiser la sélection
setSelectedPayslips(new Set());
} catch (error) {
console.error('Erreur marquage groupé:', error);
alert('Erreur lors du marquage groupé des virements');
} finally {
setIsMarkingPaid(false);
}
}
// Filtrage local pour la recherche ET la période
const filteredItems = useMemo((): VirementItem[] => {
let result: VirementItem[] = items;
// 1. Filtrage par année (toujours actif)
result = result.filter((item: VirementItem) => {
if (!item.date_mois) return false;
const [year] = item.date_mois.split('-').map(Number);
return year === filters.year;
});
// 2. Filtrage par période spécifique (si pas "toute_annee")
if (filters.period !== "toute_annee") {
result = result.filter((item: VirementItem) => {
if (!item.date_mois) return false;
const [, month] = item.date_mois.split('-').map(Number);
switch (filters.period) {
case "premier_semestre":
return month >= 1 && month <= 6;
case "second_semestre":
return month >= 7 && month <= 12;
case "trimestre_1":
return month >= 1 && month <= 3;
case "trimestre_2":
return month >= 4 && month <= 6;
case "trimestre_3":
return month >= 7 && month <= 9;
case "trimestre_4":
return month >= 10 && month <= 12;
default:
// Pour les mois individuels (mois_1, mois_2, etc.)
if (filters.period.startsWith("mois_")) {
const targetMonth = parseInt(filters.period.split("_")[1]!, 10);
return month === targetMonth;
}
return true;
}
});
}
// 3. Filtrage par recherche textuelle
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
result = result.filter((item: VirementItem) =>
(item.callsheet || item.num_appel || "").toLowerCase().includes(query) ||
(item.periode_label || item.periode || "").toLowerCase().includes(query)
);
}
return result;
}, [items, searchQuery, filters.period, filters.year]);
return (
<div className="space-y-5">
{/* En-tête + Recherche */}
<section className="rounded-2xl border bg-white p-4">
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div>
<h1 className="text-xl font-semibold">Virements de salaires</h1>
<p className="text-sm text-slate-600 mt-1">
Suivez vos appels à virement, l'état des virements reçus et les salaires payés.
</p>
</div>
{isOdentas && (
<div className="sm:ml-auto flex items-center gap-2 w-full sm:w-auto">
<div className="flex items-center gap-2 px-3 py-2 rounded-xl border w-full sm:w-80">
<Search className="w-4 h-4" />
<input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="N° appel, période…"
className="bg-transparent outline-none text-sm flex-1"
/>
</div>
</div>
)}
</div>
{/* 🎭 Message informatif en mode démo */}
{isDemoMode && (
<div className="mt-4 rounded-xl border-2 border-amber-200 bg-gradient-to-r from-amber-50 to-orange-50 p-3 shadow-sm">
<div className="flex items-center gap-3">
<div className="rounded-lg bg-amber-100 p-2">
<Info className="h-5 w-5 text-amber-700" />
</div>
<h3 className="font-semibold text-amber-900">Mode démonstration</h3>
</div>
</div>
)}
{/* Filtres de période */}
<div className="mt-4 flex flex-col sm:flex-row sm:items-stretch gap-3">
<div className="flex items-center gap-2">
<label className="text-sm font-medium">Année :</label>
<select
className="px-3 py-2 rounded-lg border bg-white text-sm"
value={filters.year}
onChange={(e) => setFilters(f => ({ ...f, year: parseInt(e.target.value, 10) }))}
>
{years.map(y => (
<option key={y} value={y}>{y}</option>
))}
</select>
</div>
{/* Sélecteur d'organisation (visible uniquement par le staff) */}
{userInfo?.isStaff && (
<div className="flex items-center gap-2">
<label className="text-sm font-medium">Organisation :</label>
<select
className="px-3 py-2 rounded-lg border bg-white text-sm min-w-[200px]"
value={selectedOrgId}
onChange={(e) => setSelectedOrgId(e.target.value)}
disabled={!organizations || organizations.length === 0}
>
<option value="">
{!organizations || organizations.length === 0
? "Chargement..."
: "Toutes les organisations"}
</option>
{organizations && organizations.map((org: any) => (
<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>
)}
{isOdentas && (
<div className="flex items-center gap-2">
<label className="text-sm font-medium">Période :</label>
<select
className="px-3 py-2 rounded-lg border bg-white text-sm"
value={filters.period}
onChange={(e) => setFilters(f => ({ ...f, period: e.target.value as PeriodKey }))}
>
<option value="toute_annee">Toute l'année</option>
<optgroup label="Semestres">
<option value="premier_semestre">Premier semestre</option>
<option value="second_semestre">Second semestre</option>
</optgroup>
<optgroup label="Trimestres">
<option value="trimestre_1">Trimestre 1</option>
<option value="trimestre_2">Trimestre 2</option>
<option value="trimestre_3">Trimestre 3</option>
<option value="trimestre_4">Trimestre 4</option>
</optgroup>
<optgroup label="Mois">
{[...Array(12)].map((_, i) => (
<option key={i + 1} value={`mois_${i + 1}` as PeriodKey}>
{new Date(2023, i).toLocaleDateString('fr-FR', { month: 'long' })}
</option>
))}
</optgroup>
</select>
</div>
)}
<div className="sm:ml-auto min-w-[280px] max-w-sm flex-1">
<div className="h-full rounded-xl border bg-white p-3">
<div className="flex items-start justify-between">
<div>
<div className="text-sm font-semibold">Gestion des virements</div>
<div className="text-xs text-slate-600">Les virements de salaires sont effectués par</div>
</div>
<div className="flex items-center gap-2">
<div className="relative group inline-block">
<button
type="button"
disabled
aria-disabled="true"
className="text-xs px-2 py-1 rounded-md border opacity-60 cursor-not-allowed"
>
Modifier
</button>
<div
role="tooltip"
className="pointer-events-none absolute right-0 mt-2 w-64 px-3 py-2 rounded-lg bg-slate-900 text-white text-xs shadow-lg opacity-0 group-hover:opacity-100 translate-y-1 group-hover:translate-y-0 transition"
>
Bientôt disponible, veuillez nous contacter.
<div className="absolute -top-1 right-6 w-2 h-2 rotate-45 bg-slate-900" />
</div>
</div>
</div>
</div>
<div className="mt-3 flex items-center justify-between gap-2">
<div className={`inline-flex items-center gap-2 px-3 py-2 rounded-lg border text-sm ${loadingOrg ? 'bg-slate-50 border-slate-200 text-slate-400' : (isOdentas ? 'bg-emerald-50 border-emerald-200 text-emerald-700' : 'bg-slate-50 border-slate-200 text-slate-700')}`}>
<span className="inline-block w-2 h-2 rounded-full" style={{ backgroundColor: isOdentas ? '#10b981' : '#64748b', opacity: loadingOrg ? 0.6 : 1 }} />
<span className="font-medium">{gestionLabel}</span>
</div>
<button
type="button"
onClick={() => !loadingOrg && setAboutOpen(true)}
disabled={loadingOrg}
className={`inline-flex items-center gap-1 text-xs px-2 py-1 rounded-md border transition-colors ${loadingOrg ? 'opacity-50 cursor-not-allowed' : 'hover:bg-slate-50'}`}
aria-label="En savoir plus sur les virements"
>
<Info className="w-3.5 h-3.5" />
En savoir plus
</button>
</div>
</div>
</div>
</div>
{/* Card informative SEPA pour clients non-Odentas */}
{!isOdentas && (
<div className="mt-4 rounded-xl border border-blue-200 bg-blue-50 p-4">
<div className="flex items-start gap-3">
<div className="rounded-lg bg-blue-100 p-2 mt-0.5">
<Info className="h-4 w-4 text-blue-700" />
</div>
<div className="flex-1">
<p className="text-sm text-blue-900 mb-2">
<span className="font-semibold">Nouveau : Export SEPA</span> — Générez un fichier XML (norme ISO 20022) pour effectuer vos virements salaires groupés depuis votre banque, si celle-ci le permet. Il vous suffit de cocher les paies concernées.
</p>
<p className="text-sm text-blue-900">
Vous pouvez également marquer plusieurs salaires comme payés en une seule fois en sélectionnant les paies et en cliquant sur Marquer comme payé.
</p>
</div>
</div>
</div>
)}
</section>
{/* Tableau principal */}
{isOdentas ? (
<section className="rounded-2xl border bg-white">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-slate-50/80">
<Th>Période</Th>
<Th>N° d'appel</Th>
<Th>Date d'appel</Th>
<Th className="text-right">Salaires nets</Th>
<Th className="text-center">Virement reçu</Th>
<Th className="text-center">Salaires payés</Th>
<Th className="text-center">Document</Th>
</tr>
</thead>
<tbody>
{isLoading ? (
<tr>
<td colSpan={7} className="py-12 text-center text-slate-500">
<Loader2 className="w-4 h-4 inline animate-spin mr-2" />
Chargement…
</td>
</tr>
) : isError ? (
<tr>
<td colSpan={7} className="py-12 text-center text-rose-500">
Erreur : {(error as any)?.message || 'imprévue'}
</td>
</tr>
) : filteredItems.length === 0 ? (
<tr>
<td colSpan={7} className="py-12 text-center text-slate-500">
{searchQuery ? 'Aucun virement trouvé pour cette recherche.' : 'Aucun virement de salaires trouvé.'}
</td>
</tr>
) : (
filteredItems.map((row: VirementItem) => (
<tr key={row.id} className="border-b last:border-b-0 hover:bg-slate-50/50">
<Td>{row.periode_label || formatPeriode(row.periode) || "—"}</Td>
<Td>
<span className="font-medium">
{row.num_appel || row.callsheet || "—"}
</span>
</Td>
<Td>{formatFR(row.date_mois || row.date || "")}</Td>
<Td className="text-right font-medium">
{formatCurrency(row.total_salaries_eur ?? row.total)}
</Td>
<Td className="text-center">
<StatusBadge status={row.virement_recu} date={row.virement_recu_date} />
</Td>
<Td className="text-center">
<StatusBadge status={row.salaires_payes} />
</Td>
<Td className="text-center">
{row.pdf_url ? (
<button
onClick={() => {
setPdfUrl(row.pdf_url!);
setPdfModalOpen(true);
}}
className="inline-flex items-center gap-1 px-2 py-1 rounded-lg bg-pink-100 text-pink-700 hover:bg-pink-200 transition-colors text-xs"
>
<Download className="w-3 h-3" />
PDF
</button>
) : (
<span className="text-slate-400">—</span>
)}
</Td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Footer avec indicateur de chargement */}
{filteredItems.length > 0 && (
<div className="p-3 border-t text-right">
<div className="text-sm text-slate-500">
{isFetching ? (
<span className="inline-flex items-center gap-1">
<Loader2 className="w-3 h-3 animate-spin" />
Mise à jour…
</span>
) : (
`${filteredItems.length} appel${filteredItems.length > 1 ? 's' : ''} à virement${filteredItems.length > 1 ? 's' : ''} affiché${filteredItems.length > 1 ? 's' : ''}`
)}
</div>
</div>
)}
</section>
) : (
<section className="rounded-2xl border bg-white">
{/* Bouton export SEPA si des payslips sont sélectionnés */}
{selectedPayslips.size > 0 && (
<div className="border-b bg-blue-50 px-4 py-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="text-sm font-medium text-blue-900">
{selectedPayslips.size} paie{selectedPayslips.size > 1 ? 's' : ''} sélectionnée{selectedPayslips.size > 1 ? 's' : ''}
</div>
<button
onClick={() => setSelectedPayslips(new Set())}
className="text-xs text-blue-600 hover:text-blue-800 underline"
>
Tout décocher
</button>
</div>
<div className="flex items-center gap-3">
<button
onClick={handleBulkMarkPaid}
disabled={isMarkingPaid}
className="inline-flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Check className="w-4 h-4" />
{isMarkingPaid ? 'Marquage en cours...' : 'Marquer comme payé'}
</button>
<button
onClick={handleExportSepa}
disabled={isExporting}
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Download className="w-4 h-4" />
{isExporting ? 'Export en cours...' : 'Exporter fichier bancaire (SEPA)'}
</button>
</div>
</div>
</div>
)}
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-slate-50/80">
<Th className="w-12">
<input
type="checkbox"
checked={selectedPayslips.size > 0 && selectedPayslips.size === clientUnpaid.filter(it => it.source === 'payslip').length}
onChange={toggleSelectAll}
className="rounded"
title="Tout sélectionner / Tout décocher"
/>
</Th>
<Th>Salarié·e</Th>
<Th>Contrat</Th>
<Th>Profession</Th>
<Th>Début contrat</Th>
<Th>Fin contrat</Th>
<Th>Période concernée</Th>
<Th className="text-right">Net à payer</Th>
<Th className="text-center">Marquer comme payé</Th>
</tr>
{/* Sous-header avec le total des nets à payer (salaires non payés) */}
{!isLoading && !isError && clientUnpaid.length > 0 && (
<tr className="bg-gradient-to-r from-indigo-50 to-purple-50 border-b-2 border-indigo-200">
<th colSpan={7} className="px-4 py-3 text-left">
<div className="flex items-center gap-2">
<div className="text-sm font-semibold text-indigo-900">
Salaires à payer
</div>
<div className="text-xs text-indigo-700">
({clientUnpaid.length} salarié{clientUnpaid.length > 1 ? 's' : ''})
</div>
</div>
</th>
<th colSpan={2} className="px-4 py-3 text-right">
<div className="text-sm font-bold text-indigo-900">
Total : {formatCurrency(totalNetAPayer)}
</div>
</th>
</tr>
)}
</thead>
<tbody>
{/* Unpaid first */}
{isLoading ? (
<tr><td colSpan={8} className="py-12 text-center text-slate-500"><Loader2 className="w-4 h-4 inline animate-spin mr-2"/> Chargement</td></tr>
) : isError ? (
<tr><td colSpan={8} className="py-12 text-center text-rose-500">Erreur : {(error as any)?.message || 'imprévue'}</td></tr>
) : (clientUnpaid.length === 0 && clientRecent.length === 0) ? (
<tr><td colSpan={9} className="py-12 text-center text-slate-500">Aucun virement de salaires trouvé.</td></tr>
) : (
<>
{clientUnpaid.map((it) => (
<tr key={`unpaid-${it.source}-${it.id}`} className="border-b last:border-b-0 hover:bg-slate-50/50">
<Td>
{it.source === 'payslip' ? (
<input
type="checkbox"
checked={selectedPayslips.has(it.id)}
onChange={() => togglePayslipSelection(it.id)}
className="rounded"
/>
) : (
<div className="w-4"></div>
)}
</Td>
<Td>
{it.salarie_matricule ? (
<a href={`/salaries/${encodeURIComponent(it.salarie_matricule)}`} target="_blank" rel="noreferrer" className="text-blue-600 hover:underline font-medium">
{it.salarie || it.salarie_matricule}
</a>
) : (
it.salarie || '—'
)}
</Td>
<Td>
{it.contract_id && it.reference ? (
<a
href={it.kind === 'RG' ? `/contrats-rg/${encodeURIComponent(it.contract_id)}` : (it.kind === 'CDDU_MULTI' ? `/contrats-multi/${encodeURIComponent(it.contract_id)}` : `/contrats/${encodeURIComponent(it.contract_id)}`)}
target="_blank"
rel="noreferrer"
className="text-blue-600 hover:underline font-medium"
>
{it.reference}
</a>
) : (
<span className="font-medium">{it.reference || '—'}</span>
)}
</Td>
<Td>{it.profession || '—'}</Td>
<Td>{formatFR(it.date_debut || '')}</Td>
<Td>{maskEndDate(it.date_fin || '')}</Td>
<Td>{formatPeriode(it.periode)}</Td>
<Td className="text-right font-medium">{it.net_a_payer != null ? formatCurrency(it.net_a_payer) : '—'}</Td>
<Td className="text-center">
{it.source === 'payslip' ? (
<button
type="button"
onClick={() => markPayslipDone(it.id)}
className="px-2 py-1 text-xs rounded-md border hover:bg-emerald-50 hover:border-emerald-300 text-emerald-700"
title="Marquer le virement comme effectué"
>
Marquer
</button>
) : (
<button type="button" disabled className="px-2 py-1 text-xs rounded-md border opacity-60 cursor-not-allowed" title="Non disponible">Marquer</button>
)}
</Td>
</tr>
))}
{clientRecent.length > 0 && (
<>
{/* Séparation visuelle renforcée */}
<tr>
<td colSpan={8} className="h-4 bg-slate-100"></td>
</tr>
<tr className="bg-gradient-to-r from-emerald-50 to-green-50 border-y-2 border-emerald-200">
<td colSpan={8} className="px-4 py-3">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-emerald-100">
<Check className="w-5 h-5 text-emerald-700" />
</div>
<div>
<div className="font-semibold text-emerald-900">Virements récemment effectués</div>
<div className="text-xs text-emerald-700">Paies virées au cours des 30 derniers jours</div>
<div className="text-xs text-emerald-600 italic mt-1">
Si vous avez noté une paie comme payée par erreur, cliquez sur "Oui" pour la noter comme non-payée.
</div>
</div>
</div>
</td>
</tr>
</>
)}
{clientRecent.map((it) => (
<tr key={`recent-${it.source}-${it.id}`} className="border-b last:border-b-0 hover:bg-slate-50/50">
<Td><span></span></Td>
<Td>
{it.salarie_matricule ? (
<a href={`/salaries/${encodeURIComponent(it.salarie_matricule)}`} target="_blank" rel="noreferrer" className="text-blue-600 hover:underline font-medium">
{it.salarie || it.salarie_matricule}
</a>
) : (
it.salarie || '—'
)}
</Td>
<Td>
{it.contract_id && it.reference ? (
<a
href={it.kind === 'RG' ? `/contrats-rg/${encodeURIComponent(it.contract_id)}` : (it.kind === 'CDDU_MULTI' ? `/contrats-multi/${encodeURIComponent(it.contract_id)}` : `/contrats/${encodeURIComponent(it.contract_id)}`)}
target="_blank"
rel="noreferrer"
className="text-blue-600 hover:underline font-medium"
>
{it.reference}
</a>
) : (
<span className="font-medium">{it.reference || '—'}</span>
)}
</Td>
<Td>{it.profession || '—'}</Td>
<Td>{formatFR(it.date_debut || '')}</Td>
<Td>{maskEndDate(it.date_fin || '')}</Td>
<Td>{formatPeriode(it.periode)}</Td>
<Td className="text-right font-medium">{it.net_a_payer != null ? formatCurrency(it.net_a_payer) : '—'}</Td>
<Td className="text-center">
{it.source === 'payslip' ? (
<button
type="button"
onClick={() => openUndoModal(it.id)}
className="inline-flex items-center px-2 py-1 rounded-full text-xs bg-emerald-100 text-emerald-800 hover:bg-emerald-200 transition-colors cursor-pointer"
title="Cliquez pour annuler le marquage de ce virement"
>
Oui
</button>
) : (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs bg-emerald-100 text-emerald-800">Oui</span>
)}
</Td>
</tr>
))}
</>
)}
</tbody>
</table>
</div>
</section>
)}
{aboutOpen && (
<div className="fixed inset-0 z-[1000]">
<div
className="absolute inset-0 bg-black/40"
onClick={() => setAboutOpen(false)}
/>
<div className="absolute inset-0 flex items-center justify-center p-4">
<div role="dialog" aria-modal="true" aria-labelledby="about-virements-title" className="w-full max-w-lg rounded-2xl border bg-white shadow-xl">
<div className="p-5 border-b flex items-start justify-between">
<div>
<h2 id="about-virements-title" className="text-base font-semibold">Gestion des virements de salaires</h2>
</div>
<button onClick={() => setAboutOpen(false)} className="text-slate-500 hover:text-slate-900"></button>
</div>
<div className="p-5 space-y-4 text-sm">
{/* Card informative pour tous les clients */}
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
<div className="flex items-start gap-3">
<div className="rounded-lg bg-blue-100 p-2 mt-0.5">
<Info className="h-4 w-4 text-blue-700" />
</div>
<div className="flex-1">
<p className="text-sm text-blue-900">
Les informations ci-dessous ne concernent que les clients ayant confié la gestion de leurs virements de salaires à Odentas. Vous pouvez nous confier cette gestion sans surcoût, contactez-nous pour plus d'informations.
</p>
</div>
</div>
</div>
<p>
En fin de mois, nous vous envoyons un <strong>appel à virement</strong> avec le <strong>total des salaires nets</strong> de la période concernée.
</p>
<p>
Dès réception de votre virement, nous <strong>redistribuons les salaires à vos salariés</strong>. Vous pouvez suivre létat dans le tableau ci-dessous (colonnes « Virement reçu » et « Salaires payés »).
</p>
<div className="rounded-lg border p-3">
<div className="text-xs uppercase tracking-wide text-slate-500 mb-2">Coordonnées bancaires (salaires)</div>
{isDemoMode ? (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-center rounded-md bg-slate-50 px-4 py-8">
<div className="text-center">
<div className="text-sm text-slate-600 mb-1">Mode démonstration</div>
<div className="text-xs text-slate-500">Les coordonnées bancaires sont masquées</div>
</div>
</div>
<p className="text-xs text-slate-500 mt-2">Ce compte bancaire est réservé à la réception des virements de salaires.</p>
</div>
) : (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2 rounded-md bg-slate-50 px-2 py-2">
<div>
<div className="text-[11px] text-slate-500">Bénéficiaire</div>
<div className="font-mono text-xs break-all">ODENTAS MEDIA SAS</div>
</div>
<button
type="button"
onClick={() => { navigator.clipboard?.writeText('ODENTAS MEDIA SAS').then(()=>{ setCopiedField('benef'); setTimeout(()=>setCopiedField(null), 1400); }); }}
className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-md border hover:bg-slate-100"
aria-label="Copier le bénéficiaire"
>
{copiedField === 'benef' ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />} Copier
</button>
</div>
<div className="flex items-center justify-between gap-2 rounded-md bg-slate-50 px-2 py-2">
<div>
<div className="text-[11px] text-slate-500">IBAN</div>
<div className="font-mono text-xs break-all">FR76 1695 8000 0141 0850 9729 813</div>
</div>
{org?.iban && (
<button
type="button"
onClick={() => { navigator.clipboard?.writeText("FR76 1695 8000 0141 0850 9729 813" as string).then(()=>{ setCopiedField('iban'); setTimeout(()=>setCopiedField(null), 1400); }); }}
className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-md border hover:bg-slate-100"
aria-label="Copier lIBAN"
>
{copiedField === 'iban' ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />} Copier
</button>
)}
</div>
<div className="flex items-center justify-between gap-2 rounded-md bg-slate-50 px-2 py-2">
<div>
<div className="text-[11px] text-slate-500">BIC</div>
<div className="font-mono text-xs break-all">QNTOFRP1XXX</div>
</div>
{org?.bic && (
<button
type="button"
onClick={() => { navigator.clipboard?.writeText("QNTOFRP1XXX" as string).then(()=>{ setCopiedField('bic'); setTimeout(()=>setCopiedField(null), 1400); }); }}
className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-md border hover:bg-slate-100"
aria-label="Copier le BIC"
>
{copiedField === 'bic' ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />} Copier
</button>
)}
</div>
<p className="text-xs text-slate-500 mt-2">Ce compte bancaire est réservé à la réception des virements de salaires.</p>
</div>
)}
</div>
</div>
<div className="p-4 border-t flex justify-end">
<button onClick={() => setAboutOpen(false)} className="px-3 py-2 rounded-md border hover:bg-slate-50 text-sm">Fermer</button>
</div>
</div>
</div>
</div>
)}
{/* Modal PDF */}
{pdfModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="bg-white rounded-lg shadow-xl w-full max-w-5xl h-[90vh] flex flex-col">
<div className="flex items-center justify-between p-4 border-b">
<h3 className="text-lg font-semibold">Appel à virement - PDF</h3>
<div className="flex items-center gap-2">
<a
href={pdfUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-2 px-3 py-2 text-sm rounded-lg bg-pink-100 text-pink-700 hover:bg-pink-200 transition-colors"
>
<ExternalLink className="w-4 h-4" />
Ouvrir dans un nouvel onglet
</a>
<button
onClick={() => setPdfModalOpen(false)}
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
aria-label="Fermer"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
<div className="flex-1 overflow-hidden bg-slate-100">
<iframe
src={`https://docs.google.com/viewer?url=${encodeURIComponent(pdfUrl)}&embedded=true`}
className="w-full h-full"
title="PDF Appel à virement"
/>
</div>
</div>
</div>
)}
{/* Modal de confirmation pour le marquage groupé */}
{showBulkMarkPaidModal && (
<div className="fixed inset-0 z-[1000]">
<div
className="absolute inset-0 bg-black/40"
onClick={() => setShowBulkMarkPaidModal(false)}
/>
<div className="absolute inset-0 flex items-center justify-center p-4">
<div role="dialog" aria-modal="true" className="w-full max-w-md rounded-2xl border bg-white shadow-xl">
<div className="p-5 border-b bg-green-50">
<div className="flex items-center gap-3">
<div className="rounded-lg bg-green-100 p-2">
<Check className="h-5 w-5 text-green-700" />
</div>
<h2 className="text-base font-semibold text-green-900">Marquer comme payé</h2>
</div>
</div>
<div className="p-5 space-y-4 text-sm">
<p className="text-slate-700">
Voulez-vous vraiment marquer <strong>{selectedPayslips.size} paie{selectedPayslips.size > 1 ? 's' : ''}</strong> comme payée{selectedPayslips.size > 1 ? 's' : ''} ?
</p>
<p className="text-slate-600">
En cas d'erreur, vous pourrez marquer la paie comme non payée à tout moment.
</p>
</div>
<div className="p-4 border-t flex justify-end gap-2">
<button
onClick={() => setShowBulkMarkPaidModal(false)}
className="px-4 py-2 rounded-lg border hover:bg-slate-50 text-sm transition-colors"
>
Annuler
</button>
<button
onClick={confirmBulkMarkPaid}
disabled={isMarkingPaid}
className="px-4 py-2 rounded-lg bg-green-600 text-white hover:bg-green-700 text-sm transition-colors disabled:opacity-50"
>
{isMarkingPaid ? 'Marquage en cours...' : 'Confirmer'}
</button>
</div>
</div>
</div>
</div>
)}
{/* Modal de confirmation pour annuler le marquage */}
{undoModalOpen && undoPayslipId && (
<div className="fixed inset-0 z-[1000]">
<div
className="absolute inset-0 bg-black/40"
onClick={() => {
setUndoModalOpen(false);
setUndoPayslipId(null);
}}
/>
<div className="absolute inset-0 flex items-center justify-center p-4">
<div role="dialog" aria-modal="true" className="w-full max-w-md rounded-2xl border bg-white shadow-xl">
<div className="p-5 border-b">
<h2 className="text-base font-semibold">Annuler le marquage du virement</h2>
</div>
<div className="p-5 space-y-4 text-sm">
<p>
Êtes-vous sûr de vouloir marquer cette paie comme <strong>non payée</strong> ?
</p>
<p className="text-slate-600">
Elle réapparaîtra dans la liste des paies à payer.
</p>
</div>
<div className="p-4 border-t flex justify-end gap-2">
<button
onClick={() => {
setUndoModalOpen(false);
setUndoPayslipId(null);
}}
className="px-3 py-2 rounded-md border hover:bg-slate-50 text-sm"
>
Annuler
</button>
<button
onClick={() => markPayslipUndone(undoPayslipId)}
className="px-3 py-2 rounded-md bg-orange-600 text-white hover:bg-orange-700 text-sm"
>
Confirmer
</button>
</div>
</div>
</div>
</div>
)}
{/* Modal d'avertissement pour les salariés sans IBAN */}
{showExportModal && (
<div className="fixed inset-0 z-[1000]">
<div
className="absolute inset-0 bg-black/40"
onClick={() => {
setShowExportModal(false);
setExportSuccess(null);
setExportWarnings([]);
}}
/>
<div className="absolute inset-0 flex items-center justify-center p-4">
<div role="dialog" aria-modal="true" className="w-full max-w-lg rounded-2xl border bg-white shadow-xl">
{/* Header avec succès ou erreur */}
{exportSuccess ? (
<div className="p-5 border-b bg-green-50">
<div className="flex items-center gap-3">
<div className="rounded-lg bg-green-100 p-2">
<Check className="h-5 w-5 text-green-700" />
</div>
<div>
<h2 className="text-base font-semibold text-green-900">Fichier SEPA généré avec succès !</h2>
<p className="text-sm text-green-700">Le téléchargement a démarré automatiquement</p>
</div>
</div>
</div>
) : (
<div className="p-5 border-b bg-red-50">
<div className="flex items-center gap-3">
<div className="rounded-lg bg-red-100 p-2">
<X className="h-5 w-5 text-red-700" />
</div>
<h2 className="text-base font-semibold text-red-900">Export impossible</h2>
</div>
</div>
)}
<div className="p-5 space-y-4 text-sm">
{/* Résumé de l'export si succès */}
{exportSuccess && (
<div className="bg-slate-50 rounded-lg p-4 space-y-2">
<div className="flex justify-between items-center">
<span className="text-slate-600">Nombre de virements :</span>
<span className="font-semibold text-slate-900">{exportSuccess.numberOfTransactions}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-slate-600">Montant total :</span>
<span className="font-semibold text-slate-900">{exportSuccess.totalAmount} </span>
</div>
{exportSuccess.excludedPayments > 0 && (
<>
<div className="border-t border-slate-200 my-2"></div>
<div className="flex justify-between items-center text-amber-700">
<span>Salariés inclus :</span>
<span className="font-semibold">{exportSuccess.includedPayments}</span>
</div>
<div className="flex justify-between items-center text-amber-700">
<span>Salariés exclus (sans RIB) :</span>
<span className="font-semibold">{exportSuccess.excludedPayments}</span>
</div>
</>
)}
</div>
)}
{/* Liste des salariés sans RIB */}
{exportWarnings.length > 0 && (
<>
<div className={`${exportSuccess ? 'border-t border-slate-200 pt-4' : ''}`}>
<p className="text-slate-700 font-medium mb-2">
{exportSuccess ? 'Salariés exclus (sans RIB) :' : 'Tous les salariés sélectionnés n\'ont pas de RIB :'}
</p>
<ul className="list-disc list-inside space-y-1 text-slate-600 bg-amber-50 rounded-lg p-3 border border-amber-200">
{exportWarnings.map((name, index) => (
<li key={index}>{name}</li>
))}
</ul>
<p className="text-slate-600 mt-3">
Ces salariés doivent <strong>envoyer leur RIB depuis leur Espace Transat</strong> pour pouvoir être inclus dans les prochains exports de virements.
</p>
</div>
</>
)}
</div>
<div className="p-4 border-t flex justify-end">
<button
onClick={() => {
setShowExportModal(false);
setExportSuccess(null);
setExportWarnings([]);
}}
className="px-4 py-2 rounded-lg bg-blue-600 text-white hover:bg-blue-700 text-sm"
>
{exportSuccess ? 'Fermer' : 'J\'ai compris'}
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
}
// --- Composants utilitaires ---
function Th({ children, className = "" }: { children: React.ReactNode; className?: string }) {
return (
<th className={`text-left font-medium px-3 py-3 ${className}`}>
{children}
</th>
);
}
function Td({ children, className = "" }: { children: React.ReactNode; className?: string }) {
return (
<td className={`px-3 py-3 ${className}`}>
{children}
</td>
);
}
function StatusBadge({ status, date }: { status?: boolean | string; date?: string | null }) {
const val = toBoolish(status);
if (val === true) {
return (
<div className="flex flex-col items-center gap-1">
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs bg-emerald-100 text-emerald-800">
Oui
</span>
{date && (
<span className="text-[10px] text-slate-500">
{formatFR(date)}
</span>
)}
</div>
);
} else if (val === false) {
return (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs bg-slate-100 text-slate-600">
Non
</span>
);
} else {
return <span className="text-slate-400"></span>;
}
}