1068 lines
89 KiB
TypeScript
1068 lines
89 KiB
TypeScript
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. > 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>
|
||
</>
|
||
);
|
||
}
|