- Créer hook useStaffOrgSelection avec persistence localStorage - Ajouter badge StaffOrgBadge dans Sidebar - Synchroniser filtres org dans toutes les pages (contrats, cotisations, facturation, etc.) - Fix calcul cachets: utiliser totalQuantities au lieu de dates.length - Fix structure field bug: ne plus écraser avec production_name - Ajouter création note lors modification contrat - Implémenter montants personnalisés pour virements salaires - Migrations SQL: custom_amount + fix_structure_field - Réorganiser boutons ContractEditor en carte flottante droite
1762 lines
73 KiB
TypeScript
1762 lines
73 KiB
TypeScript
// 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";
|
||
import { useStaffOrgSelection } from "@/hooks/useStaffOrgSelection";
|
||
|
||
// --- 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();
|
||
|
||
// Helper pour valider les UUIDs
|
||
const isValidUUID = (str: string | null): boolean => {
|
||
if (!str) return false;
|
||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(str);
|
||
};
|
||
|
||
// Zustand store pour la sélection d'organisation (staff)
|
||
const {
|
||
selectedOrgId: globalSelectedOrgId,
|
||
setSelectedOrg: setGlobalSelectedOrg
|
||
} = useStaffOrgSelection();
|
||
|
||
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);
|
||
// État local initialisé avec la valeur globale si elle est un UUID valide
|
||
const [selectedOrgId, setSelectedOrgId] = useState<string>(
|
||
isValidUUID(globalSelectedOrgId) ? globalSelectedOrgId : ""
|
||
);
|
||
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 [showChangeGestionModal, setShowChangeGestionModal] = useState(false);
|
||
const [isChangingGestion, setIsChangingGestion] = useState(false);
|
||
|
||
const { data: userInfo, isLoading: isLoadingUser } = useUserInfo();
|
||
const { data: organizations, isLoading: isLoadingOrgs, error: orgsError } = useOrganizations();
|
||
const queryClient = useQueryClient();
|
||
|
||
// Synchronisation bidirectionnelle : global → local
|
||
useEffect(() => {
|
||
if (userInfo?.isStaff && isValidUUID(globalSelectedOrgId)) {
|
||
setSelectedOrgId(globalSelectedOrgId);
|
||
}
|
||
}, [globalSelectedOrgId, userInfo?.isStaff]);
|
||
|
||
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);
|
||
}
|
||
}
|
||
|
||
// Changement de gestion des virements
|
||
async function handleChangeGestion() {
|
||
if (!org || !selectedOrgId) return;
|
||
setShowChangeGestionModal(false);
|
||
setIsChangingGestion(true);
|
||
|
||
try {
|
||
const currentMode = isOdentas ? 'odentas' : 'client';
|
||
const newMode = isOdentas ? 'client' : 'odentas';
|
||
|
||
const response = await fetch('/api/virements-salaires/change-gestion', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
credentials: 'include',
|
||
body: JSON.stringify({
|
||
organizationId: selectedOrgId,
|
||
newMode
|
||
})
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.message || 'Erreur lors du changement de gestion');
|
||
}
|
||
|
||
// Recharger les données
|
||
await queryClient.invalidateQueries({ queryKey: ["virements-salaires"] });
|
||
|
||
} catch (error) {
|
||
console.error('Erreur changement gestion:', error);
|
||
alert(error instanceof Error ? error.message : 'Erreur lors du changement de gestion');
|
||
} finally {
|
||
setIsChangingGestion(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) => {
|
||
const newOrgId = e.target.value;
|
||
setSelectedOrgId(newOrgId);
|
||
|
||
// Synchronisation bidirectionnelle : local → global
|
||
if (newOrgId) {
|
||
const selectedOrg = organizations?.find((org: any) => org.id === newOrgId);
|
||
if (selectedOrg) {
|
||
setGlobalSelectedOrg(newOrgId, selectedOrg.name);
|
||
}
|
||
} else {
|
||
setGlobalSelectedOrg(null, null);
|
||
}
|
||
}}
|
||
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">
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowChangeGestionModal(true)}
|
||
disabled={loadingOrg || isChangingGestion}
|
||
className="text-xs px-2 py-1 rounded-md border hover:bg-slate-50 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||
>
|
||
{isChangingGestion ? 'Modification...' : 'Modifier'}
|
||
</button>
|
||
</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>
|
||
|
||
</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>
|
||
<div className="relative group">
|
||
<button
|
||
disabled
|
||
className="inline-flex items-center gap-2 px-4 py-2 bg-slate-300 text-slate-500 rounded-lg cursor-not-allowed opacity-60"
|
||
>
|
||
<Download className="w-4 h-4" />
|
||
Exporter fichier bancaire (SEPA)
|
||
</button>
|
||
<div
|
||
role="tooltip"
|
||
className="pointer-events-none absolute right-0 top-full mt-2 w-48 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 z-10"
|
||
>
|
||
Bientôt disponible
|
||
<div className="absolute -top-1 right-6 w-2 h-2 rotate-45 bg-slate-900" />
|
||
</div>
|
||
</div>
|
||
</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 l’IBAN"
|
||
>
|
||
{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 de changement de gestion des virements */}
|
||
{showChangeGestionModal && (
|
||
<div className="fixed inset-0 z-[1000]">
|
||
<div
|
||
className="absolute inset-0 bg-black/40"
|
||
onClick={() => setShowChangeGestionModal(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-blue-50">
|
||
<div className="flex items-center gap-3">
|
||
<div className="rounded-lg bg-blue-100 p-2">
|
||
<Info className="h-5 w-5 text-blue-700" />
|
||
</div>
|
||
<h2 className="text-base font-semibold text-blue-900">
|
||
Changement de gestion des virements
|
||
</h2>
|
||
</div>
|
||
</div>
|
||
<div className="p-5 space-y-4 text-sm">
|
||
{isOdentas ? (
|
||
<>
|
||
<p className="text-slate-700">
|
||
Souhaitez-vous reprendre la <strong>gestion de vos virements de salaires</strong> ?
|
||
</p>
|
||
<p className="text-slate-600">
|
||
Vous pourrez effectuer vous-même les virements via votre banque.
|
||
</p>
|
||
<div className="rounded-lg bg-green-50 border border-green-200 p-3">
|
||
<p className="text-sm text-green-900">
|
||
<strong>Aucune modification tarifaire</strong> - Ce changement n'impacte pas votre facturation.
|
||
</p>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<>
|
||
<p className="text-slate-700">
|
||
Souhaitez-vous <strong>confier la gestion de vos virements de salaires à Odentas</strong> ?
|
||
</p>
|
||
<p className="text-slate-600">
|
||
Odentas s'occupera de redistribuer les salaires à vos salariés après réception de votre virement mensuel.
|
||
</p>
|
||
<div className="rounded-lg bg-green-50 border border-green-200 p-3">
|
||
<p className="text-sm text-green-900">
|
||
<strong>Service sans surcoût</strong> - Ce changement n'impacte pas votre facturation.
|
||
</p>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
<div className="p-4 border-t flex justify-end gap-2">
|
||
<button
|
||
onClick={() => setShowChangeGestionModal(false)}
|
||
className="px-4 py-2 rounded-lg border hover:bg-slate-50 text-sm transition-colors"
|
||
>
|
||
Annuler
|
||
</button>
|
||
<button
|
||
onClick={handleChangeGestion}
|
||
disabled={isChangingGestion}
|
||
className="px-4 py-2 rounded-lg bg-blue-600 text-white hover:bg-blue-700 text-sm transition-colors disabled:opacity-50"
|
||
>
|
||
{isChangingGestion ? 'Modification en cours...' : 'Confirmer'}
|
||
</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>;
|
||
}
|
||
}
|