Files
NDF/ndf/src/pages/VerificateurFinanceLight.tsx
T

1068 lines
89 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.
import { useState, useEffect } from 'react';
import {
Eye, ChevronDown, ChevronUp, Clock, CheckCircle, XCircle, History, FileText,
AlertTriangle, Check, X, ShieldCheck, Paperclip, ListChecks, Receipt
} from 'lucide-react';
interface Fichier { fileName: string; uploadUrl: string; }
interface Participant { nom: string; prenom: string; societe?: string; }
interface TvaItem { taux: string; montantTTC: string; }
interface LigneDepense {
qrNoteRef?: string; categorie?: string; libelle?: string;
date?: string; montant?: string; km?: string; chevaux?: string;
tauxTVA?: string; description?: string;
nombreParticipants?: string; participants?: Participant[];
tvaItems?: TvaItem[];
qrFiles?: { fileName: string; uploadUrl: string; origin?: string }[];
}
interface NonConforme { fileName: string; motif: string; statut: string; dateSignalement: string; }
interface NoteAVerifier {
id: number; reference?: string; libelle?: string; collaborateur?: string;
campus?: string; departement?: string; date?: string; montant?: number;
statut?: string; lignesJson?: string; fichiers?: string;
nonConformes?: NonConforme[];
lignesRefusees?: { index: number; motif: string }[];
}
interface ModalNok { noteId: number; ligneIndex: number; ligneLabel: string; }
interface VerifHistorique {
id: number; reference?: string; libelle?: string; collaborateur?: string;
campus?: string; departement?: string; montant?: number;
dateVerification: string; commentaire?: string; statut?: 'VERIFIEE' | 'REFUSEE';
nbLignes: number; nbLignesOk: number; nbLignesRefusees: number;
lignesJson?: string; fichiers?: string;
lignesRefusees?: { index: number; motif: string }[];
}
interface LigneState { status: 'ok' | 'refused' | 'pending'; motif?: string; }
const fmt = (n: number) => new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(n);
const fmtDate = (d?: string) => d ? new Date(d).toLocaleDateString('fr-FR') : '—';
const getIndemniteKm = (km: number, cv: number) => {
const BAREME: Record<number, number> = { 3: 0.529, 4: 0.606, 5: 0.636, 6: 0.665, 7: 0.697 };
return km * (BAREME[Math.min(Math.max(cv, 3), 7)] ?? 0.697);
};
const ligneKey = (noteId: number, idx: number) => `${noteId}-l${idx}`;
const SYSTEME_KEYWORDS = ['soumission', 'resoumission', 'recap-paiement', 'recap', 'signe-approuve', 'signeapprouve', 'signe_approuve'];
const isSystemFile = (f: Fichier) => SYSTEME_KEYWORDS.some(kw => (f.fileName ?? '').toLowerCase().includes(kw));
const isApprovalFile = (f: Fichier) => {
const n = (f.fileName ?? '').toLowerCase();
return (n.includes('signe') || n.includes('signé')) && (n.includes('approuve') || n.includes('approuvé'));
};
const getCatMeta = (cat?: string) => {
const c = (cat || '').toLowerCase();
if (c.includes('kilom')) return { color: '#7c3aed', bg: '#ede9fe', light: '#f5f3ff', emoji: '🚗', label: 'Kilométrique' };
if (c.includes('repas') || c.includes('restaurant')) return { color: '#d97706', bg: '#fef3c7', light: '#fffbeb', emoji: '🍽️', label: 'Repas' };
if (c.includes('transport') || c.includes('avion') || c.includes('train')) return { color: '#0369a1', bg: '#dbeafe', light: '#eff6ff', emoji: '🚆', label: 'Transport' };
if (c.includes('hebergement') || c.includes('hotel')) return { color: '#059669', bg: '#dcfce7', light: '#f0fdf4', emoji: '🏨', label: 'Hébergement' };
return { color: '#6366f1', bg: '#eef2ff', light: '#f5f3ff', emoji: '📋', label: 'Autre' };
};
// Group lines by category
const groupByCategory = (lignes: LigneDepense[]) => {
const groups: Record<string, { cat: string; indices: number[]; total: number }> = {};
lignes.forEach((l, i) => {
const cat = l.categorie || 'Autre';
if (!groups[cat]) groups[cat] = { cat, indices: [], total: 0 };
groups[cat].indices.push(i);
const isKm = cat.toLowerCase().includes('kilom');
const km = parseFloat(l.km || '0') || 0;
const cv = parseInt(l.chevaux || '7') || 7;
groups[cat].total += isKm ? getIndemniteKm(km, cv) : (parseFloat(l.montant || '0') || 0);
});
return Object.values(groups);
};
interface Props {
notesAVerifier: NoteAVerifier[];
onVerified: (noteId: number) => void;
API: string;
hdrs: Record<string, string>;
proxyUrl?: (url: string) => string;
setPreviewUrl?: (v: { url: string; name: string }) => void;
}
const CSS = `
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500;600;700&family=DM+Mono:wght@400;500&display=swap');
*{box-sizing:border-box;}
.vf-root{font-family:'DM Sans',system-ui,sans-serif;--accent:#5b21b6;--accent-bg:#ede9fe;--green:#15803d;--green-bg:#dcfce7;--red:#dc2626;--red-bg:#fee2e2;--amber:#d97706;--amber-bg:#fef3c7;--border:#e5e7eb;--border2:#f3f4f6;--muted:#6b7280;--text:#111827;--bg:#fff;--bg2:#f9fafb;--mono:'DM Mono',monospace;--panel-border:1px solid #e5e7eb;}
/* ── Tabs ── */
.vf-tabs{display:flex;gap:2px;background:#f3f4f6;border-radius:10px;padding:3px;margin-bottom:14px;}
.vf-tab{flex:1;padding:7px 12px;border:none;border-radius:8px;cursor:pointer;font-family:'DM Sans',sans-serif;font-size:12px;font-weight:600;background:transparent;color:var(--muted);display:flex;align-items:center;justify-content:center;gap:5px;transition:all .15s;}
.vf-tab.active{background:#fff;color:var(--accent);box-shadow:0 1px 3px rgba(0,0,0,.1);}
.vf-tab-badge{background:#ef4444;color:#fff;font-size:9px;font-weight:700;padding:1px 5px;border-radius:20px;}
.vf-tab.active .vf-tab-badge{background:var(--accent);}
/* ── Note card (collapsed) ── */
.vf-note{border:1px solid var(--border);border-radius:11px;overflow:hidden;margin-bottom:9px;background:var(--bg);}
.vf-note-trigger{width:100%;display:flex;align-items:stretch;background:none;border:none;cursor:pointer;padding:0;font-family:inherit;text-align:left;}
.vf-note-bar{width:4px;flex-shrink:0;background:var(--accent);}
.vf-note-hd{flex:1;padding:10px 14px;display:flex;align-items:center;justify-content:space-between;gap:10px;}
.vf-note-chev{padding:0 13px;display:flex;align-items:center;border-left:1px solid var(--border);color:var(--muted);}
.vf-ref{font-size:9px;font-weight:700;color:var(--accent);background:var(--accent-bg);padding:1px 7px;border-radius:20px;font-family:var(--mono);display:inline-block;margin-bottom:2px;}
.vf-note-title{font-size:13px;font-weight:700;color:var(--text);}
.vf-note-meta{font-size:10px;color:var(--muted);margin-top:1px;}
.vf-note-amt{font-size:17px;font-weight:800;color:var(--accent);font-family:var(--mono);}
.vf-note-sub{font-size:9px;color:var(--muted);text-align:right;}
/* ── Progress strip ── */
.vf-prog-strip{padding:4px 14px 6px;border-top:1px solid var(--border2);background:#f5f3ff;display:flex;align-items:center;gap:8px;}
.vf-prog-track{flex:1;height:3px;background:#e5e7eb;border-radius:99px;overflow:hidden;}
.vf-prog-fill{height:100%;border-radius:99px;transition:width .3s;}
.vf-pills{display:flex;gap:3px;flex-wrap:wrap;}
.vf-pill{font-size:9px;font-weight:700;padding:1px 6px;border-radius:20px;white-space:nowrap;}
.vf-pill-ok{background:var(--green-bg);color:var(--green);}
.vf-pill-nok{background:var(--red-bg);color:var(--red);}
.vf-pill-wait{background:#f3f4f6;color:var(--muted);}
.vf-pill-adj{background:var(--amber-bg);color:var(--amber);}
/* ── 5-panel grid ── */
.vf-4grid{display:grid;grid-template-columns:175px 155px 200px 1fr 205px;border-top:1px solid var(--border);min-height:520px;}
.vf-panel{border-right:1px solid var(--border);display:flex;flex-direction:column;overflow:hidden;}
.vf-panel:last-child{border-right:none;}
.vf-panel-hd{padding:7px 10px;border-bottom:1px solid var(--border);background:var(--bg2);display:flex;align-items:center;gap:5px;flex-shrink:0;}
.vf-panel-label{font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.6px;color:var(--muted);}
.vf-panel-body{padding:8px;flex:1;display:flex;flex-direction:column;gap:6px;overflow-y:auto;}
/* ── Panel 3: compact ligne list ── */
.vf-lrow{display:flex;align-items:center;gap:6px;padding:6px 8px;border:1px solid var(--border);border-radius:6px;cursor:pointer;background:var(--bg);transition:all .12s;}
.vf-lrow:hover{border-color:#c4b5fd;background:#faf5ff;}
.vf-lrow.active{border-color:#7c3aed;background:#f5f3ff;box-shadow:0 0 0 2px rgba(124,58,237,.1);}
.vf-lrow.ok{border-color:#86efac;background:#f0fdf4;}
.vf-lrow.ok.active{border-color:#16a34a;box-shadow:0 0 0 2px rgba(22,163,74,.1);}
.vf-lrow.refused{border-color:#fca5a5;background:#fff5f5;}
.vf-lrow.refused.active{border-color:#dc2626;box-shadow:0 0 0 2px rgba(220,38,38,.1);}
.vf-lrow-num{width:20px;height:20px;border-radius:4px;display:flex;align-items:center;justify-content:center;font-size:9px;font-weight:800;flex-shrink:0;}
.vf-lrow-body{flex:1;min-width:0;}
.vf-lrow-name{font-size:10px;font-weight:700;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.vf-lrow-meta{font-size:8px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.vf-lrow-right{display:flex;flex-direction:column;align-items:flex-end;flex-shrink:0;gap:2px;}
.vf-lrow-price{font-size:10px;font-weight:800;font-family:var(--mono);}
.vf-lrow-arrow{font-size:9px;color:var(--muted);}
/* ── Panel 4: ligne detail ── */
.vf-detail-hd{display:flex;align-items:center;gap:8px;padding:10px 11px;border-bottom:1px solid var(--border);}
.vf-detail-num{width:28px;height:28px;border-radius:6px;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:800;flex-shrink:0;}
.vf-detail-title{font-size:12px;font-weight:700;color:var(--text);flex:1;}
.vf-detail-price{font-size:14px;font-weight:800;font-family:var(--mono);flex-shrink:0;}
.vf-detail-section{border-bottom:1px solid var(--border2);}
.vf-detail-section-hd{padding:5px 11px;font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);background:var(--bg2);}
.vf-detail-section-body{padding:8px 11px;display:flex;flex-direction:column;gap:5px;}
.vf-detail-row{display:flex;justify-content:space-between;align-items:center;font-size:10px;}
.vf-detail-row-lbl{color:var(--muted);}
.vf-detail-row-val{font-weight:700;font-family:var(--mono);color:var(--text);}
.vf-detail-alert{margin:8px 11px;padding:6px 9px;background:#fffbeb;border:1px solid #fde68a;border-radius:6px;font-size:9px;color:#92400e;font-weight:600;display:flex;align-items:center;gap:5px;flex-wrap:wrap;}
.vf-detail-justif-file{display:flex;align-items:center;gap:7px;padding:7px 11px;border-bottom:1px solid var(--border2);cursor:pointer;}
.vf-detail-justif-file:last-child{border-bottom:none;}
.vf-detail-justif-file:hover{background:var(--bg2);}
.vf-detail-justif-icon{width:32px;height:32px;border-radius:6px;display:flex;align-items:center;justify-content:center;font-size:16px;background:var(--bg2);border:1px solid var(--border);flex-shrink:0;}
.vf-detail-justif-name{flex:1;font-size:10px;font-weight:600;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.vf-detail-justif-btn{padding:3px 8px;font-size:9px;font-weight:700;border-radius:4px;border:1px solid var(--border);background:var(--bg);cursor:pointer;font-family:inherit;display:flex;align-items:center;gap:3px;color:#374151;flex-shrink:0;}
.vf-detail-justif-btn:hover{background:#f3f4f6;}
.vf-detail-actions{padding:8px 11px;display:flex;gap:6px;border-top:1px solid var(--border);background:var(--bg2);}
.vf-detail-missing{padding:10px 11px;display:flex;align-items:center;gap:6px;font-size:10px;color:var(--red);font-weight:600;}
.vf-detail-km{margin:8px 11px;padding:7px 10px;background:#f5f3ff;border:1px solid #ddd6fe;border-radius:7px;font-size:9px;color:#5b21b6;display:flex;flex-direction:column;gap:3px;}
.vf-detail-km-row{display:flex;justify-content:space-between;align-items:center;}
.vf-detail-km-lbl{color:#7c3aed;font-weight:600;}
.vf-detail-km-val{font-weight:800;font-family:var(--mono);color:#4c1d95;}
/* ── Panel 1: Note summary ── */
.vf-summary{border:1px solid var(--border);border-radius:7px;overflow:hidden;}
.vf-summary-top{height:3px;background:linear-gradient(90deg,#7c3aed,#a78bfa);}
.vf-summary-body{padding:9px 11px;}
.vf-sum-price-lbl{font-size:9px;text-transform:uppercase;letter-spacing:.4px;color:var(--muted);}
.vf-sum-price{font-size:20px;font-weight:800;font-family:var(--mono);color:var(--text);line-height:1.1;}
.vf-sum-price.adj{color:var(--green);}
.vf-sum-adj{font-size:9px;color:var(--green);font-weight:600;margin-top:1px;}
.vf-sum-div{height:1px;background:var(--border);margin:7px 0;}
.vf-sum-meta{font-size:10px;color:var(--muted);line-height:1.7;}
.vf-sum-prog{padding:5px 11px 9px;}
.vf-appro{border:1px solid #d8b4fe;border-radius:7px;overflow:hidden;margin-top:6px;}
.vf-appro-hd{display:flex;align-items:center;gap:5px;padding:5px 9px;background:#f5f3ff;border-bottom:1px solid #e9d5ff;font-size:9px;font-weight:700;color:var(--accent);}
.vf-appro-body{padding:4px 7px;display:flex;flex-direction:column;gap:3px;}
/* ── Panel 2: Category list ── */
.vf-cat-item{border:1px solid var(--border);border-radius:7px;overflow:hidden;cursor:pointer;transition:all .15s;background:var(--bg);}
.vf-cat-item:hover{border-color:#c4b5fd;background:#faf5ff;}
.vf-cat-item.active{border-color:#7c3aed;background:#f5f3ff;box-shadow:0 0 0 2px rgba(124,58,237,.12);}
.vf-cat-hd{display:flex;align-items:center;gap:7px;padding:7px 9px;}
.vf-cat-emoji{font-size:14px;flex-shrink:0;}
.vf-cat-info{flex:1;min-width:0;}
.vf-cat-name{font-size:10px;font-weight:700;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.vf-cat-sub{font-size:9px;color:var(--muted);}
.vf-cat-right{text-align:right;flex-shrink:0;}
.vf-cat-amt{font-size:11px;font-weight:800;font-family:var(--mono);}
.vf-cat-progress{height:2px;background:var(--border2);}
.vf-cat-progress-fill{height:100%;border-radius:0 0 7px 7px;transition:width .3s;}
.vf-cat-status-strip{display:flex;gap:2px;padding:3px 9px;border-top:1px solid var(--border2);flex-wrap:wrap;}
/* ── Panel 3: Ligne detail ── */
.vf-empty-panel{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:7px;padding:24px;color:var(--muted);text-align:center;}
.vf-ligne-card{border:1px solid var(--border);border-radius:7px;overflow:hidden;background:var(--bg);}
.vf-ligne-card.ok{border-color:#86efac;background:#f0fdf4;}
.vf-ligne-card.refused{border-color:#fca5a5;background:#fff5f5;}
.vf-ligne-hd{display:flex;align-items:center;gap:7px;padding:8px 10px;border-bottom:1px solid rgba(0,0,0,.06);}
.vf-ligne-num{width:22px;height:22px;border-radius:5px;display:flex;align-items:center;justify-content:center;font-size:9px;font-weight:800;flex-shrink:0;}
.vf-ligne-name{font-size:11px;font-weight:700;color:var(--text);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.vf-ligne-date{font-size:9px;color:var(--muted);flex-shrink:0;}
.vf-ligne-price{font-size:13px;font-weight:800;font-family:var(--mono);flex-shrink:0;}
.vf-ligne-details{padding:5px 10px;display:flex;gap:10px;flex-wrap:wrap;border-bottom:1px solid rgba(0,0,0,.04);background:rgba(0,0,0,.015);}
.vf-det{display:flex;align-items:center;gap:3px;}
.vf-det-l{font-size:9px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.3px;}
.vf-det-v{font-size:9px;font-weight:700;color:var(--text);font-family:var(--mono);}
.vf-ligne-desc{padding:4px 10px;font-size:9px;color:#78350f;font-style:italic;background:#fffbeb;border-left:2px solid #f59e0b;border-bottom:1px solid #fde68a;}
.vf-ligne-participants{padding:4px 10px;display:flex;flex-wrap:wrap;gap:3px;align-items:center;border-bottom:1px solid rgba(0,0,0,.04);}
.vf-ligne-justifs{padding:5px 10px;display:flex;flex-wrap:wrap;gap:4px;align-items:center;border-bottom:1px solid rgba(0,0,0,.04);}
.vf-jlabel{font-size:9px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.3px;flex-shrink:0;}
.vf-jbtn{display:flex;align-items:center;gap:3px;padding:2px 7px;background:var(--bg);border:1px solid var(--border);border-radius:4px;cursor:pointer;font-family:inherit;font-size:9px;font-weight:600;color:#374151;white-space:nowrap;}
.vf-jbtn:hover{background:#f3f4f6;}
.vf-jkm{font-size:9px;color:var(--accent);font-weight:600;}
.vf-jmissing{font-size:9px;color:var(--red);font-weight:600;}
.vf-ligne-alert{display:flex;align-items:center;gap:5px;padding:4px 10px;background:#fffbeb;border-left:2px solid #f59e0b;border-bottom:1px solid #fde68a;font-size:9px;color:#92400e;font-weight:600;flex-wrap:wrap;}
.vf-prorata-btn{padding:1px 7px;border-radius:4px;cursor:pointer;font-family:'DM Sans',sans-serif;font-size:9px;font-weight:700;border:1px solid #6366f1;color:#6366f1;background:var(--bg);}
.vf-prorata-restore{padding:1px 7px;border-radius:4px;cursor:pointer;font-family:'DM Sans',sans-serif;font-size:9px;font-weight:700;border:1px solid #d1d5db;color:var(--muted);background:var(--bg);}
.vf-prorata-result{padding:3px 10px;font-size:9px;color:var(--green);font-weight:600;background:#f0fdf4;border-bottom:1px solid #86efac;display:flex;align-items:center;gap:4px;flex-wrap:wrap;}
.vf-ligne-actions{display:flex;align-items:center;gap:5px;padding:5px 10px;background:rgba(0,0,0,.015);}
.vf-status{flex:1;font-size:9px;font-weight:700;display:flex;align-items:center;gap:3px;}
.vf-status.ok{color:var(--green);}
.vf-status.refused{color:var(--red);}
.vf-status.pending{color:var(--muted);}
.vf-motif{flex:1;font-size:9px;font-style:italic;color:var(--red);background:#fff5f5;border:1px dashed #fca5a5;padding:2px 6px;border-radius:4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.vf-btn-edit{border:none;background:none;color:var(--accent);font-family:inherit;font-size:9px;font-weight:600;cursor:pointer;padding:1px 4px;text-decoration:underline;}
.vf-btn-ok{padding:3px 9px;font-family:'DM Sans',sans-serif;font-size:10px;font-weight:700;border-radius:4px;cursor:pointer;display:flex;align-items:center;gap:3px;border:1.5px solid #16a34a;color:#16a34a;background:var(--bg);transition:all .12s;}
.vf-btn-ok:hover{background:#f0fdf4;}
.vf-btn-ok.active{background:#16a34a;color:#fff;}
.vf-btn-refuse{padding:3px 9px;font-family:'DM Sans',sans-serif;font-size:10px;font-weight:700;border-radius:4px;cursor:pointer;display:flex;align-items:center;gap:3px;border:1.5px solid var(--red);color:var(--red);background:var(--bg);transition:all .12s;}
.vf-btn-refuse:hover{background:#fff5f5;}
.vf-btn-refuse.active{background:var(--red);color:#fff;}
/* ── Panel 4: Decision ── */
.vf-dec{border:1px solid var(--border);border-radius:7px;overflow:hidden;}
.vf-dec-hd{padding:5px 9px;background:var(--bg2);border-bottom:1px solid var(--border);font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);display:flex;align-items:center;gap:4px;}
.vf-dec-body{padding:6px 8px;display:flex;flex-direction:column;gap:4px;}
.vf-ls-row{display:flex;align-items:center;gap:5px;padding:3px 7px;border-radius:4px;border:1px solid var(--border);}
.vf-ls-dot{width:5px;height:5px;border-radius:50%;flex-shrink:0;}
.vf-ls-label{flex:1;font-size:9px;font-weight:600;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.vf-ls-tag{font-size:8px;font-weight:700;padding:1px 5px;border-radius:20px;white-space:nowrap;}
.vf-ls-tag.ok{background:var(--green-bg);color:var(--green);}
.vf-ls-tag.wait{background:var(--bg2);color:var(--muted);}
.vf-ls-tag.refused{background:var(--red-bg);color:var(--red);}
.vf-ls-tag.adj{background:var(--amber-bg);color:var(--amber);}
.vf-dec-total{display:flex;justify-content:space-between;align-items:center;padding:6px 9px;background:var(--bg2);border-top:1px solid var(--border);}
.vf-dec-total-lbl{font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.4px;color:var(--muted);}
.vf-dec-total-val{font-size:13px;font-weight:800;font-family:var(--mono);}
.vf-dec-final{padding:7px 9px;border-top:1px solid var(--border);display:flex;flex-direction:column;gap:5px;}
.vf-dec-hint{font-size:10px;color:var(--muted);}
.vf-dec-hint.ok{color:var(--green);font-weight:600;}
.vf-dec-hint.nok{color:var(--red);font-weight:600;}
.vf-btn-validate{width:100%;padding:7px 0;background:var(--green);color:#fff;border:none;border-radius:6px;font-family:'DM Sans',sans-serif;font-size:11px;font-weight:700;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:5px;}
.vf-btn-validate:hover:not(:disabled){background:#166534;}
.vf-btn-validate:disabled{opacity:.4;cursor:not-allowed;}
.vf-btn-reject{width:100%;padding:7px 0;background:var(--bg);color:var(--red);border:1.5px solid var(--red);border-radius:6px;font-family:'DM Sans',sans-serif;font-size:11px;font-weight:700;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:5px;}
.vf-btn-reject:hover:not(:disabled){background:var(--red-bg);}
.vf-btn-reject:disabled{opacity:.4;cursor:not-allowed;}
.vf-notify{display:flex;align-items:center;gap:5px;font-size:10px;color:var(--muted);cursor:pointer;}
.vf-jcard{border:1px solid var(--border);border-radius:7px;overflow:hidden;}
.vf-jcard-hd{padding:5px 9px;background:var(--bg2);border-bottom:1px solid var(--border);font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);display:flex;align-items:center;gap:4px;}
.vf-jfile{display:flex;align-items:center;gap:5px;padding:5px 9px;border-bottom:1px solid var(--border);}
.vf-jfile:last-child{border-bottom:none;}
.vf-jfile-icon{width:22px;height:22px;border-radius:4px;display:flex;align-items:center;justify-content:center;font-size:11px;background:var(--bg2);flex-shrink:0;}
.vf-jfile-name{font-size:9px;flex:1;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.vf-jfile-see{font-size:9px;font-weight:600;color:var(--muted);border:1px solid var(--border);background:var(--bg);border-radius:3px;padding:1px 6px;cursor:pointer;font-family:inherit;white-space:nowrap;}
/* ── History ── */
.vf-banner{display:flex;align-items:center;gap:9px;padding:9px 13px;border-radius:9px;border:1px solid;margin-bottom:11px;}
.vf-hist-card{background:var(--bg);border:1px solid var(--border);border-radius:9px;overflow:hidden;margin-bottom:7px;}
.vf-hist-hd{padding:9px 13px;display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:7px;background:var(--bg2);border-bottom:1px solid var(--border2);}
.vf-hist-body{padding:7px 13px;display:flex;gap:7px;flex-wrap:wrap;align-items:center;}
.vf-hist-stat{display:flex;align-items:center;gap:4px;font-size:10px;font-weight:600;}
.vf-hist-comment{font-size:9px;color:var(--muted);font-style:italic;padding:4px 8px;background:var(--bg2);border-radius:5px;border:1px solid var(--border2);margin-top:3px;}
.vf-hist-badge{font-size:9px;font-weight:700;padding:2px 7px;border-radius:20px;}
.vf-hist-badge.ok{background:var(--green-bg);color:var(--green);}
.vf-hist-badge.refused{background:var(--red-bg);color:var(--red);}
.vf-empty{background:var(--bg2);border:1px solid var(--border);border-radius:11px;padding:44px 24px;text-align:center;}
/* ── Modals ── */
.vf-modal-bg{position:fixed;inset:0;z-index:10001;background:rgba(0,0,0,.4);display:flex;align-items:center;justify-content:center;padding:1rem;}
.vf-modal{background:var(--bg);border-radius:13px;width:100%;max-width:450px;border:1px solid var(--border);overflow:hidden;box-shadow:0 20px 56px rgba(0,0,0,.18);}
.vf-mnt-modal{background:var(--bg);border-radius:15px;width:100%;max-width:410px;overflow:hidden;box-shadow:0 26px 65px rgba(0,0,0,.2);}
.vf-mnt-hd{padding:16px 20px 13px;background:#6366f1;color:#fff;}
.vf-mnt-hd-icon{width:38px;height:38px;border-radius:9px;background:rgba(255,255,255,.2);display:flex;align-items:center;justify-content:center;font-size:17px;margin-bottom:9px;}
.vf-mnt-hd-title{font-size:14px;font-weight:800;margin-bottom:2px;}
.vf-mnt-hd-sub{font-size:10px;opacity:.85;}
.vf-mnt-body{padding:16px 20px;}
.vf-mnt-recap{display:grid;grid-template-columns:1fr 1fr;gap:7px;margin-bottom:14px;}
.vf-mnt-recap-item{background:var(--bg2);border:1px solid var(--border);border-radius:7px;padding:7px 9px;}
.vf-mnt-recap-lbl{font-size:9px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.3px;margin-bottom:2px;}
.vf-mnt-recap-val{font-size:13px;font-weight:800;font-family:var(--mono);}
.vf-mnt-input-wrap{position:relative;margin-bottom:5px;}
.vf-mnt-input{width:100%;padding:11px 40px 11px 13px;font-size:17px;font-weight:800;font-family:var(--mono);border:2px solid #6366f1;border-radius:7px;outline:none;color:var(--text);background:var(--bg);box-sizing:border-box;}
.vf-mnt-input:focus{box-shadow:0 0 0 3px rgba(99,102,241,.15);}
.vf-mnt-input.error{border-color:#ef4444;}
.vf-mnt-currency{position:absolute;right:12px;top:50%;transform:translateY(-50%);font-size:15px;font-weight:800;color:#6366f1;pointer-events:none;}
.vf-mnt-hint{font-size:10px;color:var(--muted);margin-bottom:13px;}
.vf-mnt-hint.error{color:#ef4444;font-weight:600;}
.vf-mnt-preset{padding:3px 9px;background:#eef2ff;color:#6366f1;border:1px solid #c7d2fe;border-radius:20px;cursor:pointer;font-size:10px;font-weight:700;font-family:inherit;margin-bottom:14px;display:inline-block;}
.vf-mnt-preset:hover,.vf-mnt-preset.active{background:#6366f1;color:#fff;}
.vf-mnt-footer{padding:10px 20px;border-top:1px solid var(--border);display:flex;gap:7px;justify-content:flex-end;background:var(--bg2);}
.vf-mnt-btn-cancel{padding:7px 14px;background:var(--bg);border:1.5px solid var(--border);border-radius:7px;cursor:pointer;font-family:inherit;font-size:12px;font-weight:600;color:var(--muted);}
.vf-mnt-btn-confirm{padding:7px 16px;background:#6366f1;color:#fff;border:none;border-radius:7px;cursor:pointer;font-family:inherit;font-size:12px;font-weight:700;display:flex;align-items:center;gap:4px;}
.vf-mnt-btn-confirm:disabled{opacity:.4;cursor:not-allowed;}
/* ── All-lines bulk validation strip ── */
.vf-bulk-strip{display:flex;gap:5px;padding:5px 8px;border-top:1px solid var(--border2);background:#f5f3ff;flex-wrap:wrap;}
.vf-bulk-btn{padding:3px 10px;font-size:9px;font-weight:700;border-radius:4px;cursor:pointer;font-family:inherit;display:flex;align-items:center;gap:3px;}
.vf-bulk-ok{background:#f0fdf4;color:var(--green);border:1.5px solid #86efac;}
.vf-bulk-ok:hover{background:#dcfce7;}
`;
export default function VerificateurFinance4Panels({
notesAVerifier: initialNotes, onVerified, API, hdrs,
proxyUrl = u => u, setPreviewUrl,
}: Props) {
const [notes, setNotes] = useState<NoteAVerifier[]>(initialNotes);
const [ligneStates, setLigneStates] = useState<Record<string, LigneState>>({});
const [modalNok, setModalNok] = useState<ModalNok | null>(null);
const [nokReason, setNokReason] = useState('');
const [submitting, setSubmitting] = useState(false);
const [notifyOnReject, setNotifyOnReject] = useState(true);
const [expandedNotes, setExpandedNotes] = useState<Set<number>>(new Set());
const [activeTab, setActiveTab] = useState<'pending' | 'history'>('pending');
const [history, setHistory] = useState<VerifHistorique[]>([]);
const [historyLoaded, setHistoryLoaded] = useState(false);
const [montantsModifies, setMontantsModifies] = useState<Record<string, number>>({});
const [modalCommentaire, setModalCommentaire] = useState<{
noteId: number; lignesData: LigneDepense[];
modifs: { ligneIndex: number; montantOriginal: number; montantRetenu: number }[];
} | null>(null);
const [commentaireInput, setCommentaireInput] = useState('');
const [modalMontant, setModalMontant] = useState<{
noteId: number; ligneIndex: number; ligneLabel: string;
montantOriginal: number; montantProrataMax: number; valeurSaisie: string;
} | null>(null);
// Panel 2 → 3: selected category per note
const [selectedCat, setSelectedCat] = useState<Record<number, string | null>>({});
// Panel 3 → 4: selected ligne index per note
const [selectedLigne, setSelectedLigne] = useState<Record<number, number | null>>({});
useEffect(() => {
setNotes(initialNotes);
const s: Record<string, LigneState> = {};
for (const note of initialNotes)
(note.lignesRefusees || []).forEach(r => { s[ligneKey(note.id, r.index)] = { status: 'refused', motif: r.motif }; });
if (Object.keys(s).length) setLigneStates(prev => ({ ...prev, ...s }));
}, [initialNotes]);
const toggleNote = (id: number) => setExpandedNotes(prev => { const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n; });
const setLigne = (key: string, status: LigneState['status'], motif?: string) => setLigneStates(prev => ({ ...prev, [key]: { status, motif } }));
const openRefuse = (noteId: number, ligneIndex: number, ligneLabel: string) => {
setModalNok({ noteId, ligneIndex, ligneLabel });
setNokReason(ligneStates[ligneKey(noteId, ligneIndex)]?.motif || '');
};
const loadHistory = async () => {
if (historyLoaded) return;
try { const r = await fetch(`${API}/api/verificateur/historique`, { headers: hdrs }); if (r.ok) { const d = await r.json(); if (Array.isArray(d)) setHistory(d); } } catch { }
setHistoryLoaded(true);
};
const handleTabChange = (tab: 'pending' | 'history') => { setActiveTab(tab); if (tab === 'history') loadHistory(); };
const getFilesForLigne = (l: LigneDepense, li: number, jr: Fichier[], fg: Fichier[], qrRefs: string[], all: LigneDepense[]): Fichier[] => {
if (l.qrFiles?.length) return l.qrFiles.map(f => ({ fileName: f.fileName, uploadUrl: f.uploadUrl }));
const ref = l.qrNoteRef || '';
if (ref) { const found = jr.filter(f => f.fileName?.includes(ref) || f.uploadUrl?.includes(ref)); if (found.length) return found; }
if (!qrRefs.length) return fg.slice(Math.floor(li * fg.length / all.length), Math.floor((li + 1) * fg.length / all.length));
return [];
};
const submitValidation = async (noteId: number, lignesData: LigneDepense[], modifs: { ligneIndex: number; montantOriginal: number; montantRetenu: number }[], commentaire: string) => {
setSubmitting(true);
try {
const r = await fetch(`${API}/api/verificateur/notes/${noteId}/verifier`, { method: 'PUT', headers: hdrs, body: JSON.stringify({ commentaire, montantsModifies: modifs }) });
const d = await r.json(); if (!r.ok) throw new Error(d.error || 'Erreur');
onVerified(noteId); setNotes(prev => prev.filter(n => n.id !== noteId));
setLigneStates(prev => { const n = { ...prev }; lignesData.forEach((_, i) => delete n[ligneKey(noteId, i)]); return n; });
setMontantsModifies(prev => { const n = { ...prev }; lignesData.forEach((_, i) => delete n[`${noteId}-${i}`]); return n; });
setModalCommentaire(null); setHistoryLoaded(false); setHistory([]); setActiveTab('history'); handleTabChange('history');
} catch (e: any) { alert(e.message); } finally { setSubmitting(false); }
};
const submitDecision = async (noteId: number, action: 'validate' | 'reject', lignesData: LigneDepense[], lignesRefusees: { index: number; motif: string }[]) => {
if (action === 'validate') {
const modifs = Object.entries(montantsModifies).filter(([k]) => k.startsWith(`${noteId}-`))
.map(([k, v]) => { const idx = parseInt(k.split('-')[1]); return { ligneIndex: idx, montantOriginal: parseFloat(lignesData[idx]?.montant || '0'), montantRetenu: v }; });
setCommentaireInput(''); setModalCommentaire({ noteId, lignesData, modifs }); return;
}
setSubmitting(true);
try {
const r = await fetch(`${API}/api/verificateur/notes/${noteId}/refuser`, { method: 'POST', headers: hdrs, body: JSON.stringify({ lignesRefusees, notifier: notifyOnReject }) });
const d = await r.json(); if (!r.ok) throw new Error(d.error || 'Erreur');
onVerified(noteId); setNotes(prev => prev.filter(n => n.id !== noteId));
setLigneStates(prev => { const n = { ...prev }; lignesData.forEach((_, i) => delete n[ligneKey(noteId, i)]); return n; });
setMontantsModifies(prev => { const n = { ...prev }; lignesData.forEach((_, i) => delete n[`${noteId}-${i}`]); return n; });
setHistoryLoaded(false); setHistory([]); setActiveTab('history'); handleTabChange('history');
} catch (e: any) { alert(e.message); } finally { setSubmitting(false); }
};
// Compact list for panel 3
const renderLignesPanel = (note: NoteAVerifier, lignesData: LigneDepense[], jr: Fichier[], fg: Fichier[], qrRefs: string[], catName: string | null) => {
if (!catName) {
return (
<div className="vf-empty-panel">
<Receipt size={28} color="#c4b5fd" />
<div style={{ fontSize: 11, fontWeight: 600, color: '#7c3aed' }}>Sélectionnez une catégorie</div>
<div style={{ fontSize: 10 }}>Les lignes s'afficheront ici</div>
</div>
);
}
const indices = lignesData.map((l, i) => ({ l, i })).filter(({ l }) => (l.categorie || 'Autre') === catName);
const activeLi = selectedLigne[note.id] ?? null;
return (
<>
{indices.map(({ l, i: li }) => {
const isKm = (l.categorie || '').toLowerCase().includes('kilom');
const km = parseFloat(l.km || '0') || 0;
const cv = parseInt(l.chevaux || '7') || 7;
const montantOriginal = parseFloat(l.montant || '0') || 0;
const mKey = `${note.id}-${li}`;
const montantEff = montantsModifies[mKey] ?? (isKm ? getIndemniteKm(km, cv) : montantOriginal);
const cat = getCatMeta(l.categorie);
const key = ligneKey(note.id, li);
const state = ligneStates[key] ?? { status: 'pending' as const };
const cls = state.status;
const label = l.libelle || l.categorie || `Ligne ${li + 1}`;
const estModifie = montantsModifies[mKey] !== undefined;
const isActive = activeLi === li;
const isRepas = (l.categorie || '').toLowerCase().includes('repas');
const isEv = /\b(ev[eè]nement|[eé]v[eè]nement)\b/i.test(l.libelle || '') || /\b(ev[eè]nement|[eé]v[eè]nement)\b/i.test(l.description || '');
const nbConvives = Math.max(1, (parseInt(l.nombreParticipants || '0') || 0) + 1);
const ppp = isRepas && montantEff > 0 ? montantEff / nbConvives : 0;
const depasse = isRepas && !isEv && ppp > 25;
return (
<div key={li}
className={`vf-lrow ${isActive ? 'active' : ''} ${cls === 'ok' ? 'ok' : cls === 'refused' ? 'refused' : ''}`}
onClick={() => setSelectedLigne(prev => ({ ...prev, [note.id]: isActive ? null : li }))}>
<div className="vf-lrow-num" style={{ background: cat.bg, color: cat.color }}>{li + 1}</div>
<div className="vf-lrow-body">
<div className="vf-lrow-name">{label}</div>
<div className="vf-lrow-meta">
{fmtDate(l.date)}
{depasse && <span style={{ color: '#d97706', fontWeight: 700, marginLeft: 4 }}>⚠ plafond</span>}
{state.status === 'refused' && <span style={{ color: '#dc2626', fontWeight: 700, marginLeft: 4 }}>✗ refusée</span>}
{state.status === 'ok' && <span style={{ color: '#15803d', fontWeight: 700, marginLeft: 4 }}>✓ ok{estModifie ? ' ajusté' : ''}</span>}
</div>
</div>
<div className="vf-lrow-right">
<div className="vf-lrow-price" style={{ color: estModifie ? '#15803d' : cat.color }}>{fmt(montantEff)}</div>
<div className="vf-lrow-arrow"></div>
</div>
</div>
);
})}
{indices.length > 1 && (
<div className="vf-bulk-strip">
<span style={{ fontSize: 9, color: 'var(--muted)', fontWeight: 700, flex: 1 }}>Groupé</span>
<button className="vf-bulk-btn vf-bulk-ok" onClick={() => indices.forEach(({ i }) => setLigne(ligneKey(note.id, i), 'ok'))}>
<Check size={9} /> Tout valider ({indices.length})
</button>
</div>
)}
</>
);
};
// Panel 4: full detail of selected ligne
const renderLigneDetail = (note: NoteAVerifier, lignesData: LigneDepense[], jr: Fichier[], fg: Fichier[], qrRefs: string[]) => {
const li = selectedLigne[note.id] ?? null;
if (li === null) {
return (
<div className="vf-empty-panel">
<Eye size={26} color="#c4b5fd" />
<div style={{ fontSize: 11, fontWeight: 600, color: '#7c3aed' }}>Sélectionnez une dépense</div>
<div style={{ fontSize: 10 }}>Détails et justificatifs ici</div>
</div>
);
}
const l = lignesData[li];
if (!l) return null;
const isKm = (l.categorie || '').toLowerCase().includes('kilom');
const km = parseFloat(l.km || '0') || 0;
const cv = parseInt(l.chevaux || '7') || 7;
const montantOriginal = parseFloat(l.montant || '0') || 0;
const mKey = `${note.id}-${li}`;
const isRepas = (l.categorie || '').toLowerCase().includes('repas');
const isEv = /\b(ev[eè]nement|[eé]v[eè]nement)\b/i.test(l.libelle || '') || /\b(ev[eè]nement|[eé]v[eè]nement)\b/i.test(l.description || '');
const nbConvives = Math.max(1, (parseInt(l.nombreParticipants || '0') || 0) + 1);
const montantEff = montantsModifies[mKey] ?? (isKm ? getIndemniteKm(km, cv) : montantOriginal);
const ppp = isRepas && montantEff > 0 ? montantEff / nbConvives : 0;
const depasse = isRepas && !isEv && ppp > 25;
const prorataMax = parseFloat((25 * nbConvives).toFixed(2));
const taux = parseFloat(l.tauxTVA || '0') || 0;
const ht = (!isKm && taux > 0) ? montantEff / (1 + taux / 100) : null;
const tva = ht !== null ? montantEff - ht : null;
const cat = getCatMeta(l.categorie);
const files = getFilesForLigne(l, li, jr, fg, qrRefs, lignesData);
const key = ligneKey(note.id, li);
const state = ligneStates[key] ?? { status: 'pending' as const };
const cls = state.status;
const label = l.libelle || l.categorie || `Ligne ${li + 1}`;
const estModifie = montantsModifies[mKey] !== undefined;
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{/* Header */}
<div className="vf-detail-hd" style={{ background: cat.light, borderColor: cat.bg }}>
<div className="vf-detail-num" style={{ background: cat.bg, color: cat.color }}>{li + 1}</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="vf-detail-title">{label}</div>
<div style={{ fontSize: 9, color: cat.color, fontWeight: 600 }}>{cat.emoji} {l.categorie} · {fmtDate(l.date)}</div>
</div>
<div style={{ textAlign: 'right', flexShrink: 0 }}>
<div className="vf-detail-price" style={{ color: estModifie ? '#15803d' : cat.color }}>{fmt(montantEff)}</div>
{estModifie && <div style={{ fontSize: 9, color: '#6b7280', textDecoration: 'line-through', fontFamily: 'DM Mono,monospace' }}>{fmt(montantOriginal)}</div>}
</div>
</div>
<div style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
{/* Montants */}
{(isKm || taux > 0 || isRepas) && (
<div className="vf-detail-section">
<div className="vf-detail-section-hd">Montants</div>
{isKm ? (
<div className="vf-detail-km">
<div className="vf-detail-km-row"><span className="vf-detail-km-lbl">Distance</span><span className="vf-detail-km-val">{km} km</span></div>
<div className="vf-detail-km-row"><span className="vf-detail-km-lbl">Puissance</span><span className="vf-detail-km-val">{cv} CV</span></div>
<div className="vf-detail-km-row"><span className="vf-detail-km-lbl">Barème</span><span className="vf-detail-km-val">{(montantEff / (km || 1)).toFixed(3)} €/km</span></div>
<div style={{ height: 1, background: '#ddd6fe', margin: '3px 0' }} />
<div className="vf-detail-km-row"><span style={{ color: '#4c1d95', fontWeight: 700, fontSize: 10 }}>Indemnité</span><span style={{ fontWeight: 800, fontFamily: 'DM Mono,monospace', fontSize: 13, color: '#4c1d95' }}>{fmt(montantEff)}</span></div>
</div>
) : (
<div className="vf-detail-section-body">
<div className="vf-detail-row"><span className="vf-detail-row-lbl">Montant TTC</span><span className="vf-detail-row-val">{fmt(montantEff)}</span></div>
{ht !== null && <div className="vf-detail-row"><span className="vf-detail-row-lbl">Montant HT</span><span className="vf-detail-row-val">{fmt(ht)}</span></div>}
{tva !== null && <div className="vf-detail-row"><span className="vf-detail-row-lbl">TVA ({taux}%)</span><span className="vf-detail-row-val">{fmt(tva)}</span></div>}
{isRepas && <div className="vf-detail-row"><span className="vf-detail-row-lbl">Convives</span><span className="vf-detail-row-val">{nbConvives} pers.</span></div>}
{isRepas && <div className="vf-detail-row"><span className="vf-detail-row-lbl">Par personne</span><span className="vf-detail-row-val" style={{ color: depasse ? '#dc2626' : '#15803d' }}>{fmt(ppp)}</span></div>}
</div>
)}
</div>
)}
{/* Description */}
{l.description && (
<div className="vf-detail-section">
<div className="vf-detail-section-hd">Commentaire</div>
<div style={{ padding: '8px 11px', fontSize: 10, color: '#78350f', fontStyle: 'italic', lineHeight: 1.5 }}>💬 {l.description}</div>
</div>
)}
{/* Participants */}
{l.participants && l.participants.length > 0 && (
<div className="vf-detail-section">
<div className="vf-detail-section-hd">Participants ({l.participants.length})</div>
<div className="vf-detail-section-body">
{l.participants.map((p, pi) => (
<div key={pi} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '3px 0' }}>
<div style={{ width: 22, height: 22, borderRadius: '50%', background: '#eef2ff', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 9, fontWeight: 700, color: '#4338ca', flexShrink: 0 }}>
{(p.prenom[0] || '') + (p.nom[0] || '')}
</div>
<div>
<div style={{ fontSize: 10, fontWeight: 700, color: '#111827' }}>{p.prenom} {p.nom}</div>
{p.societe && <div style={{ fontSize: 9, color: '#6b7280' }}>{p.societe}</div>}
</div>
</div>
))}
</div>
</div>
)}
{/* Alerte plafond */}
{depasse && (
<div className="vf-detail-alert">
<AlertTriangle size={10} style={{ color: '#f59e0b', flexShrink: 0 }} />
<div style={{ flex: 1 }}>
<div>Plafond dépassé : {fmt(ppp)}/pers. &gt; 25 €</div>
{estModifie && <div style={{ color: '#15803d', marginTop: 2 }}>✅ Ajusté à {fmt(montantsModifies[mKey])}</div>}
</div>
<button className="vf-prorata-btn" onClick={() => setModalMontant({ noteId: note.id, ligneIndex: li, ligneLabel: label, montantOriginal, montantProrataMax: prorataMax, valeurSaisie: (montantsModifies[mKey] ?? prorataMax).toFixed(2) })}>✏️ Ajuster</button>
{estModifie && <button className="vf-prorata-restore" onClick={() => setMontantsModifies(prev => { const n = { ...prev }; delete n[mKey]; return n; })}>↩</button>}
</div>
)}
{/* Justificatifs */}
<div className="vf-detail-section">
<div className="vf-detail-section-hd"><Paperclip size={9} style={{ display: 'inline', marginRight: 4 }} />Justificatifs</div>
{isKm ? (
<div style={{ padding: '8px 11px', fontSize: 10, color: '#7c3aed', fontWeight: 600 }}>✓ Aucun justificatif requis (frais kilométriques)</div>
) : files.length === 0 ? (
<div className="vf-detail-missing"><AlertTriangle size={13} color="#dc2626" /> Aucun justificatif trouvé</div>
) : (
files.map((f, fi) => {
const isImg = /\.(jpg|jpeg|png|gif|webp)$/i.test(f.fileName);
return (
<div key={fi} className="vf-detail-justif-file" onClick={() => setPreviewUrl?.({ url: proxyUrl(f.uploadUrl), name: f.fileName })}>
<div className="vf-detail-justif-icon">{isImg ? '🖼' : '📄'}</div>
<div className="vf-detail-justif-name">{f.fileName}</div>
<button className="vf-detail-justif-btn"><Eye size={9} /> Voir</button>
</div>
);
})
)}
</div>
{/* Refus motif si refusé */}
{cls === 'refused' && state.motif && (
<div style={{ margin: '8px 11px', padding: '7px 10px', background: '#fff5f5', border: '1px solid #fca5a5', borderRadius: 6, fontSize: 10, color: '#dc2626', fontStyle: 'italic' }}>
✗ Motif : {state.motif}
</div>
)}
</div>
{/* Actions */}
<div className="vf-detail-actions">
{cls === 'refused' && (
<button className="vf-btn-edit" style={{ fontSize: 10 }} onClick={() => openRefuse(note.id, li, label)}>Modifier le motif</button>
)}
<div style={{ flex: 1 }} />
<button className={`vf-btn-ok ${cls === 'ok' ? 'active' : ''}`} onClick={() => setLigne(key, 'ok')}><Check size={10} /> OK</button>
<button className={`vf-btn-refuse ${cls === 'refused' ? 'active' : ''}`} onClick={() => openRefuse(note.id, li, label)}><X size={10} /> Refuser</button>
</div>
</div>
);
};
return (
<>
<style>{CSS}</style>
<div className="vf-root">
<div className="vf-tabs">
<button className={`vf-tab ${activeTab === 'pending' ? 'active' : ''}`} onClick={() => handleTabChange('pending')}>
<Eye size={11} /> À vérifier {notes.length > 0 && <span className="vf-tab-badge">{notes.length}</span>}
</button>
<button className={`vf-tab ${activeTab === 'history' ? 'active' : ''}`} onClick={() => handleTabChange('history')}>
<History size={11} /> Historique {history.length > 0 && <span className="vf-tab-badge" style={{ background: activeTab === 'history' ? '#5b21b6' : '#6b7280' }}>{history.length}</span>}
</button>
</div>
{activeTab === 'pending' && (notes.length === 0 ? (
<div className="vf-empty">
<Eye size={34} color="#a78bfa" style={{ display: 'block', margin: '0 auto 9px' }} />
<p style={{ fontSize: 12, fontWeight: 700, color: '#5b21b6', marginBottom: 3 }}>Aucune note en attente</p>
<span style={{ fontSize: 10, color: '#6b7280' }}>Les notes approuvées apparaîtront ici</span>
</div>
) : notes.map(note => {
const isOpen = expandedNotes.has(note.id);
let lignesData: LigneDepense[] = []; try { if (note.lignesJson) lignesData = JSON.parse(note.lignesJson); } catch { }
let tousLesFichiers: Fichier[] = []; try { tousLesFichiers = note.fichiers ? JSON.parse(note.fichiers as string) : []; } catch { }
const fichierApprobation = tousLesFichiers.filter(f => isApprovalFile(f));
const jr = tousLesFichiers.filter(f => !isSystemFile(f) && !isApprovalFile(f));
const qrRefs = lignesData.map(l => l.qrNoteRef).filter(Boolean) as string[];
const fg = qrRefs.length ? jr.filter(f => !qrRefs.some(ref => f.fileName?.includes(ref) || f.uploadUrl?.includes(ref))) : jr;
const okCount = lignesData.filter((_, i) => ligneStates[ligneKey(note.id, i)]?.status === 'ok').length;
const refusedList = lignesData.map((_, i) => ({ i, s: ligneStates[ligneKey(note.id, i)] })).filter(x => x.s?.status === 'refused').map(x => ({ index: x.i, motif: x.s!.motif || '' }));
const refusedCount = refusedList.length;
const pendingCount = lignesData.length - okCount - refusedCount;
const pct = lignesData.length > 0 ? Math.round(((okCount + refusedCount) / lignesData.length) * 100) : 0;
const allChecked = lignesData.length > 0 && pendingCount === 0;
const canValidate = allChecked && refusedCount === 0;
const canReject = refusedCount > 0 && pendingCount === 0;
const nbModifs = Object.keys(montantsModifies).filter(k => k.startsWith(`${note.id}-`)).length;
const totalAjuste = lignesData.reduce((sum, l, i) => {
const isK = (l.categorie || '').toLowerCase().includes('kilom');
const km = parseFloat(l.km || '0') || 0; const cv = parseInt(l.chevaux || '7') || 7;
return sum + (montantsModifies[`${note.id}-${i}`] ?? (isK ? getIndemniteKm(km, cv) : parseFloat(l.montant || '0') || 0));
}, 0);
const catGroups = groupByCategory(lignesData);
const activeCat = selectedCat[note.id] ?? null;
return (
<div key={note.id} className="vf-note">
{/* Note header */}
<button className="vf-note-trigger" onClick={() => toggleNote(note.id)}>
<div className="vf-note-bar" />
<div className="vf-note-hd">
<div>
<span className="vf-ref">{note.reference}</span>
<div className="vf-note-title">{note.libelle}</div>
<div className="vf-note-meta">{[note.collaborateur, note.campus, note.departement, fmtDate(note.date)].filter(Boolean).join(' · ')}</div>
</div>
<div style={{ textAlign: 'right' }}>
<div className={`vf-note-amt ${nbModifs > 0 ? 'adj' : ''}`} style={{ color: nbModifs > 0 ? '#15803d' : undefined }}>{nbModifs > 0 ? fmt(totalAjuste) : fmt(note.montant || 0)}</div>
{nbModifs > 0 && <div style={{ fontSize: 9, color: '#6b7280', textDecoration: 'line-through', fontFamily: 'DM Mono,monospace', textAlign: 'right' }}>{fmt(note.montant || 0)}</div>}
<div className="vf-note-sub">{lignesData.length} ligne{lignesData.length > 1 ? 's' : ''}{nbModifs > 0 ? ` · ${nbModifs} ajust.` : ''}</div>
</div>
</div>
<div className="vf-note-chev">{isOpen ? <ChevronUp size={14} /> : <ChevronDown size={14} />}</div>
</button>
{lignesData.length > 0 && (
<div className="vf-prog-strip">
<div className="vf-prog-track">
<div className="vf-prog-fill" style={{ width: `${pct}%`, background: refusedCount > 0 ? '#ef4444' : '#5b21b6' }} />
</div>
<span style={{ fontSize: 9, fontWeight: 700, color: '#5b21b6', flexShrink: 0 }}>{pct}%</span>
<div className="vf-pills">
{okCount > 0 && <span className="vf-pill vf-pill-ok">{okCount} OK</span>}
{refusedCount > 0 && <span className="vf-pill vf-pill-nok">{refusedCount} refus.</span>}
{pendingCount > 0 && <span className="vf-pill vf-pill-wait">{pendingCount} att.</span>}
{nbModifs > 0 && <span className="vf-pill vf-pill-adj">✂️ {nbModifs}</span>}
</div>
</div>
)}
{isOpen && (
<div className="vf-4grid">
{/* ── PANEL 1 : La note ── */}
<div className="vf-panel">
<div className="vf-panel-hd">
<FileText size={10} color="#6b7280" />
<span className="vf-panel-label">La note</span>
</div>
<div className="vf-panel-body">
<div className="vf-summary">
<div className="vf-summary-top" />
<div className="vf-summary-body">
<span className="vf-ref">{note.reference}</span>
<div style={{ fontSize: 11, fontWeight: 700, color: '#111827', marginBottom: 3 }}>{note.libelle}</div>
<div className="vf-sum-div" />
<div className="vf-sum-price-lbl">Total demandé</div>
<div className={`vf-sum-price ${nbModifs > 0 ? 'adj' : ''}`}>{fmt(note.montant || 0)}</div>
{nbModifs > 0 && <div className="vf-sum-adj">→ {fmt(totalAjuste)} après ajust.</div>}
<div className="vf-sum-div" />
<div className="vf-sum-meta">
{note.collaborateur && <div>👤 {note.collaborateur}</div>}
{note.campus && <div>🏢 {note.campus}</div>}
{note.departement && <div>🗂 {note.departement}</div>}
{note.date && <div>📅 {fmtDate(note.date)}</div>}
</div>
</div>
<div className="vf-sum-prog">
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 3 }}>
<span style={{ fontSize: 9, fontWeight: 700, color: '#6b7280', textTransform: 'uppercase', letterSpacing: '.4px' }}>Progression</span>
<span style={{ fontSize: 9, fontWeight: 700, color: '#5b21b6' }}>{pct}%</span>
</div>
<div className="vf-prog-track"><div className="vf-prog-fill" style={{ width: `${pct}%`, background: refusedCount > 0 ? '#ef4444' : '#5b21b6' }} /></div>
<div className="vf-pills" style={{ marginTop: 4 }}>
{okCount > 0 && <span className="vf-pill vf-pill-ok">{okCount} OK</span>}
{refusedCount > 0 && <span className="vf-pill vf-pill-nok">{refusedCount} refus.</span>}
{pendingCount > 0 && <span className="vf-pill vf-pill-wait">{pendingCount} att.</span>}
</div>
</div>
</div>
{fichierApprobation.length > 0 && (
<div className="vf-appro">
<div className="vf-appro-hd"><FileText size={10} color="#7c3aed" /> Approbation signée</div>
<div className="vf-appro-body">
{fichierApprobation.map((f, fi) => (
<div key={fi} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '2px 0' }}>
<span style={{ fontSize: 10 }}>📄</span>
<span style={{ fontSize: 9, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: '#111827' }}>{f.fileName.length > 22 ? f.fileName.slice(0, 20) + '' : f.fileName}</span>
<button className="vf-jfile-see" onClick={() => setPreviewUrl?.({ url: proxyUrl(f.uploadUrl), name: f.fileName })}><Eye size={8} style={{ display: 'inline', marginRight: 2 }} /> Voir</button>
</div>
))}
</div>
</div>
)}
</div>
</div>
{/* ── PANEL 2 : Catégories ── */}
<div className="vf-panel">
<div className="vf-panel-hd">
<ListChecks size={10} color="#6b7280" />
<span className="vf-panel-label">Catégories</span>
<span style={{ marginLeft: 'auto', fontSize: 9, color: '#6b7280' }}>{catGroups.length}</span>
</div>
<div className="vf-panel-body">
{catGroups.map(({ cat, indices, total }) => {
const catMeta = getCatMeta(cat);
const okInCat = indices.filter(i => ligneStates[ligneKey(note.id, i)]?.status === 'ok').length;
const refInCat = indices.filter(i => ligneStates[ligneKey(note.id, i)]?.status === 'refused').length;
const pendInCat = indices.length - okInCat - refInCat;
const catPct = indices.length > 0 ? Math.round(((okInCat + refInCat) / indices.length) * 100) : 0;
const isActive = activeCat === cat;
return (
<div key={cat} className={`vf-cat-item ${isActive ? 'active' : ''}`}
onClick={() => {
setSelectedCat(prev => ({ ...prev, [note.id]: isActive ? null : cat }));
setSelectedLigne(prev => ({ ...prev, [note.id]: null }));
}}>
<div className="vf-cat-hd">
<span className="vf-cat-emoji">{catMeta.emoji}</span>
<div className="vf-cat-info">
<div className="vf-cat-name">{cat}</div>
<div className="vf-cat-sub">{indices.length} ligne{indices.length > 1 ? 's' : ''}</div>
</div>
<div className="vf-cat-right">
<div className="vf-cat-amt" style={{ color: catMeta.color }}>{fmt(total)}</div>
<div style={{ fontSize: 8, color: '#6b7280', textAlign: 'right' }}>{catPct}%</div>
</div>
</div>
<div className="vf-cat-progress">
<div className="vf-cat-progress-fill" style={{ width: `${catPct}%`, background: refInCat > 0 ? '#ef4444' : catMeta.color }} />
</div>
<div className="vf-cat-status-strip">
{okInCat > 0 && <span className="vf-pill vf-pill-ok">{okInCat} OK</span>}
{refInCat > 0 && <span className="vf-pill vf-pill-nok">{refInCat} refus.</span>}
{pendInCat > 0 && <span className="vf-pill vf-pill-wait">{pendInCat} att.</span>}
</div>
</div>
);
})}
</div>
</div>
{/* ── PANEL 3 : Lignes de la catégorie (liste compacte) ── */}
<div className="vf-panel">
<div className="vf-panel-hd">
<Receipt size={10} color="#6b7280" />
<span className="vf-panel-label">
{activeCat ? activeCat : 'Dépenses'}
</span>
{activeCat && (
<span style={{ marginLeft: 'auto', fontSize: 9, color: '#6b7280' }}>
{lignesData.filter(l => (l.categorie || 'Autre') === activeCat).length}
</span>
)}
</div>
<div className="vf-panel-body">
{renderLignesPanel(note, lignesData, jr, fg, qrRefs, activeCat)}
</div>
</div>
{/* ── PANEL 4 : Détail de la ligne sélectionnée ── */}
<div className="vf-panel">
<div className="vf-panel-hd">
<Eye size={10} color="#6b7280" />
<span className="vf-panel-label">
{selectedLigne[note.id] !== null && selectedLigne[note.id] !== undefined
? `Ligne ${(selectedLigne[note.id] ?? 0) + 1} — détail`
: 'Détail & justificatifs'}
</span>
</div>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{renderLigneDetail(note, lignesData, jr, fg, qrRefs)}
</div>
</div>
{/* ── PANEL 5 : Décision ── */}
<div className="vf-panel">
<div className="vf-panel-hd">
<ShieldCheck size={10} color="#6b7280" />
<span className="vf-panel-label">Décision</span>
</div>
<div className="vf-panel-body">
<div className="vf-dec">
<div className="vf-dec-hd"><ListChecks size={9} /> Statut lignes</div>
<div className="vf-dec-body">
{lignesData.map((l, li) => {
const cat = getCatMeta(l.categorie);
const st = ligneStates[ligneKey(note.id, li)]?.status ?? 'pending';
const adj = montantsModifies[`${note.id}-${li}`] !== undefined;
const lbl = l.libelle || l.categorie || `Ligne ${li + 1}`;
const isActiveCatRow = activeCat === (l.categorie || 'Autre');
return (
<div key={li} className="vf-ls-row"
style={isActiveCatRow ? { background: '#faf5ff', borderColor: '#c4b5fd', cursor: 'pointer' } : { cursor: 'pointer' }}
onClick={() => setSelectedCat(prev => ({ ...prev, [note.id]: l.categorie || 'Autre' }))}>
<span className="vf-ls-dot" style={{ background: cat.color }} />
<span className="vf-ls-label" title={lbl}>{lbl}</span>
{st === 'ok' && adj && <span className="vf-ls-tag adj">OK ajusté</span>}
{st === 'ok' && !adj && <span className="vf-ls-tag ok">OK</span>}
{st === 'pending' && <span className="vf-ls-tag wait">Attente</span>}
{st === 'refused' && <span className="vf-ls-tag refused">Refusée</span>}
</div>
);
})}
</div>
<div className="vf-dec-total">
<span className="vf-dec-total-lbl">Total retenu</span>
<span className="vf-dec-total-val" style={{ color: nbModifs > 0 ? '#15803d' : '#111827' }}>{fmt(nbModifs > 0 ? totalAjuste : (note.montant || 0))}</span>
</div>
<div className="vf-dec-final">
{canValidate && <p className="vf-dec-hint ok"><CheckCircle size={9} style={{ display: 'inline', marginRight: 2 }} />Toutes conformes{nbModifs > 0 ? ` (${nbModifs} ajust.)` : ''}</p>}
{canReject && <p className="vf-dec-hint nok"><AlertTriangle size={9} style={{ display: 'inline', marginRight: 2 }} />{refusedCount} refusée{refusedCount > 1 ? 's' : ''}</p>}
{!allChecked && <p className="vf-dec-hint">{pendingCount} ligne{pendingCount > 1 ? 's' : ''} à vérifier</p>}
{canReject && (
<label className="vf-notify">
<input type="checkbox" checked={notifyOnReject} onChange={e => setNotifyOnReject(e.target.checked)} style={{ accentColor: '#dc2626' }} />
Notifier collaborateur & N1
</label>
)}
{canValidate && <button className="vf-btn-validate" disabled={submitting} onClick={() => submitDecision(note.id, 'validate', lignesData, [])}><CheckCircle size={11} />{submitting ? 'Envoi' : 'Valider Notifier Finance'}</button>}
{canReject && <button className="vf-btn-reject" disabled={submitting} onClick={() => submitDecision(note.id, 'reject', lignesData, refusedList)}><XCircle size={11} />{submitting ? 'Envoi' : `Refuser (${refusedCount})`}</button>}
{!allChecked && <button className="vf-btn-validate" disabled><Clock size={11} /> Vérifiez toutes les lignes</button>}
</div>
</div>
</div>
</div>
</div>
)}
</div>
);
}))}
{/* History tab */}
{activeTab === 'history' && (
<div>
<div className="vf-banner" style={{ background: '#f0fdf4', borderColor: '#86efac' }}>
<div style={{ width: 32, height: 32, borderRadius: 7, background: '#15803d', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}><History size={15} color="#fff" /></div>
<div>
<div style={{ fontSize: 11, fontWeight: 700, color: '#15803d' }}>Historique des vérifications</div>
<div style={{ fontSize: 9, color: '#166534', marginTop: 1 }}>Traçabilité complète de vos contrôles Finance</div>
</div>
{history.length > 0 && <div style={{ marginLeft: 'auto', background: '#15803d', color: '#fff', fontSize: 10, fontWeight: 700, padding: '2px 9px', borderRadius: 20 }}>{history.length}</div>}
</div>
{!historyLoaded ? (
<div style={{ textAlign: 'center', padding: '40px 24px', color: '#6b7280' }}><Clock size={26} style={{ display: 'block', margin: '0 auto 8px', opacity: .4 }} /><p style={{ fontSize: 11 }}>Chargement…</p></div>
) : history.length === 0 ? (
<div className="vf-empty"><History size={34} color="#a78bfa" style={{ display: 'block', margin: '0 auto 9px' }} /><p style={{ fontSize: 12, fontWeight: 700, color: '#5b21b6', marginBottom: 3 }}>Aucune vérification effectuée</p><span style={{ fontSize: 10, color: '#6b7280' }}>Les notes traitées apparaîtront ici</span></div>
) : history.map((h, i) => {
const isRefusee = h.statut === 'REFUSEE' || (h.nbLignesRefusees ?? 0) > 0;
return (
<div key={i} className="vf-hist-card">
<div className="vf-hist-hd">
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 5, marginBottom: 2, flexWrap: 'wrap' }}>
<span style={{ fontFamily: 'DM Mono,monospace', fontSize: 9, fontWeight: 700, color: '#5b21b6', background: '#ede9fe', padding: '1px 5px', borderRadius: 20 }}>{h.reference}</span>
<span className={`vf-hist-badge ${isRefusee ? 'refused' : 'ok'}`}>{isRefusee ? ' Refusée' : ' Validée'}</span>
</div>
<div style={{ fontSize: 12, fontWeight: 700, color: '#111827' }}>{h.libelle}</div>
<div style={{ fontSize: 9, color: '#6b7280', marginTop: 1 }}>{[h.collaborateur, h.campus, h.departement].filter(Boolean).join(' · ')}</div>
</div>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: 15, fontWeight: 800, color: isRefusee ? '#dc2626' : '#15803d', fontFamily: 'DM Mono,monospace' }}>{fmt(h.montant || 0)}</div>
<div style={{ fontSize: 9, color: '#6b7280' }}>{new Date(h.dateVerification).toLocaleDateString('fr-FR', { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' })}</div>
</div>
</div>
<div className="vf-hist-body">
<div className="vf-hist-stat"><FileText size={10} color="#6b7280" /><span style={{ color: '#6b7280' }}>{h.nbLignes} ligne{h.nbLignes > 1 ? 's' : ''}</span></div>
{(h.nbLignesOk ?? 0) > 0 && <div className="vf-hist-stat"><CheckCircle size={10} color="#15803d" /><span style={{ color: '#15803d' }}>{h.nbLignesOk} OK</span></div>}
{(h.nbLignesRefusees ?? 0) > 0 && <div className="vf-hist-stat"><XCircle size={10} color="#dc2626" /><span style={{ color: '#dc2626' }}>{h.nbLignesRefusees} refus.</span></div>}
{h.commentaire && <div style={{ width: '100%' }}><div className="vf-hist-comment">💬 {h.commentaire}</div></div>}
</div>
</div>
);
})}
</div>
)}
{/* Modal refus */}
{modalNok && (
<div className="vf-modal-bg" onClick={() => { setModalNok(null); setNokReason(''); }}>
<div className="vf-modal" onClick={e => e.stopPropagation()}>
<div style={{ padding: '12px 15px', display: 'flex', alignItems: 'center', gap: 9, borderBottom: '1px solid #fee2e2', background: '#fff5f5' }}>
<div style={{ width: 30, height: 30, borderRadius: '50%', background: '#fee2e2', border: '1px solid #fca5a5', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 14, flexShrink: 0 }}>⚠️</div>
<div><div style={{ fontWeight: 700, fontSize: 12, color: '#991b1b' }}>Refuser cette ligne</div><div style={{ fontSize: 9, color: '#6b7280', marginTop: 1 }}>Le motif sera transmis au collaborateur</div></div>
</div>
<div style={{ padding: '13px 15px', display: 'flex', flexDirection: 'column', gap: 9 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 7, padding: '7px 9px', background: '#f9fafb', border: '1px solid #e5e7eb', borderRadius: 6 }}>
<span style={{ fontSize: 13 }}>📋</span>
<div><div style={{ fontSize: 9, color: '#6b7280' }}>Ligne</div><div style={{ fontSize: 11, fontWeight: 600, color: '#111827', marginTop: 1 }}>{modalNok.ligneLabel}</div></div>
</div>
<div>
<label style={{ fontSize: 10, color: '#374151', fontWeight: 600, display: 'block', marginBottom: 4 }}>Motif <span style={{ color: '#dc2626' }}>*</span></label>
<textarea value={nokReason} onChange={e => setNokReason(e.target.value)} autoFocus placeholder="Ex : Justificatif illisible, montant différent…"
style={{ width: '100%', minHeight: 65, resize: 'vertical', padding: '6px 8px', fontSize: 11, borderRadius: 6, border: `1.5px solid ${nokReason.trim() ? '#d1d5db' : '#fca5a5'}`, fontFamily: 'DM Sans,sans-serif', color: '#111827', background: '#fff', boxSizing: 'border-box' as const }} />
{!nokReason.trim() && <div style={{ fontSize: 9, color: '#dc2626', marginTop: 2 }}>Obligatoire</div>}
</div>
<div style={{ padding: '7px 9px', background: '#fffbeb', border: '1px solid #fde68a', borderRadius: 6, fontSize: 9, color: '#92400e' }}>💡 Le collaborateur ne corrigera <strong>que cette ligne</strong>.</div>
</div>
<div style={{ padding: '9px 15px', borderTop: '1px solid #e5e7eb', display: 'flex', justifyContent: 'flex-end', gap: 6, background: '#f9fafb' }}>
<button onClick={() => { setModalNok(null); setNokReason(''); }} style={{ padding: '5px 12px', background: '#fff', border: '1px solid #d1d5db', borderRadius: 6, cursor: 'pointer', fontFamily: 'inherit', fontSize: 11, color: '#374151', fontWeight: 500 }}>Annuler</button>
<button disabled={!nokReason.trim()} onClick={() => { if (!nokReason.trim() || !modalNok) return; setLigne(ligneKey(modalNok.noteId, modalNok.ligneIndex), 'refused', nokReason.trim()); setModalNok(null); setNokReason(''); }}
style={{ padding: '5px 13px', background: nokReason.trim() ? '#dc2626' : '#fca5a5', color: '#fff', border: 'none', borderRadius: 6, cursor: nokReason.trim() ? 'pointer' : 'not-allowed', fontFamily: 'inherit', fontSize: 11, fontWeight: 700 }}>Confirmer le refus</button>
</div>
</div>
</div>
)}
{/* Modal montant */}
{modalMontant && (() => {
const valeur = parseFloat(modalMontant.valeurSaisie.replace(',', '.'));
const isValid = !isNaN(valeur) && valeur > 0 && valeur <= modalMontant.montantOriginal;
const isOver = !isNaN(valeur) && valeur > modalMontant.montantOriginal;
const eco = isValid ? modalMontant.montantOriginal - valeur : 0;
return (
<div className="vf-modal-bg" onClick={() => setModalMontant(null)}>
<div className="vf-mnt-modal" onClick={e => e.stopPropagation()}>
<div className="vf-mnt-hd">
<div className="vf-mnt-hd-icon">✂️</div>
<div className="vf-mnt-hd-title">Ajuster le montant remboursé</div>
<div className="vf-mnt-hd-sub">{modalMontant.ligneLabel}</div>
</div>
<div className="vf-mnt-body">
<div className="vf-mnt-recap">
<div className="vf-mnt-recap-item"><div className="vf-mnt-recap-lbl">Original</div><div className="vf-mnt-recap-val" style={{ color: '#dc2626' }}>{fmt(modalMontant.montantOriginal)}</div></div>
<div className="vf-mnt-recap-item" style={{ background: '#f0fdf4', borderColor: '#86efac' }}><div className="vf-mnt-recap-lbl">Plafond légal</div><div className="vf-mnt-recap-val" style={{ color: '#15803d' }}>{fmt(modalMontant.montantProrataMax)}</div></div>
</div>
<button type="button" className={`vf-mnt-preset ${modalMontant.valeurSaisie === modalMontant.montantProrataMax.toFixed(2) ? 'active' : ''}`}
onClick={() => setModalMontant(prev => prev ? { ...prev, valeurSaisie: prev.montantProrataMax.toFixed(2) } : null)}>
✓ Plafond exact — {fmt(modalMontant.montantProrataMax)}
</button>
<div style={{ fontSize: 10, fontWeight: 700, color: '#374151', marginBottom: 6 }}>Ou saisir un montant précis</div>
<div className="vf-mnt-input-wrap">
<input type="number" min="0.01" max={modalMontant.montantOriginal} step="0.01" className={`vf-mnt-input ${isOver ? 'error' : ''}`}
value={modalMontant.valeurSaisie} autoFocus
onChange={e => setModalMontant(prev => prev ? { ...prev, valeurSaisie: e.target.value } : null)}
onKeyDown={e => { if (e.key === 'Enter' && isValid) { setMontantsModifies(prev => ({ ...prev, [`${modalMontant.noteId}-${modalMontant.ligneIndex}`]: valeur })); setModalMontant(null); } if (e.key === 'Escape') setModalMontant(null); }} />
<span className="vf-mnt-currency">€</span>
</div>
<div className={`vf-mnt-hint ${isOver ? 'error' : ''}`}>{isOver ? `⛔ Max ${fmt(modalMontant.montantOriginal)}` : isValid && eco > 0 ? `Économie : ${fmt(eco)}` : `Entre 0,01 € et ${fmt(modalMontant.montantOriginal)}`}</div>
</div>
<div className="vf-mnt-footer">
<button type="button" className="vf-mnt-btn-cancel" onClick={() => setModalMontant(null)}>Annuler</button>
<button type="button" className="vf-mnt-btn-confirm" disabled={!isValid}
onClick={() => { if (!isValid) return; setMontantsModifies(prev => ({ ...prev, [`${modalMontant.noteId}-${modalMontant.ligneIndex}`]: valeur })); setModalMontant(null); }}>
<CheckCircle size={12} /> Confirmer — {isValid ? fmt(valeur) : ''}
</button>
</div>
</div>
</div>
);
})()}
{/* Modal commentaire validation */}
{modalCommentaire && (
<div className="vf-modal-bg" onClick={() => setModalCommentaire(null)}>
<div className="vf-modal" onClick={e => e.stopPropagation()} style={{ maxWidth: 440 }}>
<div style={{ padding: '13px 17px', background: '#15803d', display: 'flex', alignItems: 'center', gap: 9 }}>
<div style={{ width: 32, height: 32, borderRadius: 7, background: 'rgba(255,255,255,.2)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 15 }}>✅</div>
<div><div style={{ fontSize: 13, fontWeight: 800, color: '#fff' }}>Valider la note</div><div style={{ fontSize: 9, color: 'rgba(255,255,255,.8)', marginTop: 1 }}>Commentaire (optionnel)</div></div>
<button onClick={() => setModalCommentaire(null)} style={{ marginLeft: 'auto', background: 'rgba(255,255,255,.2)', border: 'none', borderRadius: 6, width: 26, height: 26, cursor: 'pointer', color: '#fff', fontSize: 13, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>✕</button>
</div>
<div style={{ padding: '14px 17px', display: 'flex', flexDirection: 'column', gap: 10 }}>
{modalCommentaire.modifs.length > 0 && (
<div style={{ background: '#fffbeb', border: '1px solid #fde68a', borderRadius: 7, padding: '9px 11px' }}>
<div style={{ fontSize: 10, fontWeight: 700, color: '#92400e', marginBottom: 5 }}>✂️ {modalCommentaire.modifs.length} montant{modalCommentaire.modifs.length > 1 ? 's' : ''} ajusté{modalCommentaire.modifs.length > 1 ? 's' : ''}</div>
{modalCommentaire.modifs.map((m, i) => { const l = modalCommentaire.lignesData[m.ligneIndex]; return (<div key={i} style={{ display: 'flex', justifyContent: 'space-between', fontSize: 10, color: '#78350f', padding: '2px 0', borderBottom: i < modalCommentaire.modifs.length - 1 ? '1px solid #fde68a' : 'none' }}><span>{l?.libelle || `Ligne ${m.ligneIndex + 1}`}</span><span style={{ fontFamily: 'DM Mono,monospace', fontWeight: 700 }}><span style={{ textDecoration: 'line-through', color: '#b45309', marginRight: 4 }}>{fmt(m.montantOriginal)}</span>→ {fmt(m.montantRetenu)}</span></div>); })}
</div>
)}
<div>
<label style={{ fontSize: 10, fontWeight: 700, color: '#374151', display: 'block', marginBottom: 5 }}>Commentaire</label>
<textarea autoFocus value={commentaireInput} onChange={e => setCommentaireInput(e.target.value)} placeholder="Ex : Dossier complet, bon pour accord…"
rows={3} style={{ width: '100%', padding: '7px 9px', border: '1.5px solid #d1d5db', borderRadius: 6, resize: 'vertical', fontFamily: 'DM Sans,sans-serif', fontSize: 11, color: '#111827', background: '#fff', outline: 'none', boxSizing: 'border-box' as const }}
onKeyDown={e => { if (e.key === 'Enter' && e.ctrlKey) submitValidation(modalCommentaire.noteId, modalCommentaire.lignesData, modalCommentaire.modifs, commentaireInput.trim()); }} />
<div style={{ fontSize: 9, color: '#9ca3af', marginTop: 2 }}>Ctrl+Entrée pour valider</div>
</div>
</div>
<div style={{ padding: '9px 17px', borderTop: '1px solid #e5e7eb', display: 'flex', justifyContent: 'flex-end', gap: 6, background: '#f9fafb' }}>
<button onClick={() => setModalCommentaire(null)} style={{ padding: '7px 13px', background: '#fff', border: '1.5px solid #e5e7eb', borderRadius: 6, cursor: 'pointer', fontFamily: 'inherit', fontSize: 11, fontWeight: 600, color: '#6b7280' }}>Annuler</button>
<button disabled={submitting} onClick={() => submitValidation(modalCommentaire.noteId, modalCommentaire.lignesData, modalCommentaire.modifs, commentaireInput.trim())}
style={{ padding: '7px 16px', background: submitting ? '#86efac' : '#15803d', color: '#fff', border: 'none', borderRadius: 6, cursor: submitting ? 'not-allowed' : 'pointer', fontFamily: 'inherit', fontSize: 11, fontWeight: 700, display: 'flex', alignItems: 'center', gap: 5 }}>
{submitting ? ' Envoi' : ' Confirmer'}
</button>
</div>
</div>
</div>
)}
</div>
</>
);
}