Ajoutez des fichiers projet.
This commit is contained in:
@@ -0,0 +1,859 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Eye, ChevronDown, ChevronUp, Clock, CheckCircle, XCircle, History, FileText, Car, Utensils, Package, MapPin } from 'lucide-react';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
interface Fichier { fileName: string; uploadUrl: string; }
|
||||
interface LigneDepense {
|
||||
qrNoteRef?: string; categorie?: string; libelle?: string;
|
||||
date?: string; montant?: string; km?: string; chevaux?: string;
|
||||
tauxTVA?: string; description?: string;
|
||||
nombreParticipants?: string; participants?: { nom: string; prenom: string; societe?: string }[];
|
||||
tvaItems?: { taux: string; montantTTC: 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[];
|
||||
}
|
||||
interface JustifState { status: 'ok' | 'nok' | 'pending'; comment?: string; }
|
||||
interface ModalNok { noteId: number; fileKey: string; fileName: string; }
|
||||
interface VerifHistorique {
|
||||
id: number; reference?: string; libelle?: string; collaborateur?: string;
|
||||
campus?: string; departement?: string; montant?: number;
|
||||
dateVerification: string; commentaire?: string;
|
||||
nbJustifs: number; nbConformes: number; nbNonConformes: number;
|
||||
lignesJson?: string; fichiers?: string;
|
||||
nonConformes?: NonConforme[];
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
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 buildKey = (noteId: number, scope: string, idx: number) => `${noteId}-${scope}-${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 { icon: Car, color: '#7c3aed', bg: '#ede9fe', emoji: '🚗' };
|
||||
if (c.includes('repas') || c.includes('restaurant')) return { icon: Utensils, color: '#d97706', bg: '#fef3c7', emoji: '🍽️' };
|
||||
if (c.includes('transport') || c.includes('avion') || c.includes('train')) return { icon: MapPin, color: '#0369a1', bg: '#e0f2fe', emoji: '🚆' };
|
||||
if (c.includes('hebergement') || c.includes('hotel')) return { icon: Package, color: '#059669', bg: '#dcfce7', emoji: '🏨' };
|
||||
return { icon: Package, color: '#6366f1', bg: '#eef2ff', emoji: '📋' };
|
||||
};
|
||||
|
||||
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');
|
||||
|
||||
.vf2-root {
|
||||
--purple:#5b21b6;--purple-light:#ede9fe;--purple-xlight:#f5f3ff;
|
||||
--green:#15803d;--green-light:#dcfce7;--red:#dc2626;--red-light:#fee2e2;
|
||||
--border:#e5e7eb;--muted:#6b7280;--text:#111827;--card:#ffffff;
|
||||
font-family:'DM Sans',system-ui,sans-serif;display:flex;flex-direction:column;gap:0;
|
||||
}
|
||||
.vf2-tabs{display:flex;gap:2px;background:#f3f4f6;border-radius:12px;padding:4px;margin-bottom:20px;}
|
||||
.vf2-tab{flex:1;padding:9px 16px;border:none;border-radius:9px;cursor:pointer;font-family:'DM Sans',sans-serif;font-size:13px;font-weight:600;background:transparent;color:var(--muted);display:flex;align-items:center;justify-content:center;gap:7px;transition:all .2s;}
|
||||
.vf2-tab.active{background:#fff;color:var(--purple);box-shadow:0 1px 4px rgba(0,0,0,.1);}
|
||||
.vf2-tab-badge{background:#ef4444;color:#fff;font-size:10px;font-weight:700;padding:1px 6px;border-radius:20px;}
|
||||
.vf2-tab.active .vf2-tab-badge{background:var(--purple);}
|
||||
.vf2-banner{display:flex;align-items:center;gap:14px;padding:14px 18px;background:linear-gradient(135deg,#f5f3ff,#ede9fe);border-radius:14px;border:1px solid #d8b4fe;margin-bottom:16px;}
|
||||
.vf2-banner-icon{width:40px;height:40px;border-radius:12px;background:var(--purple);display:flex;align-items:center;justify-content:center;flex-shrink:0;}
|
||||
.vf2-banner-title{font-size:13px;font-weight:700;color:var(--purple);}
|
||||
.vf2-banner-sub{font-size:12px;color:#6d28d9;margin-top:2px;}
|
||||
.vf2-banner-count{margin-left:auto;background:var(--purple);color:#fff;font-size:13px;font-weight:700;padding:4px 14px;border-radius:20px;white-space:nowrap;}
|
||||
.vf2-card{background:var(--card);border-radius:14px;border:1px solid var(--border);overflow:hidden;box-shadow:0 1px 6px rgba(0,0,0,.06);transition:box-shadow .2s;margin-bottom:12px;}
|
||||
.vf2-card:hover{box-shadow:0 4px 16px rgba(91,33,182,.1);}
|
||||
.vf2-card-trigger{width:100%;display:flex;align-items:center;padding:0;background:none;border:none;cursor:pointer;text-align:left;font-family:inherit;}
|
||||
.vf2-card-header{flex:1;padding:16px 20px;display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;}
|
||||
.vf2-card-chevron{padding:16px 18px;display:flex;align-items:center;border-left:1px solid var(--border);color:var(--muted);}
|
||||
.vf2-ref-badge{font-size:11px;font-weight:700;color:var(--purple);background:var(--purple-light);padding:2px 9px;border-radius:20px;font-family:'DM Mono',monospace;display:inline-block;margin-bottom:5px;}
|
||||
.vf2-card-title{font-size:15px;font-weight:700;color:var(--text);}
|
||||
.vf2-card-meta{font-size:12px;color:var(--muted);margin-top:3px;}
|
||||
.vf2-card-amount{font-size:22px;font-weight:800;color:var(--purple);text-align:right;}
|
||||
.vf2-card-depcount{font-size:11px;color:var(--muted);text-align:right;margin-top:2px;}
|
||||
.vf2-progress-wrap{padding:10px 20px;border-top:1px solid #f3f4f6;background:var(--purple-xlight);}
|
||||
.vf2-progress-bar{height:5px;background:#e5e7eb;border-radius:99px;overflow:hidden;margin-top:6px;}
|
||||
.vf2-progress-fill{height:100%;border-radius:99px;transition:width .4s ease;}
|
||||
.vf2-pills{display:flex;gap:6px;flex-wrap:wrap;}
|
||||
.vf2-pill{font-size:10px;font-weight:700;padding:2px 9px;border-radius:20px;white-space:nowrap;}
|
||||
.vf2-pill-ok{background:var(--green-light);color:var(--green);}
|
||||
.vf2-pill-nok{background:var(--red-light);color:var(--red);}
|
||||
.vf2-pill-wait{background:#f3f4f6;color:var(--muted);}
|
||||
.vf2-body{padding:12px 16px 16px;display:flex;flex-direction:column;gap:12px;}
|
||||
.vf2-section-label{font-size:10px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.8px;padding-bottom:6px;border-bottom:1px solid #f0f0f0;margin-bottom:8px;}
|
||||
|
||||
/* ── Bloc dépense ── */
|
||||
.vfc-bloc{border:1px solid rgba(0,0,0,.08);border-radius:10px;overflow:hidden;margin-bottom:0;}
|
||||
.vfc-bloc.all-ok{border-color:#86efac;}
|
||||
.vfc-bloc.has-nok{border-color:#fca5a5;}
|
||||
.vfc-dep-row{display:flex;align-items:center;gap:8px;padding:9px 12px;background:#f9fafb;}
|
||||
.vfc-num{width:20px;height:20px;border-radius:50%;font-size:9px;font-weight:800;display:flex;align-items:center;justify-content:center;flex-shrink:0;}
|
||||
.vfc-icon{width:28px;height:28px;border-radius:7px;display:flex;align-items:center;justify-content:center;font-size:13px;flex-shrink:0;}
|
||||
.vfc-info{flex:1;min-width:0;}
|
||||
.vfc-label{font-size:12px;font-weight:700;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||
.vfc-sub{font-size:10px;color:var(--muted);margin-top:1px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||
.vfc-price{font-size:13px;font-weight:800;flex-shrink:0;font-family:'DM Mono',monospace;}
|
||||
.vfc-badge{font-size:9px;font-weight:700;padding:2px 7px;border-radius:20px;flex-shrink:0;}
|
||||
.vfc-badge-km{background:#ede9fe;color:#7c3aed;}
|
||||
.vfc-badge-miss{background:#fee2e2;color:#dc2626;}
|
||||
|
||||
/* ── Justifs inline (toujours visibles) ── */
|
||||
.vfc-justifs{border-top:1px solid #f0f0f0;display:flex;flex-direction:column;}
|
||||
.vfc-jrow{display:flex;align-items:center;gap:7px;padding:7px 12px;border-bottom:1px solid #f5f5f5;}
|
||||
.vfc-jrow:last-child{border-bottom:none;}
|
||||
.vfc-jrow.ok{background:#f0fdf4;}
|
||||
.vfc-jrow.nok{background:#fff5f5;}
|
||||
.vfc-jrow.pending{background:#fff;}
|
||||
.vfc-jicon{font-size:14px;flex-shrink:0;}
|
||||
.vfc-jinfo{flex:1;min-width:0;}
|
||||
.vfc-jname{font-size:11px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||
.vfc-jname.ok{color:var(--green);}
|
||||
.vfc-jname.nok{color:var(--red);}
|
||||
.vfc-jname.pending{color:var(--text);}
|
||||
.vfc-jreason{font-size:10px;color:var(--red);font-style:italic;margin-top:1px;}
|
||||
.vfc-jstatus{font-size:9px;font-weight:700;padding:2px 7px;border-radius:20px;flex-shrink:0;}
|
||||
.vfc-jstatus.ok{background:var(--green-light);color:var(--green);}
|
||||
.vfc-jstatus.nok{background:var(--red-light);color:var(--red);}
|
||||
.vfc-jstatus.pending{background:#f3f4f6;color:var(--muted);}
|
||||
.vfc-no-km{font-size:11px;color:var(--green);background:var(--green-light);padding:6px 12px;font-weight:600;}
|
||||
|
||||
/* Boutons */
|
||||
.vf2-btn-voir{display:flex;align-items:center;gap:3px;padding:4px 8px;background:#f9fafb;border:1px solid #e5e7eb;border-radius:5px;cursor:pointer;font-family:inherit;font-size:10px;font-weight:600;color:#374151;flex-shrink:0;white-space:nowrap;}
|
||||
.vf2-btn-voir:hover{background:#f0f0f0;}
|
||||
.vf2-btn-check{width:26px;height:26px;border-radius:6px;flex-shrink:0;border:1.5px solid;cursor:pointer;font-size:12px;font-weight:700;display:flex;align-items:center;justify-content:center;transition:all .12s;background:none;}
|
||||
.vf2-btn-ok-on{border-color:#16a34a;background:#f0fdf4 !important;color:#16a34a;}
|
||||
.vf2-btn-ok-off{border-color:#d1d5db;color:#d1d5db;}
|
||||
.vf2-btn-nok-on{border-color:var(--red);background:#fff5f5 !important;color:var(--red);}
|
||||
.vf2-btn-nok-off{border-color:#d1d5db;color:#d1d5db;}
|
||||
|
||||
/* Approbation */
|
||||
.vf2-approval-box{border:1px solid #d8b4fe;border-radius:10px;overflow:hidden;}
|
||||
.vf2-approval-head{display:flex;align-items:center;gap:10px;padding:9px 14px;background:linear-gradient(90deg,#f5f3ff,#ede9fe);border-bottom:1px solid #e9d5ff;}
|
||||
.vf2-approval-label{font-size:12px;font-weight:700;color:var(--purple);}
|
||||
.vf2-approval-files{padding:6px 10px;display:flex;flex-direction:column;gap:4px;}
|
||||
|
||||
/* Footer */
|
||||
.vf2-footer{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px;padding:12px 16px;border-top:1.5px solid #f3f4f6;background:var(--purple-xlight);}
|
||||
.vf2-footer-msg{font-size:12px;color:var(--muted);}
|
||||
.vf2-footer-msg.ok{color:var(--green);font-weight:700;}
|
||||
.vf2-footer-msg.nok{color:var(--red);font-weight:700;}
|
||||
.vf2-btn-submit{padding:10px 20px;background:var(--purple);color:#fff;border:none;border-radius:9px;font-family:inherit;font-size:13px;font-weight:700;white-space:nowrap;box-shadow:0 4px 14px rgba(91,33,182,.3);cursor:pointer;transition:all .15s;}
|
||||
.vf2-btn-submit:hover:not(:disabled){background:#4c1d95;transform:translateY(-1px);}
|
||||
.vf2-btn-submit:disabled{opacity:.35;cursor:not-allowed;}
|
||||
|
||||
.vf2-modal-backdrop{position:fixed;inset:0;z-index:10001;background:rgba(0,0,0,.4);display:flex;align-items:center;justify-content:center;backdrop-filter:blur(4px);padding:1rem;}
|
||||
.vf2-modal{background:#fff;border-radius:16px;width:100%;max-width:460px;border:1px solid #e5e7eb;overflow:hidden;box-shadow:0 24px 64px rgba(0,0,0,.18);}
|
||||
.vf2-empty{background:#fafafa;border:1px solid var(--border);border-radius:14px;padding:64px 24px;text-align:center;}
|
||||
.vf2-hist-card{background:#fff;border:1px solid var(--border);border-radius:12px;overflow:hidden;margin-bottom:10px;box-shadow:0 1px 4px rgba(0,0,0,.05);}
|
||||
.vf2-hist-head{padding:14px 18px;display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:10px;background:#fafafa;border-bottom:1px solid #f0f0f0;}
|
||||
.vf2-hist-body{padding:12px 18px;display:flex;gap:10px;flex-wrap:wrap;align-items:center;}
|
||||
.vf2-hist-stat{display:flex;align-items:center;gap:5px;font-size:12px;font-weight:600;}
|
||||
.vf2-hist-comment{font-size:12px;color:var(--muted);font-style:italic;padding:8px 11px;background:#f9fafb;border-radius:8px;border:1px solid #f0f0f0;margin-top:6px;}
|
||||
`;
|
||||
|
||||
export default function VerificateurFinanceLight({
|
||||
notesAVerifier: initialNotes, onVerified, API, hdrs,
|
||||
proxyUrl = u => u, setPreviewUrl,
|
||||
}: Props) {
|
||||
const [notes, setNotes] = useState<NoteAVerifier[]>(initialNotes);
|
||||
const [justifStates, setJustifStates] = useState<Record<string, JustifState>>({});
|
||||
const [modalNok, setModalNok] = useState<ModalNok | null>(null);
|
||||
const [nokReason, setNokReason] = useState('');
|
||||
const [nokSending, setNokSending] = useState(false);
|
||||
const [notifyOnNok, setNotifyOnNok] = 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 [expandedHistory, setExpandedHistory] = useState<Set<number>>(new Set());
|
||||
const toggleHistory = (id: number) => setExpandedHistory(prev => {
|
||||
const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n;
|
||||
});
|
||||
// ✅ Restaurer les états non-conformes depuis la BDD au chargement
|
||||
useEffect(() => {
|
||||
setNotes(initialNotes);
|
||||
|
||||
if (!initialNotes.length) return;
|
||||
|
||||
const newStates: Record<string, JustifState> = {};
|
||||
|
||||
for (const note of initialNotes) {
|
||||
const ncs = note.nonConformes;
|
||||
if (!ncs || ncs.length === 0) continue;
|
||||
|
||||
let tousLesFichiers: Fichier[] = [];
|
||||
try { tousLesFichiers = note.fichiers ? JSON.parse(note.fichiers as string) : []; } catch { }
|
||||
|
||||
const justificatifsReels = tousLesFichiers.filter(f =>
|
||||
!SYSTEME_KEYWORDS.some(kw => (f.fileName ?? '').toLowerCase().includes(kw))
|
||||
);
|
||||
|
||||
let lignesData: LigneDepense[] = [];
|
||||
try { if (note.lignesJson) lignesData = JSON.parse(note.lignesJson); } catch { }
|
||||
|
||||
const allQrRefs = lignesData.map(l => l.qrNoteRef).filter(Boolean) as string[];
|
||||
const fichiersGlobaux = allQrRefs.length > 0
|
||||
? justificatifsReels.filter(f => !allQrRefs.some(ref => f.fileName?.includes(ref) || f.uploadUrl?.includes(ref)))
|
||||
: justificatifsReels;
|
||||
|
||||
for (const nc of ncs) {
|
||||
// Chercher dans les fichiers globaux (distribués par ligne)
|
||||
const idxGlobal = fichiersGlobaux.findIndex(f => f.fileName === nc.fileName);
|
||||
if (idxGlobal !== -1) {
|
||||
for (let li = 0; li < lignesData.length; li++) {
|
||||
const start = Math.floor(li * fichiersGlobaux.length / lignesData.length);
|
||||
const end = Math.floor((li + 1) * fichiersGlobaux.length / lignesData.length);
|
||||
if (idxGlobal >= start && idxGlobal < end) {
|
||||
newStates[buildKey(note.id, `global${li}`, idxGlobal - start)] = { status: 'nok', comment: nc.motif };
|
||||
break;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Chercher dans les fichiers par ligne (scope ligne{li})
|
||||
for (let li = 0; li < lignesData.length; li++) {
|
||||
const qr = lignesData[li].qrNoteRef || '';
|
||||
if (!qr) continue;
|
||||
const filesLigne = justificatifsReels.filter(f =>
|
||||
f.fileName?.includes(qr) || f.uploadUrl?.includes(qr)
|
||||
);
|
||||
const idxLigne = filesLigne.findIndex(f => f.fileName === nc.fileName);
|
||||
if (idxLigne !== -1) {
|
||||
newStates[buildKey(note.id, `ligne${li}`, idxLigne)] = { status: 'nok', comment: nc.motif };
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Chercher dans les fichiers d'approbation
|
||||
const fichierApprobation = tousLesFichiers.filter(f => isApprovalFile(f));
|
||||
const idxApprob = fichierApprobation.findIndex(f => f.fileName === nc.fileName);
|
||||
if (idxApprob !== -1) {
|
||||
newStates[buildKey(note.id, 'approbation', idxApprob)] = { status: 'nok', comment: nc.motif };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(newStates).length > 0) {
|
||||
setJustifStates(prev => ({ ...prev, ...newStates }));
|
||||
}
|
||||
}, [initialNotes]);
|
||||
|
||||
const toggleNote = (id: number) => setExpandedNotes(prev => {
|
||||
const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n;
|
||||
});
|
||||
const setJustif = (key: string, status: JustifState['status'], comment?: string) =>
|
||||
setJustifStates(prev => ({ ...prev, [key]: { status, comment } }));
|
||||
const openNok = (noteId: number, fileKey: string, fileName: string) => {
|
||||
setModalNok({ noteId, fileKey, fileName });
|
||||
setNokReason(justifStates[fileKey]?.comment || '');
|
||||
};
|
||||
const loadHistory = async () => {
|
||||
if (historyLoaded) return;
|
||||
try {
|
||||
const res = await fetch(`${API}/api/verificateur/historique`, { headers: hdrs });
|
||||
if (res.ok) { const d = await res.json(); if (Array.isArray(d)) setHistory(d); }
|
||||
} catch { }
|
||||
setHistoryLoaded(true);
|
||||
};
|
||||
const handleTabChange = (tab: 'pending' | 'history') => {
|
||||
setActiveTab(tab);
|
||||
if (tab === 'history') loadHistory();
|
||||
};
|
||||
|
||||
// ── Justificatif inline toujours visible ─────────────────────────────────
|
||||
const renderJRow = (f: Fichier, stateKey: string, noteId: number) => {
|
||||
const state = justifStates[stateKey] ?? { status: 'pending' };
|
||||
const cls = state.status;
|
||||
const isImg = /\.(jpg|jpeg|png|gif|webp)$/i.test(f.fileName);
|
||||
const short = f.fileName.length > 44 ? f.fileName.slice(0, 42) + '…' : f.fileName;
|
||||
const label = cls === 'ok' ? 'Conforme' : cls === 'nok' ? 'Non conforme' : 'En attente';
|
||||
return (
|
||||
<div key={stateKey} className={`vfc-jrow ${cls}`}>
|
||||
<span className="vfc-jicon">{isImg ? '🖼️' : '📄'}</span>
|
||||
<div className="vfc-jinfo">
|
||||
<div className={`vfc-jname ${cls}`}>{short}</div>
|
||||
{cls === 'nok' && state.comment && <div className="vfc-jreason">↳ {state.comment}</div>}
|
||||
</div>
|
||||
<span className={`vfc-jstatus ${cls}`}>{label}</span>
|
||||
{setPreviewUrl && (
|
||||
<button className="vf2-btn-voir"
|
||||
onClick={() => setPreviewUrl({ url: proxyUrl(f.uploadUrl), name: f.fileName })}>
|
||||
<Eye size={11} /> Voir
|
||||
</button>
|
||||
)}
|
||||
<button className={`vf2-btn-check ${cls === 'ok' ? 'vf2-btn-ok-on' : 'vf2-btn-ok-off'}`}
|
||||
title="Conforme" onClick={() => setJustif(stateKey, 'ok')}>✓</button>
|
||||
<button className={`vf2-btn-check ${cls === 'nok' ? 'vf2-btn-nok-on' : 'vf2-btn-nok-off'}`}
|
||||
title="Non conforme" onClick={() => openNok(noteId, stateKey, f.fileName)}>✗</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Bloc dépense ─────────────────────────────────────────────────────────
|
||||
// ✅ FIX : lignesData est maintenant passé en paramètre
|
||||
const renderBloc = (
|
||||
l: LigneDepense, li: number, note: NoteAVerifier,
|
||||
justificatifsReels: Fichier[], fichiersGlobaux: Fichier[], allQrRefs: string[],
|
||||
lignesData: LigneDepense[] // ← paramètre ajouté
|
||||
) => {
|
||||
const isKm = (l.categorie || '').toLowerCase().includes('kilom');
|
||||
const km = parseFloat(l.km || '0') || 0;
|
||||
const cv = parseInt(l.chevaux || '7') || 7;
|
||||
const price = isKm ? getIndemniteKm(km, cv) : parseFloat(l.montant || '0') || 0;
|
||||
const taux = parseFloat(l.tauxTVA || '0') || 0;
|
||||
const ht = (!isKm && taux > 0) ? price / (1 + taux / 100) : null;
|
||||
const cat = getCatMeta(l.categorie);
|
||||
|
||||
const qrRef = l.qrNoteRef || '';
|
||||
|
||||
// ✅ FIX : distribution équitable des fichiers globaux entre les dépenses
|
||||
const files: Fichier[] = qrRef
|
||||
? justificatifsReels.filter(f => f.fileName?.includes(qrRef) || f.uploadUrl?.includes(qrRef))
|
||||
: (allQrRefs.length === 0
|
||||
? fichiersGlobaux.slice(
|
||||
Math.floor(li * fichiersGlobaux.length / lignesData.length),
|
||||
Math.floor((li + 1) * fichiersGlobaux.length / lignesData.length)
|
||||
)
|
||||
: []);
|
||||
|
||||
// ✅ FIX : scope différent par ligne même en mode global
|
||||
const scope = qrRef ? `ligne${li}` : `global${li}`;
|
||||
|
||||
const depOk = files.filter((_, fi) => justifStates[buildKey(note.id, scope, fi)]?.status === 'ok').length;
|
||||
const depNok = files.filter((_, fi) => justifStates[buildKey(note.id, scope, fi)]?.status === 'nok').length;
|
||||
const blockCls = depNok > 0 ? 'has-nok' : (depOk === files.length && files.length > 0 ? 'all-ok' : '');
|
||||
|
||||
const sub: string[] = [];
|
||||
if (l.categorie) sub.push(l.categorie);
|
||||
if (l.date) sub.push(fmtDate(l.date));
|
||||
if (isKm && km > 0) sub.push(`${km} km · ${cv} CV`);
|
||||
if (!isKm && taux > 0 && ht !== null) sub.push(`HT ${fmt(ht)} · TVA ${taux}%`);
|
||||
if (l.nombreParticipants) sub.push(`${l.nombreParticipants} pers.`);
|
||||
if (l.description) sub.push(l.description);
|
||||
|
||||
return (
|
||||
<div key={li} className={`vfc-bloc ${blockCls}`}>
|
||||
<div className="vfc-dep-row">
|
||||
<div className="vfc-num" style={{ background: cat.bg, color: cat.color }}>{li + 1}</div>
|
||||
<div className="vfc-icon" style={{ background: cat.bg }}>{cat.emoji}</div>
|
||||
<div className="vfc-info">
|
||||
<div className="vfc-label">{l.libelle || l.categorie || '—'}</div>
|
||||
<div className="vfc-sub">{sub.join(' · ')}</div>
|
||||
</div>
|
||||
<span className="vfc-price" style={{ color: cat.color }}>{fmt(price)}</span>
|
||||
{files.length === 0 && isKm && <span className="vfc-badge vfc-badge-km">km</span>}
|
||||
{files.length === 0 && !isKm && <span className="vfc-badge vfc-badge-miss">⚠ manquant</span>}
|
||||
</div>
|
||||
{files.length > 0 && (
|
||||
<div className="vfc-justifs">
|
||||
{files.map((f, fi) => renderJRow(f, buildKey(note.id, scope, fi), note.id))}
|
||||
</div>
|
||||
)}
|
||||
{files.length === 0 && isKm && (
|
||||
<div className="vfc-no-km">✓ Indemnité kilométrique — aucun justificatif requis</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{CSS}</style>
|
||||
<div className="vf2-root">
|
||||
{/* Tabs */}
|
||||
<div className="vf2-tabs">
|
||||
<button className={`vf2-tab ${activeTab === 'pending' ? 'active' : ''}`} onClick={() => handleTabChange('pending')}>
|
||||
<Eye size={14} /> À vérifier
|
||||
{notes.length > 0 && <span className="vf2-tab-badge">{notes.length}</span>}
|
||||
</button>
|
||||
<button className={`vf2-tab ${activeTab === 'history' ? 'active' : ''}`} onClick={() => handleTabChange('history')}>
|
||||
<History size={14} /> Historique
|
||||
{history.length > 0 && <span className="vf2-tab-badge" style={{ background: activeTab === 'history' ? '#5b21b6' : '#6b7280' }}>{history.length}</span>}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── PENDING ── */}
|
||||
{activeTab === 'pending' && (
|
||||
<>
|
||||
|
||||
|
||||
{notes.length === 0 ? (
|
||||
<div className="vf2-empty">
|
||||
<Eye size={44} color="#a78bfa" style={{ display: 'block', margin: '0 auto 14px' }} />
|
||||
<p style={{ fontSize: 14, fontWeight: 700, color: '#5b21b6', marginBottom: 6 }}>Aucune note en attente</p>
|
||||
<span style={{ fontSize: 12, 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 justificatifsReels = tousLesFichiers.filter(
|
||||
f => !isSystemFile(f) && !isApprovalFile(f)
|
||||
);
|
||||
const allQrRefs = lignesData.map(l => l.qrNoteRef).filter(Boolean) as string[];
|
||||
const fichiersGlobaux = allQrRefs.length > 0
|
||||
? justificatifsReels.filter(f => !allQrRefs.some(ref => f.fileName?.includes(ref) || f.uploadUrl?.includes(ref)))
|
||||
: justificatifsReels;
|
||||
|
||||
// ✅ FIX : allKeys cohérent avec la distribution par ligne
|
||||
const allKeys: string[] = [];
|
||||
if (allQrRefs.length === 0) {
|
||||
lignesData.forEach((_, li) => {
|
||||
const start = Math.floor(li * fichiersGlobaux.length / lignesData.length);
|
||||
const end = Math.floor((li + 1) * fichiersGlobaux.length / lignesData.length);
|
||||
fichiersGlobaux.slice(start, end).forEach((_, fi) =>
|
||||
allKeys.push(buildKey(note.id, `global${li}`, fi))
|
||||
);
|
||||
});
|
||||
} else {
|
||||
lignesData.forEach((l, li) => {
|
||||
const qr = l.qrNoteRef || '';
|
||||
if (qr) {
|
||||
justificatifsReels
|
||||
.filter(f => f.fileName?.includes(qr) || f.uploadUrl?.includes(qr))
|
||||
.forEach((_, fi) => allKeys.push(buildKey(note.id, `ligne${li}`, fi)));
|
||||
}
|
||||
});
|
||||
fichiersGlobaux.forEach((_, fi) => allKeys.push(buildKey(note.id, 'global0', fi)));
|
||||
}
|
||||
fichierApprobation.forEach((_, fi) => allKeys.push(buildKey(note.id, 'approbation', fi)));
|
||||
|
||||
const okAll = allKeys.filter(k => justifStates[k]?.status === 'ok').length;
|
||||
const nokAll = allKeys.filter(k => justifStates[k]?.status === 'nok').length;
|
||||
const pendingAll = allKeys.length - okAll - nokAll;
|
||||
const pct = allKeys.length > 0 ? Math.round(((okAll + nokAll) / allKeys.length) * 100) : 0;
|
||||
const allChecked = allKeys.length > 0 && pendingAll === 0;
|
||||
|
||||
return (
|
||||
<div key={note.id} className="vf2-card">
|
||||
<button className="vf2-card-trigger" onClick={() => toggleNote(note.id)}>
|
||||
<div className="vf2-card-header">
|
||||
<div>
|
||||
<div className="vf2-ref-badge">{note.reference}</div>
|
||||
<div className="vf2-card-title">{note.libelle}</div>
|
||||
<div className="vf2-card-meta">
|
||||
{[note.collaborateur, note.campus, note.departement, fmtDate(note.date)].filter(Boolean).join(' · ')}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="vf2-card-amount">{fmt(note.montant || 0)}</div>
|
||||
<div className="vf2-card-depcount">{lignesData.length} dépense{lignesData.length > 1 ? 's' : ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="vf2-card-chevron">
|
||||
{isOpen ? <ChevronUp size={18} /> : <ChevronDown size={18} />}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{allKeys.length > 0 && (
|
||||
<div className="vf2-progress-wrap">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
|
||||
<span style={{ fontSize: 10, fontWeight: 700, color: '#6b7280', textTransform: 'uppercase', letterSpacing: '.5px' }}>
|
||||
Conformité — {pct}%
|
||||
</span>
|
||||
<div className="vf2-pills">
|
||||
{okAll > 0 && <span className="vf2-pill vf2-pill-ok">{okAll} conforme{okAll > 1 ? 's' : ''}</span>}
|
||||
{nokAll > 0 && <span className="vf2-pill vf2-pill-nok">{nokAll} non conforme{nokAll > 1 ? 's' : ''}</span>}
|
||||
{pendingAll > 0 && <span className="vf2-pill vf2-pill-wait">{pendingAll} en attente</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="vf2-progress-bar">
|
||||
<div className="vf2-progress-fill" style={{ width: `${pct}%`, background: nokAll > 0 ? '#ef4444' : '#5b21b6' }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isOpen && (
|
||||
<div className="vf2-body">
|
||||
<div>
|
||||
<div className="vf2-section-label">Dépenses & justificatifs</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{/* ✅ FIX : lignesData passé en dernier argument */}
|
||||
{lignesData.map((l, li) =>
|
||||
renderBloc(l, li, note, justificatifsReels, fichiersGlobaux, allQrRefs, lignesData)
|
||||
)}
|
||||
</div>
|
||||
{lignesData.length > 1 && (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '7px 12px', marginTop: 8, background: 'linear-gradient(90deg,#eef2ff,#f0fdf4)', border: '1px solid #c7d2fe', borderRadius: 8 }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 700, color: '#6b7280' }}>TOTAL — {lignesData.length} dépenses</span>
|
||||
<span style={{ fontSize: 15, fontWeight: 900, color: '#111827', fontFamily: 'DM Mono, monospace' }}>{fmt(note.montant || 0)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{fichierApprobation.length > 0 && (
|
||||
<div>
|
||||
<div className="vf2-section-label">Document d'approbation</div>
|
||||
<div className="vf2-approval-box">
|
||||
<div className="vf2-approval-head">
|
||||
<FileText size={15} color="#7c3aed" />
|
||||
<span className="vf2-approval-label">PDF d'approbation signé</span>
|
||||
<span style={{ marginLeft: 'auto', fontSize: 11, color: '#6b7280' }}>{fichierApprobation.length} fichier{fichierApprobation.length > 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
<div className="vf2-approval-files">
|
||||
{fichierApprobation.map((f, fi) => renderJRow(f, buildKey(note.id, 'approbation', fi), note.id))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="vf2-footer">
|
||||
<div className={`vf2-footer-msg ${allChecked ? 'ok' : nokAll > 0 ? 'nok' : ''}`}>
|
||||
{allChecked && nokAll === 0 ? '✓ Tous les justificatifs sont conformes'
|
||||
: allChecked && nokAll > 0 ? `⚠️ ${nokAll} non conforme${nokAll > 1 ? 's' : ''} — vérification possible`
|
||||
: nokAll > 0 && pendingAll > 0 ? `${nokAll} non conforme${nokAll > 1 ? 's' : ''} · ${pendingAll} en attente`
|
||||
: pendingAll > 0 ? `${pendingAll} justificatif${pendingAll > 1 ? 's' : ''} encore en attente`
|
||||
: allKeys.length === 0 ? 'Aucun justificatif à vérifier' : ''}
|
||||
</div>
|
||||
<button className="vf2-btn-submit" disabled={!allChecked && allKeys.length > 0}
|
||||
onClick={async () => {
|
||||
const commentaire = window.prompt('Commentaire de vérification (optionnel)', '') ?? '';
|
||||
if (commentaire === null) return;
|
||||
try {
|
||||
const res = await fetch(`${API}/api/verificateur/notes/${note.id}/verifier`, {
|
||||
method: 'PUT', headers: hdrs, body: JSON.stringify({ commentaire }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error);
|
||||
onVerified(note.id);
|
||||
setNotes(prev => prev.filter(n => n.id !== note.id));
|
||||
setJustifStates(prev => {
|
||||
const next = { ...prev };
|
||||
allKeys.forEach(k => delete next[k]);
|
||||
return next;
|
||||
});
|
||||
setHistoryLoaded(false);
|
||||
setHistory([]);
|
||||
setActiveTab('history');
|
||||
handleTabChange('history');
|
||||
} catch (e: any) { alert(e.message); }
|
||||
}}>
|
||||
✓ Marquer comme vérifié — Notifier Validateur Finance
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── HISTORY ── */}
|
||||
{activeTab === 'history' && (
|
||||
<div>
|
||||
<div className="vf2-banner" style={{ background: 'linear-gradient(135deg,#f0fdf4,#dcfce7)', borderColor: '#86efac' }}>
|
||||
<div className="vf2-banner-icon" style={{ background: '#15803d' }}><History size={20} color="#fff" /></div>
|
||||
<div>
|
||||
<div className="vf2-banner-title" style={{ color: '#15803d' }}>Historique des vérifications</div>
|
||||
<div className="vf2-banner-sub" style={{ color: '#166534' }}>Traçabilité complète de vos contrôles Finance</div>
|
||||
</div>
|
||||
{history.length > 0 && <div className="vf2-banner-count" style={{ background: '#15803d' }}>{history.length} vérifiée{history.length > 1 ? 's' : ''}</div>}
|
||||
</div>
|
||||
{!historyLoaded ? (
|
||||
<div style={{ textAlign: 'center', padding: '60px 24px', color: '#6b7280' }}>
|
||||
<Clock size={36} style={{ display: 'block', margin: '0 auto 12px', opacity: .4 }} />
|
||||
<p style={{ fontSize: 13 }}>Chargement de l'historique…</p>
|
||||
</div>
|
||||
) : history.length === 0 ? (
|
||||
<div className="vf2-empty">
|
||||
<History size={44} color="#a78bfa" style={{ display: 'block', margin: '0 auto 14px' }} />
|
||||
<p style={{ fontSize: 14, fontWeight: 700, color: '#5b21b6', marginBottom: 6 }}>Aucune vérification effectuée</p>
|
||||
<span style={{ fontSize: 12, color: '#6b7280' }}>Les notes vérifiées apparaîtront ici</span>
|
||||
</div>
|
||||
) : history.map((h, i) => {
|
||||
const isOpen = expandedHistory.has(h.id);
|
||||
|
||||
let lignesData: LigneDepense[] = [];
|
||||
try { if (h.lignesJson) lignesData = JSON.parse(h.lignesJson); } catch { }
|
||||
|
||||
let tousLesFichiers: Fichier[] = [];
|
||||
try { tousLesFichiers = h.fichiers ? JSON.parse(h.fichiers as string) : []; } catch { }
|
||||
|
||||
const fichierApprobation = tousLesFichiers.filter(f => isApprovalFile(f));
|
||||
const justificatifsReels = tousLesFichiers.filter(f => !isSystemFile(f) && !isApprovalFile(f));
|
||||
const allQrRefs = lignesData.map(l => l.qrNoteRef).filter(Boolean) as string[];
|
||||
const fichiersGlobaux = allQrRefs.length > 0
|
||||
? justificatifsReels.filter(f => !allQrRefs.some(ref => f.fileName?.includes(ref) || f.uploadUrl?.includes(ref)))
|
||||
: justificatifsReels;
|
||||
|
||||
return (
|
||||
<div key={i} className="vf2-hist-card">
|
||||
{/* ── Header cliquable ── */}
|
||||
<button
|
||||
onClick={() => toggleHistory(h.id)}
|
||||
style={{ width: '100%', background: 'none', border: 'none', cursor: 'pointer', textAlign: 'left', padding: 0, fontFamily: 'inherit' }}>
|
||||
<div className="vf2-hist-head">
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||
<span style={{ fontFamily: 'DM Mono,monospace', fontSize: 11, fontWeight: 700, color: '#5b21b6', background: '#ede9fe', padding: '2px 8px', borderRadius: 20 }}>{h.reference}</span>
|
||||
<CheckCircle size={13} color="#15803d" />
|
||||
<span style={{ fontSize: 11, fontWeight: 700, color: '#15803d' }}>Vérifiée</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: '#111827' }}>{h.libelle}</div>
|
||||
<div style={{ fontSize: 12, color: '#6b7280', marginTop: 2 }}>{[h.collaborateur, h.campus, h.departement].filter(Boolean).join(' · ')}</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right', display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 4 }}>
|
||||
<div style={{ fontSize: 20, fontWeight: 800, color: '#15803d' }}>{fmt(h.montant || 0)}</div>
|
||||
<div style={{ fontSize: 11, color: '#6b7280' }}>
|
||||
<Clock size={10} style={{ display: 'inline', marginRight: 4 }} />
|
||||
{new Date(h.dateVerification).toLocaleDateString('fr-FR', { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' })}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, fontSize: 11, color: '#6b7280' }}>
|
||||
{isOpen ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||
<span>{isOpen ? 'Réduire' : 'Voir les justificatifs'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* ── Stats toujours visibles ── */}
|
||||
<div className="vf2-hist-body">
|
||||
<div className="vf2-hist-stat"><FileText size={13} color="#6b7280" /><span style={{ color: '#6b7280' }}>{h.nbJustifs} justificatif{h.nbJustifs > 1 ? 's' : ''}</span></div>
|
||||
{h.nbConformes > 0 && <div className="vf2-hist-stat"><CheckCircle size={13} color="#15803d" /><span style={{ color: '#15803d' }}>{h.nbConformes} conforme{h.nbConformes > 1 ? 's' : ''}</span></div>}
|
||||
{h.nbNonConformes > 0 && <div className="vf2-hist-stat"><XCircle size={13} color="#dc2626" /><span style={{ color: '#dc2626' }}>{h.nbNonConformes} non conforme{h.nbNonConformes > 1 ? 's' : ''}</span></div>}
|
||||
{h.commentaire && <div style={{ width: '100%' }}><div className="vf2-hist-comment">💬 {h.commentaire}</div></div>}
|
||||
</div>
|
||||
|
||||
{/* ── Détail dépenses + justificatifs (accordéon) ── */}
|
||||
{isOpen && (
|
||||
<div style={{ padding: '0 16px 16px', borderTop: '1px solid #f0f0f0', display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{lignesData.length > 0 && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<div className="vf2-section-label">Dépenses & justificatifs</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{lignesData.map((l, li) => {
|
||||
const isKm = (l.categorie || '').toLowerCase().includes('kilom');
|
||||
const km = parseFloat(l.km || '0') || 0;
|
||||
const cv = parseInt(l.chevaux || '7') || 7;
|
||||
const price = isKm ? getIndemniteKm(km, cv) : parseFloat(l.montant || '0') || 0;
|
||||
const cat = getCatMeta(l.categorie);
|
||||
const qrRef = l.qrNoteRef || '';
|
||||
|
||||
const files: Fichier[] = qrRef
|
||||
? justificatifsReels.filter(f => f.fileName?.includes(qrRef) || f.uploadUrl?.includes(qrRef))
|
||||
: (allQrRefs.length === 0
|
||||
? fichiersGlobaux.slice(
|
||||
Math.floor(li * fichiersGlobaux.length / lignesData.length),
|
||||
Math.floor((li + 1) * fichiersGlobaux.length / lignesData.length)
|
||||
)
|
||||
: []);
|
||||
|
||||
const nonConformesLigne = (h.nonConformes || []).filter(nc =>
|
||||
files.some(f => f.fileName === nc.fileName)
|
||||
);
|
||||
|
||||
const sub: string[] = [];
|
||||
if (l.categorie) sub.push(l.categorie);
|
||||
if (l.date) sub.push(fmtDate(l.date));
|
||||
if (isKm && km > 0) sub.push(`${km} km · ${cv} CV`);
|
||||
if (l.nombreParticipants) sub.push(`${l.nombreParticipants} pers.`);
|
||||
if (l.description) sub.push(l.description);
|
||||
|
||||
return (
|
||||
<div key={li} className={`vfc-bloc ${nonConformesLigne.length > 0 ? 'has-nok' : files.length > 0 ? 'all-ok' : ''}`}>
|
||||
<div className="vfc-dep-row">
|
||||
<div className="vfc-num" style={{ background: cat.bg, color: cat.color }}>{li + 1}</div>
|
||||
<div className="vfc-icon" style={{ background: cat.bg }}>{cat.emoji}</div>
|
||||
<div className="vfc-info">
|
||||
<div className="vfc-label">{l.libelle || l.categorie || '—'}</div>
|
||||
<div className="vfc-sub">{sub.join(' · ')}</div>
|
||||
</div>
|
||||
<span className="vfc-price" style={{ color: cat.color }}>{fmt(price)}</span>
|
||||
{files.length === 0 && isKm && <span className="vfc-badge vfc-badge-km">km</span>}
|
||||
{files.length === 0 && !isKm && <span className="vfc-badge vfc-badge-miss">⚠ manquant</span>}
|
||||
</div>
|
||||
{files.length > 0 && (
|
||||
<div className="vfc-justifs">
|
||||
{files.map((f, fi) => {
|
||||
const nc = nonConformesLigne.find(nc => nc.fileName === f.fileName);
|
||||
const status = nc ? 'nok' : 'ok';
|
||||
const isImg = /\.(jpg|jpeg|png|gif|webp)$/i.test(f.fileName);
|
||||
const short = f.fileName.length > 44 ? f.fileName.slice(0, 42) + '…' : f.fileName;
|
||||
return (
|
||||
<div key={fi} className={`vfc-jrow ${status}`}>
|
||||
<span className="vfc-jicon">{isImg ? '🖼️' : '📄'}</span>
|
||||
<div className="vfc-jinfo">
|
||||
<div className={`vfc-jname ${status}`}>{short}</div>
|
||||
{nc && <div className="vfc-jreason">↳ {nc.motif}</div>}
|
||||
</div>
|
||||
<span className={`vfc-jstatus ${status}`}>{status === 'ok' ? 'Conforme' : 'Non conforme'}</span>
|
||||
{setPreviewUrl && (
|
||||
<button className="vf2-btn-voir"
|
||||
onClick={() => setPreviewUrl({ url: proxyUrl(f.uploadUrl), name: f.fileName })}>
|
||||
<Eye size={11} /> Voir
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{files.length === 0 && isKm && (
|
||||
<div className="vfc-no-km">✓ Indemnité kilométrique — aucun justificatif requis</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{lignesData.length > 1 && (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '7px 12px', marginTop: 8, background: 'linear-gradient(90deg,#eef2ff,#f0fdf4)', border: '1px solid #c7d2fe', borderRadius: 8 }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 700, color: '#6b7280' }}>TOTAL — {lignesData.length} dépenses</span>
|
||||
<span style={{ fontSize: 15, fontWeight: 900, color: '#111827', fontFamily: 'DM Mono, monospace' }}>{fmt(h.montant || 0)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fichierApprobation.length > 0 && (
|
||||
<div>
|
||||
<div className="vf2-section-label">Document d'approbation</div>
|
||||
<div className="vf2-approval-box">
|
||||
<div className="vf2-approval-head">
|
||||
<FileText size={15} color="#7c3aed" />
|
||||
<span className="vf2-approval-label">PDF d'approbation signé</span>
|
||||
</div>
|
||||
<div className="vf2-approval-files">
|
||||
{fichierApprobation.map((f, fi) => {
|
||||
const isImg = /\.(jpg|jpeg|png|gif|webp)$/i.test(f.fileName);
|
||||
const short = f.fileName.length > 44 ? f.fileName.slice(0, 42) + '…' : f.fileName;
|
||||
return (
|
||||
<div key={fi} className="vfc-jrow ok">
|
||||
<span className="vfc-jicon">{isImg ? '🖼️' : '📄'}</span>
|
||||
<div className="vfc-jinfo">
|
||||
<div className="vfc-jname ok">{short}</div>
|
||||
</div>
|
||||
<span className="vfc-jstatus ok">Signé</span>
|
||||
{setPreviewUrl && (
|
||||
<button className="vf2-btn-voir"
|
||||
onClick={() => setPreviewUrl({ url: proxyUrl(f.uploadUrl), name: f.fileName })}>
|
||||
<Eye size={11} /> Voir
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Modal NOK ── */}
|
||||
{modalNok && (
|
||||
<div className="vf2-modal-backdrop" onClick={() => { setModalNok(null); setNokReason(''); }}>
|
||||
<div className="vf2-modal" onClick={e => e.stopPropagation()}>
|
||||
<div style={{ padding: '16px 20px', display: 'flex', alignItems: 'center', gap: 12, borderBottom: '1px solid #fee2e2', background: '#fff5f5' }}>
|
||||
<div style={{ width: 36, height: 36, borderRadius: '50%', background: '#fee2e2', border: '1px solid #fca5a5', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 18, flexShrink: 0 }}>⚠️</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 700, fontSize: 14, color: '#991b1b' }}>Justificatif non conforme</div>
|
||||
<div style={{ fontSize: 11, color: '#6b7280', marginTop: 2 }}>Précisez le motif pour notifier le collaborateur</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: '18px 20px', display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 12px', background: '#f9fafb', border: '1px solid #e5e7eb', borderRadius: 8 }}>
|
||||
<span style={{ fontSize: 18 }}>📄</span>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, color: '#6b7280' }}>Fichier concerné</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: '#111827', marginTop: 1 }}>{modalNok.fileName}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, color: '#374151', fontWeight: 600, display: 'block', marginBottom: 6 }}>
|
||||
Motif de non-conformité <span style={{ color: '#dc2626' }}>*</span>
|
||||
</label>
|
||||
<textarea value={nokReason} onChange={e => setNokReason(e.target.value)} autoFocus
|
||||
placeholder="Ex : Justificatif illisible, montant différent, facture non conforme TVA…"
|
||||
style={{ width: '100%', minHeight: 80, resize: 'vertical', padding: '8px 10px', fontSize: 13, borderRadius: 8, 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: 11, color: '#dc2626', marginTop: 4 }}>Le motif est obligatoire</div>}
|
||||
</div>
|
||||
<label style={{ display: 'flex', alignItems: 'flex-start', gap: 10, padding: '12px 14px', background: '#f9fafb', border: '1px solid #e5e7eb', borderRadius: 8, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={notifyOnNok} onChange={e => setNotifyOnNok(e.target.checked)} style={{ marginTop: 2, accentColor: '#dc2626', cursor: 'pointer', flexShrink: 0 }} />
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, color: '#111827', fontSize: 13 }}>Notifier par email</div>
|
||||
<div style={{ fontSize: 11, color: '#6b7280', marginTop: 2 }}>Le collaborateur et son validateur N1 recevront un email avec le motif</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ padding: '14px 20px', borderTop: '1px solid #e5e7eb', display: 'flex', justifyContent: 'flex-end', gap: 10, background: '#f9fafb' }}>
|
||||
<button onClick={() => { setModalNok(null); setNokReason(''); }}
|
||||
style={{ padding: '8px 16px', background: '#fff', border: '1px solid #d1d5db', borderRadius: 8, cursor: 'pointer', fontFamily: 'inherit', fontSize: 13, color: '#374151', fontWeight: 500 }}>
|
||||
Annuler
|
||||
</button>
|
||||
<button disabled={!nokReason.trim() || nokSending}
|
||||
onClick={async () => {
|
||||
if (!nokReason.trim() || !modalNok) return;
|
||||
setNokSending(true);
|
||||
try {
|
||||
const res = await fetch(`${API}/api/verificateur/notes/${modalNok.noteId}/non-conforme`, {
|
||||
method: 'POST',
|
||||
headers: hdrs,
|
||||
body: JSON.stringify({
|
||||
fileName: modalNok.fileName,
|
||||
motif: nokReason.trim(),
|
||||
notifier: notifyOnNok,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Erreur');
|
||||
setJustifStates(prev => ({ ...prev, [modalNok.fileKey]: { status: 'nok', comment: nokReason.trim() } }));
|
||||
setModalNok(null);
|
||||
setNokReason('');
|
||||
} catch (e: any) { alert(e.message); }
|
||||
finally { setNokSending(false); }
|
||||
}}
|
||||
style={{ padding: '8px 18px', background: nokReason.trim() && !nokSending ? '#dc2626' : '#fca5a5', color: '#fff', border: 'none', borderRadius: 8, cursor: nokReason.trim() && !nokSending ? 'pointer' : 'not-allowed', fontFamily: 'inherit', fontSize: 13, fontWeight: 700, transition: 'background .15s' }}>
|
||||
{nokSending ? 'Envoi…' : 'Confirmer non conforme'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user