resolutionano
This commit is contained in:
@@ -129,7 +129,13 @@ export function preparerLignesPDF(lignesParsed, tarifKm = 0.697) {
|
|||||||
const km = parseFloat(l.km) || 0;
|
const km = parseFloat(l.km) || 0;
|
||||||
const cv = parseInt(l.chevaux) || 7;
|
const cv = parseInt(l.chevaux) || 7;
|
||||||
|
|
||||||
const indemniteKm = isKm ? getIndemniteKm(km, cv) : 0;
|
const indemniteKm = isKm ? (() => {
|
||||||
|
const b = BAREME_KM[cv];
|
||||||
|
if (!b || km <= 0) return 0;
|
||||||
|
if (km <= 5000) return parseFloat((km * b.t1).toFixed(2));
|
||||||
|
if (km <= 20000) return parseFloat((km * b.t2_a + b.t2_b).toFixed(2));
|
||||||
|
return parseFloat((km * b.t3).toFixed(2));
|
||||||
|
})() : 0;
|
||||||
const tarifKmAffiche = isKm ? (km > 0 ? parseFloat((indemniteKm / km).toFixed(3)) : tarifKm) : 0;
|
const tarifKmAffiche = isKm ? (km > 0 ? parseFloat((indemniteKm / km).toFixed(3)) : tarifKm) : 0;
|
||||||
|
|
||||||
const montantAjuste = l.montantAjuste === true;
|
const montantAjuste = l.montantAjuste === true;
|
||||||
@@ -208,6 +214,7 @@ export function preparerLignesPDF(lignesParsed, tarifKm = 0.697) {
|
|||||||
// generateFicheSignee
|
// generateFicheSignee
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
export async function generateFicheSignee(note, signatures = []) {
|
export async function generateFicheSignee(note, signatures = []) {
|
||||||
|
console.log('🔍 generateFicheSignee montantServeur reçu =', note.montant);
|
||||||
const tarifKm = parseFloat(note.tarifKm) || 0.697;
|
const tarifKm = parseFloat(note.tarifKm) || 0.697;
|
||||||
|
|
||||||
let lignesPDF = [];
|
let lignesPDF = [];
|
||||||
@@ -244,22 +251,23 @@ export async function generateFicheSignee(note, signatures = []) {
|
|||||||
mois = m.charAt(0).toUpperCase() + m.slice(1);
|
mois = m.charAt(0).toUpperCase() + m.slice(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
return _buildPDF({
|
return _buildPDF({
|
||||||
reference: note.reference || '',
|
reference: note.reference || '',
|
||||||
nomPrenom: note.nomPrenom || note.collaborateur || '',
|
nomPrenom: note.nomPrenom || note.collaborateur || '',
|
||||||
mois,
|
mois,
|
||||||
departement: note.departement || '',
|
departement: note.departement || '',
|
||||||
lignes: lignesPDF,
|
lignes: lignesPDF,
|
||||||
tarifKm,
|
tarifKm,
|
||||||
signatures,
|
signatures,
|
||||||
statut: note.statut || 'enattente',
|
statut: note.statut || 'enattente',
|
||||||
});
|
montantServeur: note.montant ? parseFloat(note.montant) : null,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// _buildPDF
|
// _buildPDF
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
function _buildPDF({ reference, nomPrenom, mois, departement, lignes, tarifKm, signatures }) {
|
function _buildPDF({ reference, nomPrenom, mois, departement, lignes, tarifKm, signatures, montantServeur }) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const doc = new PDFDocument({
|
const doc = new PDFDocument({
|
||||||
size: 'A4', layout: 'landscape', margin: 0,
|
size: 'A4', layout: 'landscape', margin: 0,
|
||||||
@@ -367,7 +375,7 @@ function _buildPDF({ reference, nomPrenom, mois, departement, lignes, tarifKm, s
|
|||||||
lib: libelleAffiche,
|
lib: libelleAffiche,
|
||||||
km: km > 0 ? f2(km) : '',
|
km: km > 0 ? f2(km) : '',
|
||||||
tarifKm: tarif > 0 ? f3(tarif) : '',
|
tarifKm: tarif > 0 ? f3(tarif) : '',
|
||||||
sousKm: sousKm > 0 ? f2(sousKm) : '',
|
sousKm: sousKm > 0 ? f2(sousKm) + ' €' : '',
|
||||||
ttc: f2(ttc),
|
ttc: f2(ttc),
|
||||||
tva21: f2(t21), tva55: f2(t55),
|
tva21: f2(t21), tva55: f2(t55),
|
||||||
tva10: f2(t10), tva20: f2(t20),
|
tva10: f2(t10), tva20: f2(t20),
|
||||||
@@ -426,7 +434,7 @@ function _buildPDF({ reference, nomPrenom, mois, departement, lignes, tarifKm, s
|
|||||||
const totMap = {
|
const totMap = {
|
||||||
km: totKm > 0 ? f2(totKm) : '',
|
km: totKm > 0 ? f2(totKm) : '',
|
||||||
tarifKm: '',
|
tarifKm: '',
|
||||||
sousKm: totSousKm > 0 ? f2(totSousKm) : '',
|
sousKm: totSousKm > 0 ? f2(totSousKm) + ' €' : '',
|
||||||
ttc: f2(totTTC),
|
ttc: f2(totTTC),
|
||||||
tva21: f2(totT21), tva55: f2(totT55),
|
tva21: f2(totT21), tva55: f2(totT55),
|
||||||
tva10: f2(totT10), tva20: f2(totT20),
|
tva10: f2(totT10), tva20: f2(totT20),
|
||||||
@@ -444,7 +452,7 @@ function _buildPDF({ reference, nomPrenom, mois, departement, lignes, tarifKm, s
|
|||||||
|
|
||||||
// ── Zone bas ─────────────────────────────────────────────────
|
// ── Zone bas ─────────────────────────────────────────────────
|
||||||
const footY = totalY + ROW_H + 12;
|
const footY = totalY + ROW_H + 12;
|
||||||
const montantR = parseFloat((totTTC + totSousKm).toFixed(2));
|
const montantR = parseFloat((totTTC + totSousKm).toFixed(2));
|
||||||
const bw = 64;
|
const bw = 64;
|
||||||
|
|
||||||
// ✅ Légende proratisation si au moins une ligne ajustée
|
// ✅ Légende proratisation si au moins une ligne ajustée
|
||||||
@@ -469,18 +477,32 @@ function _buildPDF({ reference, nomPrenom, mois, departement, lignes, tarifKm, s
|
|||||||
|
|
||||||
const labelOffsetY = hasProrata ? 16 : 0;
|
const labelOffsetY = hasProrata ? 16 : 0;
|
||||||
|
|
||||||
doc.font('Helvetica-Bold').fontSize(9).fillColor(C.dark)
|
// APRÈS
|
||||||
.text('Montant total à rembourser', MARGIN, footY + 4 + labelOffsetY, { lineBreak: false });
|
doc.font('Helvetica-Bold').fontSize(9).fillColor(C.dark)
|
||||||
drawRect(doc, MARGIN + 180, footY + labelOffsetY, bw + 10, 18, C.amountBg, C.border, 0.5);
|
.text('Montant total à rembourser', MARGIN, footY + 4 + labelOffsetY, { lineBreak: false });
|
||||||
doc.font('Helvetica-Bold').fontSize(11).fillColor(C.blue)
|
|
||||||
.text(f2(montantR) + ' €',
|
// Détail calcul km + dépenses
|
||||||
MARGIN + 182, footY + 3.5 + labelOffsetY,
|
doc.font('Helvetica').fontSize(7.5).fillColor(C.grey)
|
||||||
{ width: bw + 6, align: 'right', lineBreak: false });
|
.text(
|
||||||
|
totSousKm > 0 && totTTC > 0
|
||||||
|
? `${f2(totSousKm)} € (km) + ${f2(totTTC)} € (dépenses) =`
|
||||||
|
: totSousKm > 0
|
||||||
|
? `${f2(totSousKm)} € (indemnités kilométriques) =`
|
||||||
|
: `${f2(totTTC)} € (dépenses) =`,
|
||||||
|
MARGIN, footY + 17 + labelOffsetY,
|
||||||
|
{ lineBreak: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
drawRect(doc, MARGIN + 220, footY + 13 + labelOffsetY, bw + 10, 18, C.amountBg, C.border, 0.5);
|
||||||
|
doc.font('Helvetica-Bold').fontSize(11).fillColor(C.blue)
|
||||||
|
.text(f2(montantR) + ' €',
|
||||||
|
MARGIN + 222, footY + 16.5 + labelOffsetY,
|
||||||
|
{ width: bw + 6, align: 'right', lineBreak: false });
|
||||||
|
|
||||||
doc.font('Helvetica').fontSize(6.5).fillColor(C.grey)
|
doc.font('Helvetica').fontSize(6.5).fillColor(C.grey)
|
||||||
.text(
|
.text(
|
||||||
`Tarif km appliqué : ${tarifKm.toFixed(3)} €/km — Sous-total km : ${f2(totSousKm)} € — TTC hors km : ${f2(totTTC)} €`,
|
` Sous-total km : ${f2(totSousKm)} € — TTC hors km : ${f2(totTTC)} € — Total : ${f2(totSousKm)} € + ${f2(totTTC)} € = ${f2(montantR)} €`,
|
||||||
MARGIN, footY + 24 + labelOffsetY, { lineBreak: false }
|
MARGIN, footY + 34 + labelOffsetY, { lineBreak: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── Signatures ────────────────────────────────────────────────
|
// ── Signatures ────────────────────────────────────────────────
|
||||||
|
|||||||
+573
-292
File diff suppressed because it is too large
Load Diff
+1216
-472
File diff suppressed because it is too large
Load Diff
+304
-101
@@ -61,6 +61,7 @@ interface NouvelleNoteProps {
|
|||||||
commentaireInitial?: string;
|
commentaireInitial?: string;
|
||||||
depensesInitiales?: any[];
|
depensesInitiales?: any[];
|
||||||
onNavigateToProfil?: () => void;
|
onNavigateToProfil?: () => void;
|
||||||
|
onBrouillonChange?: (id: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── CONSTANTES ────────────────────────────────────────
|
// ── CONSTANTES ────────────────────────────────────────
|
||||||
@@ -89,7 +90,7 @@ const TVA_EXCEL_COLS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
// Catégories utilisant le tableau Excel TVA
|
// Catégories utilisant le tableau Excel TVA
|
||||||
const CATS_EXCEL_TVA = ["repas", "hebergement", "transport"];
|
const CATS_EXCEL_TVA = ["repas", "hebergement", "transport", "autre"];
|
||||||
|
|
||||||
const CAT_ICONS: Record<string, string> = {
|
const CAT_ICONS: Record<string, string> = {
|
||||||
"Deplacement kilometrique": "🚗",
|
"Deplacement kilometrique": "🚗",
|
||||||
@@ -240,7 +241,7 @@ interface ProfilVehiculeData {
|
|||||||
function newDepense(defaultChevaux = 7): Depense {
|
function newDepense(defaultChevaux = 7): Depense {
|
||||||
return {
|
return {
|
||||||
id: Math.random(), categorie: "", date: "", libelle: "", description: "",
|
id: Math.random(), categorie: "", date: "", libelle: "", description: "",
|
||||||
km: "", chevaux: defaultChevaux, tvaItems: [{ taux: "20", montantTTC: "" }],
|
km: "", chevaux: defaultChevaux, tvaItems: [{ taux: "", montantTTC: "" }],
|
||||||
nombreParticipants: "", participants: [{ nom: "", prenom: "", societe: "" }],
|
nombreParticipants: "", participants: [{ nom: "", prenom: "", societe: "" }],
|
||||||
files: [], filesMeta: [], qrLink: null, qrUploaded: false, qrNoteRef: "", qrFiles: [],
|
files: [], filesMeta: [], qrLink: null, qrUploaded: false, qrNoteRef: "", qrFiles: [],
|
||||||
nuits: "",
|
nuits: "",
|
||||||
@@ -248,13 +249,35 @@ function newDepense(defaultChevaux = 7): Depense {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function serializeDepenses(depenses: Depense[]) {
|
function serializeDepenses(depenses: Depense[]) {
|
||||||
return depenses.map(d => ({
|
return depenses.map(d => {
|
||||||
...d,
|
const reactFiles = d.files ?? [];
|
||||||
files: [],
|
const storedFiles = getStoredFiles(d.id);
|
||||||
filesMeta: d.filesMeta ?? [],
|
const allFiles = storedFiles.length > 0 ? storedFiles : reactFiles;
|
||||||
qrFiles: d.qrFiles ?? [],
|
|
||||||
tvaItems: d.tvaItems.map(tvaItemToBackend),
|
// Récupérer les metas déjà calculées pour les fichiers en mémoire
|
||||||
}));
|
const inMemoryMetas = (d.filesMeta ?? []).filter(
|
||||||
|
m => allFiles.some(f => f.name === m.name && f.size === m.size)
|
||||||
|
);
|
||||||
|
// Pour les nouveaux fichiers sans meta encore
|
||||||
|
const missingMetas: FileMeta[] = allFiles
|
||||||
|
.filter(f => !inMemoryMetas.some(m => m.name === f.name && m.size === f.size))
|
||||||
|
.map(f => ({ name: f.name, type: f.type, size: f.size }));
|
||||||
|
|
||||||
|
const freshMetas = [...inMemoryMetas, ...missingMetas];
|
||||||
|
|
||||||
|
// Conserver les metas de fichiers déjà uploadés (QR, session précédente)
|
||||||
|
const existingNonMemory = (d.filesMeta ?? []).filter(
|
||||||
|
m => !allFiles.some(f => f.name === m.name && f.size === m.size)
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...d,
|
||||||
|
files: [],
|
||||||
|
filesMeta: [...freshMetas, ...existingNonMemory],
|
||||||
|
qrFiles: d.qrFiles ?? [],
|
||||||
|
tvaItems: d.tvaItems.map(tvaItemToBackend),
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── FILES STORE ───────────────────────────────────────
|
// ── FILES STORE ───────────────────────────────────────
|
||||||
@@ -682,6 +705,32 @@ select.nn-input {
|
|||||||
@media(max-width: 900px) { .nn-layout { flex-direction: column; } .nn-side { width: 100%; position: static; } }
|
@media(max-width: 900px) { .nn-layout { flex-direction: column; } .nn-side { width: 100%; position: static; } }
|
||||||
@media(max-width: 600px) { .nn-meta { grid-template-columns: 1fr 1fr; } .nn-tva-row { grid-template-columns: 22px 22px 110px 1fr 70px 60px; } }
|
@media(max-width: 600px) { .nn-meta { grid-template-columns: 1fr 1fr; } .nn-tva-row { grid-template-columns: 22px 22px 110px 1fr 70px 60px; } }
|
||||||
@media(max-width: 480px) { .nn-meta { grid-template-columns: 1fr; } .nn-row.g3 { grid-template-columns: 1fr 1fr; } }
|
@media(max-width: 480px) { .nn-meta { grid-template-columns: 1fr; } .nn-row.g3 { grid-template-columns: 1fr 1fr; } }
|
||||||
|
.nn-tva-alert {
|
||||||
|
background: rgba(245,158,11,.08);
|
||||||
|
border: 2px solid rgba(245,158,11,.5);
|
||||||
|
border-radius: 9px;
|
||||||
|
padding: 10px;
|
||||||
|
animation: nn-pulse-border 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes nn-pulse-border {
|
||||||
|
0%, 100% { border-color: rgba(245,158,11,.5); }
|
||||||
|
50% { border-color: rgba(245,158,11,1); }
|
||||||
|
}
|
||||||
|
.nn-tva-alert-banner {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
background: rgba(245,158,11,.15);
|
||||||
|
border: 1.5px solid rgba(245,158,11,.6);
|
||||||
|
border-radius: 7px; padding: 8px 12px;
|
||||||
|
font-size: 12px; font-weight: 700; color: #92400e;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.nn-tva-select-highlight {
|
||||||
|
border: 2px solid #f59e0b !important;
|
||||||
|
background: rgba(245,158,11,.07) !important;
|
||||||
|
font-weight: 700 !important;
|
||||||
|
color: #92400e !important;
|
||||||
|
box-shadow: 0 0 0 3px rgba(245,158,11,.2) !important;
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// ── VIEWER MODAL ──────────────────────────────────────
|
// ── VIEWER MODAL ──────────────────────────────────────
|
||||||
@@ -1114,6 +1163,7 @@ const DepenseCard = React.memo(({
|
|||||||
onDelete: (id: number) => void;
|
onDelete: (id: number) => void;
|
||||||
onGenerateQR?: (id: number) => void;
|
onGenerateQR?: (id: number) => void;
|
||||||
onNavigateToProfil?: () => void;
|
onNavigateToProfil?: () => void;
|
||||||
|
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
apiBaseUrl: string;
|
apiBaseUrl: string;
|
||||||
profilVehicule?: ProfilVehiculeData | null;
|
profilVehicule?: ProfilVehiculeData | null;
|
||||||
@@ -1252,7 +1302,7 @@ const DepenseCard = React.memo(({
|
|||||||
<div className="nn-repas-info" style={{ marginTop: 10, marginBottom: 0 }}>
|
<div className="nn-repas-info" style={{ marginTop: 10, marginBottom: 0 }}>
|
||||||
<span className="nn-repas-info-icon">🍽️</span>
|
<span className="nn-repas-info-icon">🍽️</span>
|
||||||
<span>
|
<span>
|
||||||
Catégorie <strong>Repas</strong> sélectionnée — plafond <strong>25 € / personne</strong> (sauf repas événementiel).
|
Catégorie <strong>Repas</strong> sélectionnée — plafond <strong>25 € / personne</strong> .
|
||||||
Renseignez le nombre de convives en bas pour contrôler le seuil en temps réel.
|
Renseignez le nombre de convives en bas pour contrôler le seuil en temps réel.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1262,15 +1312,19 @@ const DepenseCard = React.memo(({
|
|||||||
<div className="nn-field">
|
<div className="nn-field">
|
||||||
<label className="nn-fl">Catégorie <span>*</span></label>
|
<label className="nn-fl">Catégorie <span>*</span></label>
|
||||||
<select className="nn-input" value={depense.categorie}
|
<select className="nn-input" value={depense.categorie}
|
||||||
onChange={e => {
|
onChange={e => {
|
||||||
set("categorie", e.target.value);
|
set("categorie", e.target.value);
|
||||||
set("km", "");
|
set("km", "");
|
||||||
set("tvaItems", [{ taux: "20", montantTTC: "" }]);
|
set("tvaItems", [{ taux: "20", montantTTC: "" }]);
|
||||||
if (!e.target.value.toLowerCase().includes("repas")) {
|
// ✅ Ajouter ceci :
|
||||||
set("nombreParticipants", "");
|
if (e.target.value.toLowerCase().includes("kilom") && profilVehicule?.chevaux) {
|
||||||
set("participants", [{ nom: "", prenom: "", societe: "" }]);
|
set("chevaux", profilVehicule.chevaux);
|
||||||
}
|
}
|
||||||
}}>
|
if (!e.target.value.toLowerCase().includes("repas")) {
|
||||||
|
set("nombreParticipants", "");
|
||||||
|
set("participants", [{ nom: "", prenom: "", societe: "" }]);
|
||||||
|
}
|
||||||
|
}}>
|
||||||
<option value="">— Choisir —</option>
|
<option value="">— Choisir —</option>
|
||||||
{CATEGORIES_DEFAULT.map(c => <option key={c} value={c}>{CAT_ICONS[c]} {c}</option>)}
|
{CATEGORIES_DEFAULT.map(c => <option key={c} value={c}>{CAT_ICONS[c]} {c}</option>)}
|
||||||
</select>
|
</select>
|
||||||
@@ -1371,7 +1425,7 @@ const DepenseCard = React.memo(({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── TVA EXCEL ── */}
|
{/* ── TVA EXCEL ── */}
|
||||||
{!isKm && isExcel && (
|
{!isKm && isExcel && depense.categorie && (
|
||||||
<TvaExcelTable
|
<TvaExcelTable
|
||||||
tvaItems={tvaItems}
|
tvaItems={tvaItems}
|
||||||
onUpdate={items => set("tvaItems", items)}
|
onUpdate={items => set("tvaItems", items)}
|
||||||
@@ -1379,7 +1433,8 @@ const DepenseCard = React.memo(({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── NUITS (Hébergement) ── */}
|
{/* ── NUITS (Hébergement) ── */}
|
||||||
{depense.categorie.toLowerCase().includes("hebergement") && (
|
{depense.categorie.toLowerCase().includes("hebergement") && depense.categorie && (
|
||||||
|
|
||||||
<div className="nn-nuits-box">
|
<div className="nn-nuits-box">
|
||||||
<div>
|
<div>
|
||||||
<div className="nn-nuits-label">🌙 Nombre de nuits</div>
|
<div className="nn-nuits-label">🌙 Nombre de nuits</div>
|
||||||
@@ -1416,46 +1471,94 @@ const DepenseCard = React.memo(({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── TVA CLASSIQUE ── */}
|
{/* ── TVA CLASSIQUE ── */}
|
||||||
{!isKm && !isExcel && (
|
{!isKm && !isExcel && depense.categorie && (
|
||||||
<div className="nn-tva-box">
|
<div className={`nn-tva-box${tvaItems.some(it => !it.taux || it.taux === "") ? " nn-tva-alert" : ""}`}>
|
||||||
<div className="nn-tva-head">
|
<div className="nn-tva-head">
|
||||||
<span className="nn-tva-title">Montants & TVA</span>
|
<span className="nn-tva-title">Montants & TVA</span>
|
||||||
<span style={{ fontSize: 10, color: "var(--text-secondary, #9ca3af)", fontStyle: "italic" }}>
|
<span style={{ fontSize: 10, color: "var(--text-secondary, #9ca3af)", fontStyle: "italic" }}>
|
||||||
Saisir le montant TTC — HT calculé automatiquement
|
Saisir le montant TTC — HT calculé automatiquement
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Bandeau alerte si taux non confirmé ── */}
|
||||||
|
{tvaItems.some(it => !it.taux || it.taux === "") && (
|
||||||
|
<div className="nn-tva-alert-banner">
|
||||||
|
<span style={{ fontSize: 16 }}>⚠️</span>
|
||||||
|
<span>Vérifiez le <strong>taux de TVA</strong> avant de saisir le montant — il impacte directement le montant HT remboursé.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="nn-tva-row" style={{ marginBottom: 4 }}>
|
<div className="nn-tva-row" style={{ marginBottom: 4 }}>
|
||||||
<div /><div />
|
<div /><div />
|
||||||
{["Taux TVA", "Montant TTC", "HT calculé", "TVA"].map((h, i) => (
|
{["Taux TVA", "Montant TTC", "HT calculé", "TVA"].map((h, i) => (
|
||||||
<div key={i} style={{ fontSize: 9, fontWeight: 700, textTransform: "uppercase", color: "var(--text-secondary, #9ca3af)", letterSpacing: ".04em" }}>{h}</div>
|
<div key={i} style={{
|
||||||
|
fontSize: 9, fontWeight: 700, textTransform: "uppercase",
|
||||||
|
color: i === 0 ? "#f59e0b" : "var(--text-secondary, #9ca3af)",
|
||||||
|
letterSpacing: ".04em",
|
||||||
|
}}>
|
||||||
|
{i === 0 ? "⚠ " + h + " *" : h}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tvaItems.map((item, idx) => {
|
{tvaItems.map((item, idx) => {
|
||||||
const ttc = parseFloat(item.montantTTC) || 0;
|
const ttc = parseFloat(item.montantTTC) || 0;
|
||||||
const taux = parseFloat(item.taux) || 0;
|
const taux = parseFloat(item.taux) || 0;
|
||||||
const ht = ttcToHt(ttc, taux);
|
const ht = ttcToHt(ttc, taux);
|
||||||
const tva = ttcToTva(ttc, taux);
|
const tva = ttcToTva(ttc, taux);
|
||||||
|
const tauxManquant = !item.taux || item.taux === "";
|
||||||
return (
|
return (
|
||||||
<div key={idx} className="nn-tva-row">
|
<div key={idx} className="nn-tva-row">
|
||||||
<button type="button" className="nn-tva-btn add" onClick={() => addTvaAfter(idx)} title="Ajouter une ligne après">+</button>
|
<button type="button" className="nn-tva-btn add"
|
||||||
<button type="button" className="nn-tva-btn rm" disabled={tvaItems.length <= 1} onClick={() => removeTva(idx)} title="Supprimer cette ligne">−</button>
|
onClick={() => addTvaAfter(idx)} title="Ajouter une ligne après">+</button>
|
||||||
<select className="nn-input" style={{ fontSize: 12, padding: "6px 8px" }}
|
<button type="button" className="nn-tva-btn rm"
|
||||||
value={item.taux} onChange={e => updTva(idx, "taux", e.target.value)}>
|
disabled={tvaItems.length <= 1}
|
||||||
<option value="">— Taux —</option>
|
onClick={() => removeTva(idx)} title="Supprimer cette ligne">−</button>
|
||||||
{TAUX_TVA.map((t, i) => (
|
|
||||||
<option key={i} value={String(t.taux)}>{t.libelle}</option>
|
{/* ── Select TVA mis en évidence si non renseigné ── */}
|
||||||
))}
|
<div style={{ position: "relative" }}>
|
||||||
</select>
|
<select
|
||||||
|
className={`nn-input${tauxManquant ? " nn-tva-select-highlight" : ""}`}
|
||||||
|
style={{ fontSize: 12, padding: "6px 8px" }}
|
||||||
|
value={item.taux}
|
||||||
|
onChange={e => updTva(idx, "taux", e.target.value)}>
|
||||||
|
<option value="">⚠ Choisir un taux *</option>
|
||||||
|
{TAUX_TVA.map((t, i) => (
|
||||||
|
<option key={i} value={String(t.taux)}>{t.libelle}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{tauxManquant && (
|
||||||
|
<span style={{
|
||||||
|
position: "absolute", right: 26, top: "50%",
|
||||||
|
transform: "translateY(-50%)",
|
||||||
|
fontSize: 13, pointerEvents: "none",
|
||||||
|
}}>⚠️</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{ position: "relative" }}>
|
<div style={{ position: "relative" }}>
|
||||||
<input type="number" min="0" step="0.01" className="nn-input"
|
<input type="number" min="0" step="0.01" className="nn-input"
|
||||||
style={{ fontSize: 12, padding: "6px 22px 6px 8px" }}
|
style={{
|
||||||
|
fontSize: 12, padding: "6px 22px 6px 8px",
|
||||||
|
borderColor: tauxManquant ? "rgba(245,158,11,.5)" : undefined,
|
||||||
|
background: tauxManquant ? "rgba(245,158,11,.04)" : undefined,
|
||||||
|
}}
|
||||||
value={item.montantTTC}
|
value={item.montantTTC}
|
||||||
onChange={e => updTva(idx, "montantTTC", e.target.value)}
|
onChange={e => updTva(idx, "montantTTC", e.target.value)}
|
||||||
placeholder="0,00" />
|
placeholder="0,00" />
|
||||||
<span style={{ position: "absolute", right: 7, top: "50%", transform: "translateY(-50%)", fontSize: 11, color: "#9ca3af", pointerEvents: "none" }}>€</span>
|
<span style={{
|
||||||
|
position: "absolute", right: 7, top: "50%",
|
||||||
|
transform: "translateY(-50%)", fontSize: 11,
|
||||||
|
color: "#9ca3af", pointerEvents: "none",
|
||||||
|
}}>€</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`nn-tva-computed ttc${ttc <= 0 ? " na" : ""}`}>
|
||||||
|
{ttc > 0 ? fmt(ht) : "—"}
|
||||||
|
</div>
|
||||||
|
<div className={`nn-tva-computed tva${tva <= 0 ? " na" : ""}`}>
|
||||||
|
{tva > 0 ? fmt(tva) : taux === 0 && ttc > 0 ? "0 €" : "—"}
|
||||||
</div>
|
</div>
|
||||||
<div className={`nn-tva-computed ttc${ttc <= 0 ? " na" : ""}`}>{ttc > 0 ? fmt(ht) : "—"}</div>
|
|
||||||
<div className={`nn-tva-computed tva${tva <= 0 ? " na" : ""}`}>{tva > 0 ? fmt(tva) : taux === 0 && ttc > 0 ? "0 €" : "—"}</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -1486,7 +1589,7 @@ const DepenseCard = React.memo(({
|
|||||||
<div className="nn-repas-info">
|
<div className="nn-repas-info">
|
||||||
<span className="nn-repas-info-icon">ℹ️</span>
|
<span className="nn-repas-info-icon">ℹ️</span>
|
||||||
<span>
|
<span>
|
||||||
Un repas professionnel <strong>ne doit pas dépasser 25 € par personne</strong> (sauf repas événementiel).
|
Un repas professionnel <strong>ne doit pas dépasser 25 € par personne</strong>.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1728,7 +1831,7 @@ export default function NouvelleNote({
|
|||||||
initialBrouillonId = null,
|
initialBrouillonId = null,
|
||||||
libelleInitial = '', dateDebutInitiale = '', commentaireInitial = '',
|
libelleInitial = '', dateDebutInitiale = '', commentaireInitial = '',
|
||||||
depensesInitiales,
|
depensesInitiales,
|
||||||
onNavigateToProfil,
|
onNavigateToProfil, onBrouillonChange,
|
||||||
}: NouvelleNoteProps) {
|
}: NouvelleNoteProps) {
|
||||||
|
|
||||||
const initDepenses = (defaultCv = 7): Depense[] => {
|
const initDepenses = (defaultCv = 7): Depense[] => {
|
||||||
@@ -1756,6 +1859,7 @@ export default function NouvelleNote({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const [titre, setTitre] = useState(libelleInitial || "");
|
const [titre, setTitre] = useState(libelleInitial || "");
|
||||||
|
const [wasOnceActive, setWasOnceActive] = useState(false);
|
||||||
const [dateDebut, setDateDebut] = useState(dateDebutInitiale || "");
|
const [dateDebut, setDateDebut] = useState(dateDebutInitiale || "");
|
||||||
const [dateFin, setDateFin] = useState("");
|
const [dateFin, setDateFin] = useState("");
|
||||||
const [commentaire, setComment] = useState(commentaireInitial || "");
|
const [commentaire, setComment] = useState(commentaireInitial || "");
|
||||||
@@ -1780,6 +1884,22 @@ export default function NouvelleNote({
|
|||||||
const isFirstRender = useRef(true);
|
const isFirstRender = useRef(true);
|
||||||
const activeBrouillonIdRef = useRef<number | null>(initialBrouillonId);
|
const activeBrouillonIdRef = useRef<number | null>(initialBrouillonId);
|
||||||
|
|
||||||
|
const isSavingRef = useRef(false);
|
||||||
|
const needsResaveRef = useRef(false);
|
||||||
|
const latestStateRef = useRef({ titre, dateDebut, dateFin, commentaire, depenses });
|
||||||
|
const hasLoadedInitial = useRef(false);
|
||||||
|
const mountInitialIdRef = useRef(initialBrouillonId);
|
||||||
|
const persistRef = useRef<() => void>(() => { });
|
||||||
|
|
||||||
|
// snapshot de l'état toujours à jour pour le flush et la sauvegarde
|
||||||
|
useEffect(() => {
|
||||||
|
latestStateRef.current = { titre, dateDebut, dateFin, commentaire, depenses };
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeBrouillonId !== null) setWasOnceActive(true);
|
||||||
|
}, [activeBrouillonId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authToken) return;
|
if (!authToken) return;
|
||||||
fetch(`${apiBaseUrl}/api/profil/vehicule`, {
|
fetch(`${apiBaseUrl}/api/profil/vehicule`, {
|
||||||
@@ -1810,8 +1930,10 @@ export default function NouvelleNote({
|
|||||||
setDepenses(p => p.map(d => d.id === id ? { ...d, [f]: v } : d)), []);
|
setDepenses(p => p.map(d => d.id === id ? { ...d, [f]: v } : d)), []);
|
||||||
const handleDeleteDepense = useCallback((id: number) => { filesStore.delete(id); setDepenses(p => p.filter(d => d.id !== id)); }, []);
|
const handleDeleteDepense = useCallback((id: number) => { filesStore.delete(id); setDepenses(p => p.filter(d => d.id !== id)); }, []);
|
||||||
const handleAddDepense = useCallback(() => {
|
const handleAddDepense = useCallback(() => {
|
||||||
const d = newDepense(); setDepenses(p => [...p, d]); setExpandedId(d.id);
|
const d = newDepense(profilVehicule?.chevaux ?? 7);
|
||||||
}, []);
|
setDepenses(p => [...p, d]);
|
||||||
|
setExpandedId(d.id);
|
||||||
|
}, [profilVehicule]);
|
||||||
|
|
||||||
const generateQRForDepense = useCallback(async (depenseId: number) => {
|
const generateQRForDepense = useCallback(async (depenseId: number) => {
|
||||||
const tempRef = `NDF-DEP-${depenseId}-${Date.now()}`;
|
const tempRef = `NDF-DEP-${depenseId}-${Date.now()}`;
|
||||||
@@ -1903,61 +2025,135 @@ export default function NouvelleNote({
|
|||||||
}, [apiBaseUrl, getHeaders]);
|
}, [apiBaseUrl, getHeaders]);
|
||||||
useEffect(() => { fetchBrouillons(); }, [fetchBrouillons]);
|
useEffect(() => { fetchBrouillons(); }, [fetchBrouillons]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (depensesInitiales && depensesInitiales.length > 0) return;
|
if (depensesInitiales && depensesInitiales.length > 0) return;
|
||||||
if (!initialBrouillonId || !brouillons.length) return;
|
if (hasLoadedInitial.current) return; // déjà chargé
|
||||||
const b = brouillons.find(b => b.id === initialBrouillonId);
|
if (!mountInitialIdRef.current || !brouillons.length) return;
|
||||||
if (b) loadBrouillon(b);
|
const b = brouillons.find(x => x.id === mountInitialIdRef.current);
|
||||||
}, [initialBrouillonId, brouillons]);
|
if (b) loadBrouillon(b);
|
||||||
|
hasLoadedInitial.current = true;
|
||||||
|
}, [brouillons]); // ← plus de dépendance sur initialBrouillonId
|
||||||
|
|
||||||
const scheduleSave = useCallback((state: any) => {
|
// ── persistBrouillon — envoi correct des fichiers ──
|
||||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
const persistBrouillon = useCallback(async () => {
|
||||||
setSaveStatus("saving");
|
if (isSavingRef.current) {
|
||||||
saveTimerRef.current = setTimeout(async () => {
|
needsResaveRef.current = true;
|
||||||
const currentId = activeBrouillonIdRef.current;
|
return;
|
||||||
const fd = new FormData();
|
}
|
||||||
fd.append("libelle", state.titre || "Sans titre");
|
isSavingRef.current = true;
|
||||||
fd.append("date", state.dateDebut || new Date().toISOString().split("T")[0]);
|
setSaveStatus("saving");
|
||||||
fd.append("description", state.commentaire || "");
|
try {
|
||||||
fd.append("lignes", JSON.stringify(serializeDepenses(state.depenses)));
|
const state = latestStateRef.current;
|
||||||
for (const dep of state.depenses) {
|
const currentId = activeBrouillonIdRef.current;
|
||||||
for (const file of getStoredFiles(dep.id)) fd.append(`files_${dep.id}`, file);
|
const fd = new FormData();
|
||||||
}
|
fd.append("libelle", state.titre || "Sans titre");
|
||||||
const headers = { Authorization: `Bearer ${authToken}` };
|
fd.append("date", state.dateDebut || new Date().toISOString().split("T")[0]);
|
||||||
try {
|
fd.append("description", state.commentaire || "");
|
||||||
if (currentId) {
|
fd.append("lignes", JSON.stringify(serializeDepenses(state.depenses)));
|
||||||
const res = await fetch(`${apiBaseUrl}/api/notes/brouillons/${currentId}`, { method: "PUT", headers, body: fd });
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.uploadedFiles) {
|
|
||||||
setDepenses(prev => prev.map(d => {
|
|
||||||
const uploaded = data.uploadedFiles[d.id];
|
|
||||||
if (!uploaded?.length) return d;
|
|
||||||
const newQrFiles = [
|
|
||||||
...(d.qrFiles ?? []),
|
|
||||||
...uploaded.map((f: any) => ({ ...f, origin: 'upload' as const }))
|
|
||||||
].filter((f, i, arr) => arr.findIndex(x => x.uploadUrl === f.uploadUrl) === i);
|
|
||||||
return { ...d, qrFiles: newQrFiles };
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const res = await fetch(`${apiBaseUrl}/api/notes/brouillons`, { method: "POST", headers, body: fd });
|
|
||||||
const created = await res.json();
|
|
||||||
activeBrouillonIdRef.current = created.id;
|
|
||||||
setActiveBrouillonId(created.id);
|
|
||||||
}
|
|
||||||
await fetchBrouillons();
|
|
||||||
setSaveStatus("saved");
|
|
||||||
state.depenses.forEach((d: Depense) => setStoredFiles(d.id, []));
|
|
||||||
} catch { setSaveStatus("error"); }
|
|
||||||
}, 1500);
|
|
||||||
}, [apiBaseUrl, authToken, fetchBrouillons]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
// ✅ FIX : lire depuis state.depenses.files ET filesStore
|
||||||
if (isFirstRender.current) { isFirstRender.current = false; return; }
|
for (const dep of state.depenses) {
|
||||||
scheduleSave({ titre, dateDebut, dateFin, commentaire, depenses });
|
// Fichiers en mémoire React (depense.files)
|
||||||
}, [titre, dateDebut, dateFin, commentaire, depenses, scheduleSave]);
|
const reactFiles = dep.files ?? [];
|
||||||
|
// Fichiers dans le store séparé
|
||||||
|
const storedFiles = getStoredFiles(dep.id);
|
||||||
|
|
||||||
|
// Fusionner sans doublons (filesStore peut contenir des copies de dep.files)
|
||||||
|
const allFiles = storedFiles.length > 0 ? storedFiles : reactFiles;
|
||||||
|
|
||||||
|
for (const file of allFiles) {
|
||||||
|
fd.append(`files_${dep.id}`, file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = { Authorization: `Bearer ${authToken}` };
|
||||||
|
|
||||||
|
if (currentId) {
|
||||||
|
const res = await fetch(`${apiBaseUrl}/api/notes/brouillons/${currentId}`, {
|
||||||
|
method: "PUT", headers, body: fd
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.uploadedFiles) {
|
||||||
|
setDepenses(prev => prev.map(d => {
|
||||||
|
const uploaded = data.uploadedFiles[d.id];
|
||||||
|
if (!uploaded?.length) return d;
|
||||||
|
const merged = [
|
||||||
|
...(d.qrFiles ?? []),
|
||||||
|
...uploaded.map((f: any) => ({ ...f, origin: 'upload' as const })),
|
||||||
|
].filter((f, i, arr) => arr.findIndex(x => x.uploadUrl === f.uploadUrl) === i);
|
||||||
|
setStoredFiles(d.id, []);
|
||||||
|
// ✅ Garder files en état React — seulement vider le store
|
||||||
|
// Les qrFiles contiennent déjà l'URL SharePoint pour le re-submit
|
||||||
|
return { ...d, qrFiles: merged };
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
// ✅ FIX : si pas d'uploadedFiles dans la réponse, vider quand même le store
|
||||||
|
// car le serveur a bien reçu les fichiers
|
||||||
|
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const res = await fetch(`${apiBaseUrl}/api/notes/brouillons`, {
|
||||||
|
method: "POST", headers, body: fd
|
||||||
|
});
|
||||||
|
const created = await res.json();
|
||||||
|
activeBrouillonIdRef.current = created.id;
|
||||||
|
setActiveBrouillonId(created.id);
|
||||||
|
onBrouillonChange?.(created.id);
|
||||||
|
|
||||||
|
// ✅ FIX : vider le store après création réussie
|
||||||
|
if (created.uploadedFiles) {
|
||||||
|
setDepenses(prev => prev.map(d => {
|
||||||
|
const uploaded = created.uploadedFiles[d.id];
|
||||||
|
if (!uploaded?.length) return d;
|
||||||
|
const merged = [
|
||||||
|
...(d.qrFiles ?? []),
|
||||||
|
...uploaded.map((f: any) => ({ ...f, origin: 'upload' as const })),
|
||||||
|
].filter((f, i, arr) => arr.findIndex(x => x.uploadUrl === f.uploadUrl) === i);
|
||||||
|
setStoredFiles(d.id, []);
|
||||||
|
return { ...d, files: [], qrFiles: merged };
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetchBrouillons();
|
||||||
|
setSaveStatus("saved");
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
setSaveStatus("error");
|
||||||
|
} finally {
|
||||||
|
isSavingRef.current = false;
|
||||||
|
if (needsResaveRef.current) {
|
||||||
|
needsResaveRef.current = false;
|
||||||
|
persistBrouillon();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [apiBaseUrl, authToken, fetchBrouillons, onBrouillonChange]);
|
||||||
|
|
||||||
|
const scheduleSave = useCallback(() => {
|
||||||
|
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||||||
|
setSaveStatus("saving");
|
||||||
|
saveTimerRef.current = setTimeout(() => {
|
||||||
|
saveTimerRef.current = null; // marque comme exécuté
|
||||||
|
persistBrouillon();
|
||||||
|
}, 1500);
|
||||||
|
}, [persistBrouillon]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isFirstRender.current) { isFirstRender.current = false; return; }
|
||||||
|
scheduleSave(); // plus besoin de passer l'état
|
||||||
|
}, [titre, dateDebut, dateFin, commentaire, depenses, scheduleSave]);
|
||||||
|
|
||||||
|
useEffect(() => { persistRef.current = persistBrouillon; });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (saveTimerRef.current) {
|
||||||
|
clearTimeout(saveTimerRef.current);
|
||||||
|
persistRef.current();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const loadBrouillon = (b: BrouillonServeur) => {
|
const loadBrouillon = (b: BrouillonServeur) => {
|
||||||
setTitre(b.libelle || ""); setComment(b.description || "");
|
setTitre(b.libelle || ""); setComment(b.description || "");
|
||||||
@@ -2045,12 +2241,19 @@ export default function NouvelleNote({
|
|||||||
}
|
}
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const depensesBackend = depenses.map(d => ({
|
const depensesBackend = depenses.map(d => {
|
||||||
...d,
|
// Fusionner : store (fichiers pas encore uploadés) + d.files (fichiers React)
|
||||||
files: getStoredFiles(d.id).length > 0 ? getStoredFiles(d.id) : d.files,
|
const storedFiles = getStoredFiles(d.id);
|
||||||
tvaItems: d.tvaItems.map(tvaItemToBackend),
|
const allFiles = storedFiles.length > 0
|
||||||
qrFiles: d.qrFiles ?? [],
|
? [...d.files, ...storedFiles.filter(sf => !d.files.some(rf => rf.name === sf.name && rf.size === sf.size))]
|
||||||
}));
|
: d.files;
|
||||||
|
return {
|
||||||
|
...d,
|
||||||
|
files: allFiles,
|
||||||
|
tvaItems: d.tvaItems.map(tvaItemToBackend),
|
||||||
|
qrFiles: d.qrFiles ?? [],
|
||||||
|
};
|
||||||
|
});
|
||||||
await onSubmit(titre, dateDebut, dateFin, commentaire, depensesBackend);
|
await onSubmit(titre, dateDebut, dateFin, commentaire, depensesBackend);
|
||||||
} catch (e: any) { setSubmitError(e.message || "Erreur lors de la soumission"); setSubmitting(false); }
|
} catch (e: any) { setSubmitError(e.message || "Erreur lors de la soumission"); setSubmitting(false); }
|
||||||
};
|
};
|
||||||
@@ -2103,7 +2306,7 @@ export default function NouvelleNote({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{libelleInitial && !activeBrouillon && (
|
{libelleInitial && !activeBrouillon && !wasOnceActive && (
|
||||||
<div style={{ background: "linear-gradient(90deg, #fef2f2, #fff1f1)", border: "1.5px solid #fecaca", borderLeft: "4px solid #ef4444", borderRadius: "0 10px 10px 0", padding: "10px 16px", display: "flex", alignItems: "center", gap: 10 }}>
|
<div style={{ background: "linear-gradient(90deg, #fef2f2, #fff1f1)", border: "1.5px solid #fecaca", borderLeft: "4px solid #ef4444", borderRadius: "0 10px 10px 0", padding: "10px 16px", display: "flex", alignItems: "center", gap: 10 }}>
|
||||||
<span style={{ fontSize: 18, flexShrink: 0 }}>✏️</span>
|
<span style={{ fontSize: 18, flexShrink: 0 }}>✏️</span>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -36,10 +36,20 @@ interface LigneState { status: 'ok' | 'refused' | 'pending'; motif?: string; }
|
|||||||
|
|
||||||
const fmt = (n: number) => new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(n);
|
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 fmtDate = (d?: string) => d ? new Date(d).toLocaleDateString('fr-FR') : '—';
|
||||||
const getIndemniteKm = (km: number, cv: number) => {
|
const getIndemniteKm = (km: number, cv: number): number => {
|
||||||
const BAREME: Record<number, number> = { 3: 0.529, 4: 0.606, 5: 0.636, 6: 0.665, 7: 0.697 };
|
const BAREME: Record<number, { t1: number; t2_a: number; t2_b: number; t3: number }> = {
|
||||||
return km * (BAREME[Math.min(Math.max(cv, 3), 7)] ?? 0.697);
|
3: { t1: 0.529, t2_a: 0.316, t2_b: 1061, t3: 0.369 },
|
||||||
};
|
4: { t1: 0.606, t2_a: 0.340, t2_b: 1330, t3: 0.408 },
|
||||||
|
5: { t1: 0.636, t2_a: 0.356, t2_b: 1391, t3: 0.427 },
|
||||||
|
6: { t1: 0.665, t2_a: 0.374, t2_b: 1457, t3: 0.448 },
|
||||||
|
7: { t1: 0.697, t2_a: 0.394, t2_b: 1512, t3: 0.470 },
|
||||||
|
};
|
||||||
|
const b = BAREME[Math.min(Math.max(cv, 3), 7)];
|
||||||
|
if (km <= 0) return 0;
|
||||||
|
if (km <= 5000) return parseFloat((km * b.t1).toFixed(2));
|
||||||
|
if (km <= 20000) return parseFloat((km * b.t2_a + b.t2_b).toFixed(2));
|
||||||
|
return parseFloat((km * b.t3).toFixed(2));
|
||||||
|
};
|
||||||
const ligneKey = (noteId: number, idx: number) => `${noteId}-l${idx}`;
|
const ligneKey = (noteId: number, idx: number) => `${noteId}-l${idx}`;
|
||||||
const SYSTEME_KEYWORDS = ['soumission', 'resoumission', 'recap-paiement', 'recap', 'signe-approuve', 'signeapprouve', 'signe_approuve'];
|
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 isSystemFile = (f: Fichier) => SYSTEME_KEYWORDS.some(kw => (f.fileName ?? '').toLowerCase().includes(kw));
|
||||||
@@ -690,7 +700,7 @@ export default function VerificateurFinance4Panels({
|
|||||||
<div className="vf-note-meta">{[note.collaborateur, note.campus, note.departement, fmtDate(note.date)].filter(Boolean).join(' · ')}</div>
|
<div className="vf-note-meta">{[note.collaborateur, note.campus, note.departement, fmtDate(note.date)].filter(Boolean).join(' · ')}</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ textAlign: 'right' }}>
|
<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>}
|
||||||
{nbModifs > 0 && <div style={{ fontSize: 9, color: '#6b7280', textDecoration: 'line-through', fontFamily: 'DM Mono,monospace', textAlign: 'right' }}>{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 className="vf-note-sub">{lignesData.length} ligne{lignesData.length > 1 ? 's' : ''}{nbModifs > 0 ? ` · ${nbModifs} ajust.` : ''}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -730,7 +740,7 @@ export default function VerificateurFinance4Panels({
|
|||||||
<div style={{ fontSize: 11, fontWeight: 700, color: '#111827', marginBottom: 3 }}>{note.libelle}</div>
|
<div style={{ fontSize: 11, fontWeight: 700, color: '#111827', marginBottom: 3 }}>{note.libelle}</div>
|
||||||
<div className="vf-sum-div" />
|
<div className="vf-sum-div" />
|
||||||
<div className="vf-sum-price-lbl">Total demandé</div>
|
<div className="vf-sum-price-lbl">Total demandé</div>
|
||||||
<div className={`vf-sum-price ${nbModifs > 0 ? 'adj' : ''}`}>{fmt(note.montant || 0)}</div>
|
<div className={`vf-sum-price ${nbModifs > 0 ? 'adj' : ''}`}>{fmt(totalAjuste)}</div>
|
||||||
{nbModifs > 0 && <div className="vf-sum-adj">→ {fmt(totalAjuste)} après ajust.</div>}
|
{nbModifs > 0 && <div className="vf-sum-adj">→ {fmt(totalAjuste)} après ajust.</div>}
|
||||||
<div className="vf-sum-div" />
|
<div className="vf-sum-div" />
|
||||||
<div className="vf-sum-meta">
|
<div className="vf-sum-meta">
|
||||||
@@ -882,7 +892,7 @@ export default function VerificateurFinance4Panels({
|
|||||||
</div>
|
</div>
|
||||||
<div className="vf-dec-total">
|
<div className="vf-dec-total">
|
||||||
<span className="vf-dec-total-lbl">Total retenu</span>
|
<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>
|
<span className="vf-dec-total-val" style={{ color: '#111827' }}>{fmt(totalAjuste)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="vf-dec-final">
|
<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>}
|
{canValidate && <p className="vf-dec-hint ok"><CheckCircle size={9} style={{ display: 'inline', marginRight: 2 }} />Toutes conformes{nbModifs > 0 ? ` (${nbModifs} ajust.)` : ''}</p>}
|
||||||
@@ -937,7 +947,20 @@ export default function VerificateurFinance4Panels({
|
|||||||
<div style={{ fontSize: 9, color: '#6b7280', marginTop: 1 }}>{[h.collaborateur, h.campus, h.departement].filter(Boolean).join(' · ')}</div>
|
<div style={{ fontSize: 9, color: '#6b7280', marginTop: 1 }}>{[h.collaborateur, h.campus, h.departement].filter(Boolean).join(' · ')}</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ textAlign: 'right' }}>
|
<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: 15, fontWeight: 800, color: isRefusee ? '#dc2626' : '#15803d', fontFamily: 'DM Mono,monospace' }}>
|
||||||
|
{fmt((() => {
|
||||||
|
try {
|
||||||
|
const lignes = JSON.parse(h.lignesJson || '[]');
|
||||||
|
if (!lignes.length) return h.montant || 0;
|
||||||
|
return lignes.reduce((sum: number, l: any) => {
|
||||||
|
const isKm = (l.categorie || '').toLowerCase().includes('kilom');
|
||||||
|
const km = parseFloat(l.km || '0') || 0;
|
||||||
|
const cv = parseInt(l.chevaux || '7') || 7;
|
||||||
|
return sum + (isKm ? getIndemniteKm(km, cv) : (parseFloat(l.montant || '0') || 0));
|
||||||
|
}, 0);
|
||||||
|
} catch { return 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 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>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user