Compare commits

7 Commits

Author SHA1 Message Date
oimer 250378e569 Merge branch 'master' of https://mygitea.ensup-adm.net/oimer/NDF
CI-CD NDF Complet / test-and-quality (push) Successful in 54s
CI-CD NDF Complet / deploy (push) Successful in 35s
2026-05-29 14:04:50 +02:00
oimer 08f2261fc3 test 2026-05-29 14:03:59 +02:00
msaid-soighiri c4d26d4019 Actualiser .gitea/workflows/ci.yml
CI-CD NDF Complet / test-and-quality (push) Successful in 1m15s
CI-CD NDF Complet / deploy (push) Successful in 56s
2026-05-29 12:09:21 +02:00
msaid-soighiri 9b3e31fec7 Actualiser .gitea/workflows/ci.yml
CI-CD NDF Complet / test-and-quality (push) Successful in 1m18s
CI-CD NDF Complet / deploy (push) Failing after 2m25s
2026-05-29 11:41:14 +02:00
msaid-soighiri af414beabd Actualiser .gitea/workflows/ci.yml
CI-CD NDF Complet / test-and-quality (push) Successful in 1m12s
CI-CD NDF Complet / deploy (push) Failing after 11s
2026-05-29 11:32:49 +02:00
oimer 11f374538e version_Rôle_President Version_Chatbot
CI-CD NDF Complet / test-and-quality (push) Successful in 2m59s
CI-CD NDF Complet / deploy (push) Failing after 2m8s
2026-05-29 10:33:08 +02:00
oimer eb6ba9f78e Version_Chatbot
CI-CD NDF Complet / test-and-quality (push) Successful in 1m5s
CI-CD NDF Complet / deploy (push) Successful in 1m27s
2026-05-19 15:20:38 +02:00
8 changed files with 1584 additions and 2703 deletions
+8 -29
View File
@@ -53,7 +53,7 @@ jobs:
sed -i '/context:/d' docker-compose.yml sed -i '/context:/d' docker-compose.yml
sed -i '/dockerfile:/d' docker-compose.yml sed -i '/dockerfile:/d' docker-compose.yml
sed -i 's/8024:3024/8025:3024/g' docker-compose.yml sed -i 's/8024:3024/8025:3024/g' docker-compose.yml
sed -i 's/3025:81/3026:81/g' docker-compose.yml sed -i 's/3025:81/3027:81/g' docker-compose.yml
sed -i 's/container_name: ndf-backend/container_name: gitea-ndf-backend/g' docker-compose.yml sed -i 's/container_name: ndf-backend/container_name: gitea-ndf-backend/g' docker-compose.yml
sed -i 's/container_name: ndf-frontend/container_name: gitea-ndf-frontend/g' docker-compose.yml sed -i 's/container_name: ndf-frontend/container_name: gitea-ndf-frontend/g' docker-compose.yml
echo "Contenu du compose après modification :" echo "Contenu du compose après modification :"
@@ -63,9 +63,7 @@ jobs:
run: | run: |
echo "--- 1. Authentification ---" echo "--- 1. Authentification ---"
printf '{"username":"%s","password":"%s"}' "${{ secrets.PORTAINER_USER }}" "${{ secrets.PORTAINER_PASS }}" > auth.json printf '{"username":"%s","password":"%s"}' "${{ secrets.PORTAINER_USER }}" "${{ secrets.PORTAINER_PASS }}" > auth.json
TOKEN=$(curl -s -k -X POST "${PORTAINER_API}/auth" \ TOKEN=$(curl -s -k -X POST "${PORTAINER_API}/auth" -H "Content-Type: application/json" -d @auth.json | grep -o '"jwt":"[^"]*"' | cut -d'"' -f4)
-H "Content-Type: application/json" \
-d @auth.json | grep -o '"jwt":"[^"]*"' | cut -d'"' -f4)
rm -f auth.json rm -f auth.json
if [ -z "$TOKEN" ]; then if [ -z "$TOKEN" ]; then
@@ -75,50 +73,31 @@ jobs:
echo "Authentification réussie" echo "Authentification réussie"
echo "--- 2. Récupération des IDs ---" echo "--- 2. Récupération des IDs ---"
ENDPOINT_ID=$(curl -s -k \ ENDPOINT_ID=$(curl -s -k -H "Authorization: Bearer $TOKEN" "${PORTAINER_API}/endpoints" | grep -o '"Id":[0-9]*,"Name":"myportainer-cgy-dev"' | grep -o '[0-9]*' | head -n 1)
-H "Authorization: Bearer $TOKEN" \
"${PORTAINER_API}/endpoints" \
| grep -o '"Id":[0-9]*,"Name":"myportainer-cgy-dev"' \
| grep -o '[0-9]*' | head -n 1)
echo "Endpoint ID : $ENDPOINT_ID" echo "Endpoint ID : $ENDPOINT_ID"
STACK_ID=$(curl -s -k \ STACK_ID=$(curl -s -k -H "Authorization: Bearer $TOKEN" "${PORTAINER_API}/stacks" | grep -o '"Id":[0-9]*,"Name":"gitea-action-dev"' | grep -o '[0-9]*' | head -n 1)
-H "Authorization: Bearer $TOKEN" \
"${PORTAINER_API}/stacks" \
| grep -o '"Id":[0-9]*,"Name":"gitea-action-dev"' \
| grep -o '[0-9]*' | head -n 1)
echo "Stack ID : $STACK_ID" echo "Stack ID : $STACK_ID"
echo "--- 3. Génération du Payload ---" echo "--- 3. Génération du Payload ---"
node -e ' node -e '
const fs = require("fs"); const fs = require("fs");
const compose = fs.readFileSync("docker-compose.yml", "utf8"); const compose = fs.readFileSync("docker-compose.yml", "utf8");
const payload = { const payload = { stackFileContent: compose, pullImage: true, prune: true };
stackFileContent: compose,
pullImage: true,
prune: true
};
fs.writeFileSync("payload.json", JSON.stringify(payload)); fs.writeFileSync("payload.json", JSON.stringify(payload));
console.log("Payload size:", JSON.stringify(payload).length, "bytes"); console.log("Payload size:", JSON.stringify(payload).length, "bytes");
' '
echo "--- 4. Envoi à Portainer ---" echo "--- 4. Envoi à Portainer ---"
RESPONSE=$(curl -s -k \ RESPONSE=$(curl -s -k --max-time 60 --retry 3 --retry-delay 5 --retry-all-errors -o response_body.json -w "%{http_code}" -X PUT "${PORTAINER_API}/stacks/${STACK_ID}?endpointId=${ENDPOINT_ID}" -H "Authorization: Bearer ${TOKEN}" -H "Content-Type: application/json" -d @payload.json)
-o response_body.json \
-w "%{http_code}" \
-X PUT "${PORTAINER_API}/stacks/${STACK_ID}?endpointId=${ENDPOINT_ID}" \
-H "Authorization: Bearer ${TOKEN}" \
-H "Content-Type: application/json" \
-d @payload.json)
echo "HTTP Code : $RESPONSE" echo "HTTP Code : $RESPONSE"
echo "Réponse Portainer :"
cat response_body.json cat response_body.json
if [ "$RESPONSE" = "200" ]; then if [ "$RESPONSE" = "200" ]; then
echo "DEPLOIEMENT REUSSI - Frontend: 3026 / Backend: 8025" echo "DEPLOIEMENT REUSSI - Frontend: 3026 / Backend: 8025"
else else
echo "Erreur Portainer (Code HTTP $RESPONSE)" echo "❌ ECHEC - Erreur Portainer (Code HTTP $RESPONSE)"
exit 1 exit 1
fi fi
+9 -31
View File
@@ -129,13 +129,7 @@ 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 ? (() => { const indemniteKm = isKm ? getIndemniteKm(km, cv) : 0;
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;
@@ -214,7 +208,6 @@ 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 = [];
@@ -260,14 +253,13 @@ export async function generateFicheSignee(note, signatures = []) {
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, montantServeur }) { function _buildPDF({ reference, nomPrenom, mois, departement, lignes, tarifKm, signatures }) {
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,
@@ -375,7 +367,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),
@@ -434,7 +426,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),
@@ -452,7 +444,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
@@ -477,32 +469,18 @@ const montantR = parseFloat((totTTC + totSousKm).toFixed(2));
const labelOffsetY = hasProrata ? 16 : 0; const labelOffsetY = hasProrata ? 16 : 0;
// APRÈS
doc.font('Helvetica-Bold').fontSize(9).fillColor(C.dark) doc.font('Helvetica-Bold').fontSize(9).fillColor(C.dark)
.text('Montant total à rembourser', MARGIN, footY + 4 + labelOffsetY, { lineBreak: false }); .text('Montant total à rembourser', MARGIN, footY + 4 + labelOffsetY, { lineBreak: false });
drawRect(doc, MARGIN + 180, footY + labelOffsetY, bw + 10, 18, C.amountBg, C.border, 0.5);
// Détail calcul km + dépenses
doc.font('Helvetica').fontSize(7.5).fillColor(C.grey)
.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) doc.font('Helvetica-Bold').fontSize(11).fillColor(C.blue)
.text(f2(montantR) + ' €', .text(f2(montantR) + ' €',
MARGIN + 222, footY + 16.5 + labelOffsetY, MARGIN + 182, footY + 3.5 + labelOffsetY,
{ width: bw + 6, align: 'right', lineBreak: false }); { 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(
` Sous-total km : ${f2(totSousKm)} € — TTC hors km : ${f2(totTTC)} € — Total : ${f2(totSousKm)} € + ${f2(totTTC)} € = ${f2(montantR)}`, `Tarif km appliqué : ${tarifKm.toFixed(3)} €/km — Sous-total km : ${f2(totSousKm)} € — TTC hors km : ${f2(totTTC)}`,
MARGIN, footY + 34 + labelOffsetY, { lineBreak: false } MARGIN, footY + 24 + labelOffsetY, { lineBreak: false }
); );
// ── Signatures ──────────────────────────────────────────────── // ── Signatures ────────────────────────────────────────────────
File diff suppressed because it is too large Load Diff
+1
View File
@@ -142,4 +142,5 @@ export type DocInfo = {
updatedAt: string; updatedAt: string;
statut: DocStatut; statut: DocStatut;
commentaire?: string | null; commentaire?: string | null;
} | null; } | null;
+282 -1148
View File
File diff suppressed because it is too large Load Diff
+42
View File
@@ -449,12 +449,22 @@ function MessageBubble({ msg, onNegativeFeedback }: MessageBubbleProps) {
gap: 8, alignItems: "flex-end", marginBottom: 10, gap: 8, alignItems: "flex-end", marginBottom: 10,
animation: "ndfFade 0.2s ease", animation: "ndfFade 0.2s ease",
}}> }}>
<<<<<<< HEAD
{isBot && (
<div style={{
width: 26, height: 26, borderRadius: "50%",
background: "linear-gradient(135deg,#6366f1,#4f46e5)",
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 12, flexShrink: 0,
}}>🤖</div>
=======
{isBot && ( {isBot && (
<img <img
src="/img/emma-avatar.jpg" src="/img/emma-avatar.jpg"
alt="Emma" alt="Emma"
style={{ width: 26, height: 26, borderRadius: "50%", objectFit: "cover", flexShrink: 0 }} style={{ width: 26, height: 26, borderRadius: "50%", objectFit: "cover", flexShrink: 0 }}
/> />
>>>>>>> 78516cb (version_Rôle_President Version_Chatbot)
)} )}
<div style={{ maxWidth: "83%" }}> <div style={{ maxWidth: "83%" }}>
<div style={{ <div style={{
@@ -489,11 +499,20 @@ function MessageBubble({ msg, onNegativeFeedback }: MessageBubbleProps) {
function TypingIndicator() { function TypingIndicator() {
return ( return (
<div style={{ display: "flex", gap: 8, alignItems: "flex-end", marginBottom: 10 }}> <div style={{ display: "flex", gap: 8, alignItems: "flex-end", marginBottom: 10 }}>
<<<<<<< HEAD
<div style={{
width: 26, height: 26, borderRadius: "50%",
background: "linear-gradient(135deg,#6366f1,#4f46e5)",
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 12, flexShrink: 0,
}}>🤖</div>
=======
<img <img
src="/img/emma-avatar.png" src="/img/emma-avatar.png"
alt="Emma" alt="Emma"
style={{ width: 26, height: 26, borderRadius: "50%", objectFit: "cover", flexShrink: 0 }} style={{ width: 26, height: 26, borderRadius: "50%", objectFit: "cover", flexShrink: 0 }}
/> />
>>>>>>> 78516cb (version_Rôle_President Version_Chatbot)
<div style={{ <div style={{
padding: "11px 15px", padding: "11px 15px",
background: "var(--bg-card,#fff)", background: "var(--bg-card,#fff)",
@@ -577,7 +596,11 @@ export default function NDFChatbot() {
{ {
id: "welcome", id: "welcome",
role: "assistant", role: "assistant",
<<<<<<< HEAD
content: "Bonjour ! 👋 Je suis l'assistant NDF d'ENSUP Group.\n\nJe peux répondre à tes questions sur la plateforme : soumission, justificatifs, validations, remboursements, profil...\n\nComment puis-je t'aider ?",
=======
content: "Bonjour ! 👋 Je suis Emma l'assistante NDF d'ENSUP Group.\n\nJe peux répondre à tes questions sur la plateforme : soumission, justificatifs, validations, remboursements, profil...\n\nComment puis-je t'aider ?", content: "Bonjour ! 👋 Je suis Emma l'assistante NDF d'ENSUP Group.\n\nJe peux répondre à tes questions sur la plateforme : soumission, justificatifs, validations, remboursements, profil...\n\nComment puis-je t'aider ?",
>>>>>>> 78516cb (version_Rôle_President Version_Chatbot)
topic: null, topic: null,
}, },
]); ]);
@@ -721,7 +744,11 @@ export default function NDFChatbot() {
></button> ></button>
<div style={{ fontSize: 12.5, fontWeight: 700, marginBottom: 4, color: "#4f46e5" }}>Hello 👋</div> <div style={{ fontSize: 12.5, fontWeight: 700, marginBottom: 4, color: "#4f46e5" }}>Hello 👋</div>
<div style={{ fontSize: 11.5, lineHeight: 1.45, paddingRight: 12 }}> <div style={{ fontSize: 11.5, lineHeight: 1.45, paddingRight: 12 }}>
<<<<<<< HEAD
Je suis <strong>NDF BOT</strong>, je peux t'aider si besoin.
=======
Je suis <strong>Emma</strong>, je peux t'aider si besoin. Je suis <strong>Emma</strong>, je peux t'aider si besoin.
>>>>>>> 78516cb (version_Rôle_President Version_Chatbot)
</div> </div>
<div style={{ <div style={{
position: "absolute", bottom: -8, right: 18, position: "absolute", bottom: -8, right: 18,
@@ -778,6 +805,16 @@ export default function NDFChatbot() {
padding: "13px 16px", padding: "13px 16px",
display: "flex", alignItems: "center", gap: 10, flexShrink: 0, display: "flex", alignItems: "center", gap: 10, flexShrink: 0,
}}> }}>
<<<<<<< HEAD
<div style={{
width: 34, height: 34, borderRadius: "50%",
background: "rgba(255,255,255,0.18)",
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 16, flexShrink: 0,
}}>🤖</div>
<div style={{ flex: 1 }}>
<div style={{ color: "#fff", fontSize: 13, fontWeight: 700 }}>Assistant NDF</div>
=======
<img <img
src="/img/emma-avatar.jpg" src="/img/emma-avatar.jpg"
alt="Emma" alt="Emma"
@@ -785,6 +822,7 @@ export default function NDFChatbot() {
/> />
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<div style={{ color: "#fff", fontSize: 13, fontWeight: 700 }}>Emma</div> <div style={{ color: "#fff", fontSize: 13, fontWeight: 700 }}>Emma</div>
>>>>>>> 78516cb (version_Rôle_President Version_Chatbot)
<div style={{ color: "rgba(255,255,255,0.65)", fontSize: 10, marginTop: 1 }}> <div style={{ color: "rgba(255,255,255,0.65)", fontSize: 10, marginTop: 1 }}>
<span style={{ <span style={{
display: "inline-block", width: 5, height: 5, borderRadius: "50%", display: "inline-block", width: 5, height: 5, borderRadius: "50%",
@@ -891,7 +929,11 @@ export default function NDFChatbot() {
textAlign: "center", fontSize: 9.5, textAlign: "center", fontSize: 9.5,
color: "var(--text-muted,#94a3b8)", color: "var(--text-muted,#94a3b8)",
}}> }}>
<<<<<<< HEAD
Assistant NDF · ENSUP Group
=======
Emma · ENSUP Group Emma · ENSUP Group
>>>>>>> 78516cb (version_Rôle_President Version_Chatbot)
</div> </div>
</div> </div>
)} )}
+64 -291
View File
@@ -61,7 +61,6 @@ interface NouvelleNoteProps {
commentaireInitial?: string; commentaireInitial?: string;
depensesInitiales?: any[]; depensesInitiales?: any[];
onNavigateToProfil?: () => void; onNavigateToProfil?: () => void;
onBrouillonChange?: (id: number) => void;
} }
// ── CONSTANTES ──────────────────────────────────────── // ── CONSTANTES ────────────────────────────────────────
@@ -90,7 +89,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", "autre"]; const CATS_EXCEL_TVA = ["repas", "hebergement", "transport"];
const CAT_ICONS: Record<string, string> = { const CAT_ICONS: Record<string, string> = {
"Deplacement kilometrique": "🚗", "Deplacement kilometrique": "🚗",
@@ -241,7 +240,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: "", montantTTC: "" }], km: "", chevaux: defaultChevaux, tvaItems: [{ taux: "20", 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: "",
@@ -249,35 +248,13 @@ function newDepense(defaultChevaux = 7): Depense {
} }
function serializeDepenses(depenses: Depense[]) { function serializeDepenses(depenses: Depense[]) {
return depenses.map(d => { return depenses.map(d => ({
const reactFiles = d.files ?? [];
const storedFiles = getStoredFiles(d.id);
const allFiles = storedFiles.length > 0 ? storedFiles : reactFiles;
// 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, ...d,
files: [], files: [],
filesMeta: [...freshMetas, ...existingNonMemory], filesMeta: d.filesMeta ?? [],
qrFiles: d.qrFiles ?? [], qrFiles: d.qrFiles ?? [],
tvaItems: d.tvaItems.map(tvaItemToBackend), tvaItems: d.tvaItems.map(tvaItemToBackend),
}; }));
});
} }
// ── FILES STORE ─────────────────────────────────────── // ── FILES STORE ───────────────────────────────────────
@@ -705,32 +682,6 @@ 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 ──────────────────────────────────────
@@ -1163,7 +1114,6 @@ 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;
@@ -1195,6 +1145,9 @@ const DepenseCard = React.memo(({
// Détection repas événementiel — désactive l'alerte 25 // Détection repas événementiel — désactive l'alerte 25
const isEvenementiel = isRepas && isRepasEvenementiel(depense.libelle, depense.description); const isEvenementiel = isRepas && isRepasEvenementiel(depense.libelle, depense.description);
<<<<<<< HEAD
const repasAlerte = isRepas && ttcParPersonne > 25 && !isEvenementiel;
=======
const [alerteRepasVue, setAlerteRepasVue] = useState(false); const [alerteRepasVue, setAlerteRepasVue] = useState(false);
useEffect(() => { useEffect(() => {
@@ -1209,6 +1162,7 @@ const DepenseCard = React.memo(({
// Remplace l'ancienne ligne repasAlerte : // Remplace l'ancienne ligne repasAlerte :
const repasAlerte = isRepas && alerteRepasVue && ttcTotal > 25 && !isEvenementiel; const repasAlerte = isRepas && alerteRepasVue && ttcTotal > 25 && !isEvenementiel;
>>>>>>> 78516cb (version_Rôle_President Version_Chatbot)
// ✅ Calcul HT — gestion du cas MIXED // ✅ Calcul HT — gestion du cas MIXED
const htTotal = isKm const htTotal = isKm
@@ -1302,7 +1256,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> . Catégorie <strong>Repas</strong> sélectionnée plafond <strong>25 / personne</strong> (sauf repas événementiel).
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>
@@ -1316,10 +1270,6 @@ const DepenseCard = React.memo(({
set("categorie", e.target.value); set("categorie", e.target.value);
set("km", ""); set("km", "");
set("tvaItems", [{ taux: "20", montantTTC: "" }]); set("tvaItems", [{ taux: "20", montantTTC: "" }]);
// ✅ Ajouter ceci :
if (e.target.value.toLowerCase().includes("kilom") && profilVehicule?.chevaux) {
set("chevaux", profilVehicule.chevaux);
}
if (!e.target.value.toLowerCase().includes("repas")) { if (!e.target.value.toLowerCase().includes("repas")) {
set("nombreParticipants", ""); set("nombreParticipants", "");
set("participants", [{ nom: "", prenom: "", societe: "" }]); set("participants", [{ nom: "", prenom: "", societe: "" }]);
@@ -1364,7 +1314,11 @@ const DepenseCard = React.memo(({
vous choisirez le <em>« trajet le plus rapide »</em>, en favorisant les trajets sans section à péage. vous choisirez le <em>« trajet le plus rapide »</em>, en favorisant les trajets sans section à péage.
</div> </div>
<<<<<<< HEAD
{profilVehicule ? (
=======
{profilVehicule && ( {profilVehicule && (
>>>>>>> 78516cb (version_Rôle_President Version_Chatbot)
<div style={{ <div style={{
display: "flex", alignItems: "center", gap: 10, display: "flex", alignItems: "center", gap: 10,
background: "rgba(124,58,237,.07)", border: "1px solid rgba(124,58,237,.22)", background: "rgba(124,58,237,.07)", border: "1px solid rgba(124,58,237,.22)",
@@ -1425,7 +1379,7 @@ const DepenseCard = React.memo(({
)} )}
{/* ── TVA EXCEL ── */} {/* ── TVA EXCEL ── */}
{!isKm && isExcel && depense.categorie && ( {!isKm && isExcel && (
<TvaExcelTable <TvaExcelTable
tvaItems={tvaItems} tvaItems={tvaItems}
onUpdate={items => set("tvaItems", items)} onUpdate={items => set("tvaItems", items)}
@@ -1433,8 +1387,7 @@ const DepenseCard = React.memo(({
)} )}
{/* ── NUITS (Hébergement) ── */} {/* ── NUITS (Hébergement) ── */}
{depense.categorie.toLowerCase().includes("hebergement") && depense.categorie && ( {depense.categorie.toLowerCase().includes("hebergement") && (
<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>
@@ -1471,94 +1424,46 @@ const DepenseCard = React.memo(({
)} )}
{/* ── TVA CLASSIQUE ── */} {/* ── TVA CLASSIQUE ── */}
{!isKm && !isExcel && depense.categorie && ( {!isKm && !isExcel && (
<div className={`nn-tva-box${tvaItems.some(it => !it.taux || it.taux === "") ? " nn-tva-alert" : ""}`}> <div className="nn-tva-box">
<div className="nn-tva-head"> <div className="nn-tva-head">
<span className="nn-tva-title">Montants &amp; TVA</span> <span className="nn-tva-title">Montants &amp; 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={{ <div key={i} style={{ fontSize: 9, fontWeight: 700, textTransform: "uppercase", color: "var(--text-secondary, #9ca3af)", letterSpacing: ".04em" }}>{h}</div>
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" <button type="button" className="nn-tva-btn add" onClick={() => addTvaAfter(idx)} title="Ajouter une ligne après">+</button>
onClick={() => addTvaAfter(idx)} title="Ajouter une ligne après">+</button> <button type="button" className="nn-tva-btn rm" disabled={tvaItems.length <= 1} onClick={() => removeTva(idx)} title="Supprimer cette ligne"></button>
<button type="button" className="nn-tva-btn rm" <select className="nn-input" style={{ fontSize: 12, padding: "6px 8px" }}
disabled={tvaItems.length <= 1} value={item.taux} onChange={e => updTva(idx, "taux", e.target.value)}>
onClick={() => removeTva(idx)} title="Supprimer cette ligne"></button> <option value=""> Taux </option>
{/* ── Select TVA mis en évidence si non renseigné ── */}
<div style={{ position: "relative" }}>
<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) => ( {TAUX_TVA.map((t, i) => (
<option key={i} value={String(t.taux)}>{t.libelle}</option> <option key={i} value={String(t.taux)}>{t.libelle}</option>
))} ))}
</select> </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={{ style={{ fontSize: 12, padding: "6px 22px 6px 8px" }}
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={{ <span style={{ position: "absolute", right: 7, top: "50%", transform: "translateY(-50%)", fontSize: 11, color: "#9ca3af", pointerEvents: "none" }}></span>
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>
); );
})} })}
@@ -1589,7 +1494,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>. Un repas professionnel <strong>ne doit pas dépasser 25 par personne</strong> (sauf repas événementiel).
</span> </span>
</div> </div>
@@ -1831,7 +1736,7 @@ export default function NouvelleNote({
initialBrouillonId = null, initialBrouillonId = null,
libelleInitial = '', dateDebutInitiale = '', commentaireInitial = '', libelleInitial = '', dateDebutInitiale = '', commentaireInitial = '',
depensesInitiales, depensesInitiales,
onNavigateToProfil, onBrouillonChange, onNavigateToProfil,
}: NouvelleNoteProps) { }: NouvelleNoteProps) {
const initDepenses = (defaultCv = 7): Depense[] => { const initDepenses = (defaultCv = 7): Depense[] => {
@@ -1859,7 +1764,6 @@ 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 || "");
@@ -1883,24 +1787,6 @@ export default function NouvelleNote({
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isFirstRender = useRef(true); const isFirstRender = useRef(true);
const activeBrouillonIdRef = useRef<number | null>(initialBrouillonId); const activeBrouillonIdRef = useRef<number | null>(initialBrouillonId);
const errorBannerRef = useRef<HTMLDivElement>(null);
const depenseRefs = useRef<Record<number, HTMLDivElement | null>>({});
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;
@@ -1932,10 +1818,8 @@ 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(profilVehicule?.chevaux ?? 7); const d = newDepense(); setDepenses(p => [...p, d]); setExpandedId(d.id);
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()}`;
@@ -1951,14 +1835,13 @@ export default function NouvelleNote({
const s = await r.json(); const s = await r.json();
if (s.uploaded) { if (s.uploaded) {
clearInterval(qrPollingRefs.current[depenseId]); clearInterval(qrPollingRefs.current[depenseId]);
delete qrPollingRefs.current[depenseId]; // ✅ empêche un 2e tick concurrent de re-déclencher la fusion
setDepenses(prev => prev.map(d => d.id === depenseId setDepenses(prev => prev.map(d => d.id === depenseId
? { ? {
...d, qrUploaded: true, qrLink: null, ...d, qrUploaded: true, qrLink: null,
qrFiles: [ qrFiles: [
...(d.qrFiles ?? []), ...(d.qrFiles ?? []),
...(Array.isArray(s.files) ? s.files.map((f: any) => ({ ...f, origin: 'qr' as const })) : []) ...(Array.isArray(s.files) ? s.files.map((f: any) => ({ ...f, origin: 'qr' as const })) : [])
].filter((f, i, arr) => arr.findIndex(x => x.uploadUrl === f.uploadUrl) === i) // ✅ dédup par URL ]
} }
: d)); : d));
} }
@@ -2030,134 +1913,60 @@ export default function NouvelleNote({
useEffect(() => { useEffect(() => {
if (depensesInitiales && depensesInitiales.length > 0) return; if (depensesInitiales && depensesInitiales.length > 0) return;
if (hasLoadedInitial.current) return; // déjà chargé if (!initialBrouillonId || !brouillons.length) return;
if (!mountInitialIdRef.current || !brouillons.length) return; const b = brouillons.find(b => b.id === initialBrouillonId);
const b = brouillons.find(x => x.id === mountInitialIdRef.current);
if (b) loadBrouillon(b); if (b) loadBrouillon(b);
hasLoadedInitial.current = true; }, [initialBrouillonId, brouillons]);
}, [brouillons]); // ← plus de dépendance sur initialBrouillonId
// ── persistBrouillon — envoi correct des fichiers ── const scheduleSave = useCallback((state: any) => {
const persistBrouillon = useCallback(async () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
if (isSavingRef.current) {
needsResaveRef.current = true;
return;
}
isSavingRef.current = true;
setSaveStatus("saving"); setSaveStatus("saving");
try { saveTimerRef.current = setTimeout(async () => {
const state = latestStateRef.current;
const currentId = activeBrouillonIdRef.current; const currentId = activeBrouillonIdRef.current;
const fd = new FormData(); const fd = new FormData();
fd.append("libelle", state.titre || "Sans titre"); fd.append("libelle", state.titre || "Sans titre");
fd.append("date", state.dateDebut || new Date().toISOString().split("T")[0]); fd.append("date", state.dateDebut || new Date().toISOString().split("T")[0]);
fd.append("description", state.commentaire || ""); fd.append("description", state.commentaire || "");
fd.append("lignes", JSON.stringify(serializeDepenses(state.depenses))); fd.append("lignes", JSON.stringify(serializeDepenses(state.depenses)));
// ✅ FIX : lire depuis state.depenses.files ET filesStore
for (const dep of state.depenses) { for (const dep of state.depenses) {
// Fichiers en mémoire React (depense.files) for (const file of getStoredFiles(dep.id)) fd.append(`files_${dep.id}`, file);
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}` }; const headers = { Authorization: `Bearer ${authToken}` };
try {
if (currentId) { if (currentId) {
const res = await fetch(`${apiBaseUrl}/api/notes/brouillons/${currentId}`, { const res = await fetch(`${apiBaseUrl}/api/notes/brouillons/${currentId}`, { method: "PUT", headers, body: fd });
method: "PUT", headers, body: fd
});
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
if (data.uploadedFiles) { if (data.uploadedFiles) {
setDepenses(prev => prev.map(d => { setDepenses(prev => prev.map(d => {
const uploaded = data.uploadedFiles[d.id]; const uploaded = data.uploadedFiles[d.id];
if (!uploaded?.length) return d; if (!uploaded?.length) return d;
const merged = [ const newQrFiles = [
...(d.qrFiles ?? []), ...(d.qrFiles ?? []),
...uploaded.map((f: any) => ({ ...f, origin: 'upload' as const })), ...uploaded.map((f: any) => ({ ...f, origin: 'upload' as const }))
].filter((f, i, arr) => arr.findIndex(x => x.uploadUrl === f.uploadUrl) === i); ].filter((f, i, arr) => arr.findIndex(x => x.uploadUrl === f.uploadUrl) === i);
setStoredFiles(d.id, []); return { ...d, qrFiles: newQrFiles };
// ✅ 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 { } else {
const res = await fetch(`${apiBaseUrl}/api/notes/brouillons`, { const res = await fetch(`${apiBaseUrl}/api/notes/brouillons`, { method: "POST", headers, body: fd });
method: "POST", headers, body: fd
});
const created = await res.json(); const created = await res.json();
activeBrouillonIdRef.current = created.id; activeBrouillonIdRef.current = created.id;
setActiveBrouillonId(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(); await fetchBrouillons();
setSaveStatus("saved"); setSaveStatus("saved");
state.depenses.forEach((d: Depense) => setStoredFiles(d.id, []));
} catch { } catch { setSaveStatus("error"); }
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); }, 1500);
}, [persistBrouillon]); }, [apiBaseUrl, authToken, fetchBrouillons]);
useEffect(() => { useEffect(() => {
if (isFirstRender.current) { isFirstRender.current = false; return; } if (isFirstRender.current) { isFirstRender.current = false; return; }
scheduleSave(); // plus besoin de passer l'état scheduleSave({ titre, dateDebut, dateFin, commentaire, depenses });
}, [titre, dateDebut, dateFin, commentaire, depenses, scheduleSave]); }, [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 || "");
setDateDebut(b.date ? b.date.split("T")[0] : ""); setDateDebut(b.date ? b.date.split("T")[0] : "");
@@ -2200,57 +2009,35 @@ export default function NouvelleNote({
fetchBrouillons(); fetchBrouillons();
} catch { } } catch { }
}; };
const scrollToError = (depenseId?: number) => {
requestAnimationFrame(() => {
const target = depenseId ? depenseRefs.current[depenseId] : errorBannerRef.current;
if (target) {
target.scrollIntoView({ behavior: "smooth", block: "center" });
} else {
errorBannerRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
}
});
};
const handleSubmit = async () => { const handleSubmit = async () => {
setSubmitError(""); setSubmitError("");
if (submitting) return; if (submitting) return;
if (!titre.trim()) { if (!titre.trim()) { setSubmitError("Veuillez saisir un titre."); return; }
setSubmitError("Veuillez saisir un titre.");
scrollToError();
return;
}
if (dateDebut && isDateFutureMonth(dateDebut)) { if (dateDebut && isDateFutureMonth(dateDebut)) {
setSubmitError("La date de la note ne peut pas être dans un mois futur. Vous ne pouvez créer des notes que pour le mois en cours ou des mois passés."); setSubmitError("La date de la note ne peut pas être dans un mois futur. Vous ne pouvez créer des notes que pour le mois en cours ou des mois passés.");
scrollToError();
return; return;
} }
for (const d of depenses) { for (const d of depenses) {
if (!d.date || !d.libelle.trim()) { if (!d.date || !d.libelle.trim()) {
setSubmitError(`La dépense "${d.libelle || "sans libellé"}" doit avoir une date et un libellé.`); setSubmitError(`La dépense "${d.libelle || "sans libellé"}" doit avoir une date et un libellé.`);
setExpandedId(d.id); setExpandedId(d.id); return;
scrollToError(d.id);
return;
} }
// ✅ Bloquer les dates dans un mois futur // ✅ Bloquer les dates dans un mois futur
if (isDateFutureMonth(d.date)) { if (isDateFutureMonth(d.date)) {
setSubmitError(`"${d.libelle}" — la date ne peut pas être dans un mois futur. Vous ne pouvez soumettre des frais que pour le mois en cours ou des mois passés.`); setSubmitError(`"${d.libelle}" — la date ne peut pas être dans un mois futur. Vous ne pouvez soumettre des frais que pour le mois en cours ou des mois passés.`);
setExpandedId(d.id); setExpandedId(d.id); return;
scrollToError(d.id);
return;
} }
const isKmLine = d.categorie.toLowerCase().includes("kilom"); const isKmLine = d.categorie.toLowerCase().includes("kilom");
if (isKmLine && (!d.km || parseFloat(d.km) <= 0)) { if (isKmLine && (!d.km || parseFloat(d.km) <= 0)) {
setSubmitError(`"${d.libelle}" doit avoir un kilométrage.`); setSubmitError(`"${d.libelle}" doit avoir un kilométrage.`);
setExpandedId(d.id); setExpandedId(d.id); return;
scrollToError(d.id);
return;
} }
if (!isKmLine) { if (!isKmLine) {
const hasFiles = d.files.length > 0 || (d.filesMeta ?? []).length > 0 || (d.qrFiles ?? []).length > 0; const hasFiles = d.files.length > 0 || (d.filesMeta ?? []).length > 0 || (d.qrFiles ?? []).length > 0;
if (!hasFiles) { if (!hasFiles) {
setSubmitError(`"${d.libelle}" — un justificatif est obligatoire pour cette catégorie.`); setSubmitError(`"${d.libelle}" — un justificatif est obligatoire pour cette catégorie.`);
setExpandedId(d.id); setExpandedId(d.id); return;
scrollToError(d.id);
return;
} }
} }
if (d.categorie.toLowerCase().includes("repas")) { if (d.categorie.toLowerCase().includes("repas")) {
@@ -2258,9 +2045,7 @@ export default function NouvelleNote({
const p = d.participants[pi]; const p = d.participants[pi];
if (!p.nom?.trim() || !p.prenom?.trim()) { if (!p.nom?.trim() || !p.prenom?.trim()) {
setSubmitError(`"${d.libelle}" — le nom et prénom du participant ${pi + 1} sont obligatoires.`); setSubmitError(`"${d.libelle}" — le nom et prénom du participant ${pi + 1} sont obligatoires.`);
setExpandedId(d.id); setExpandedId(d.id); return;
scrollToError(d.id);
return;
} }
} }
// Pas de blocage sur le dépassement 25€ — c'est un warning uniquement // Pas de blocage sur le dépassement 25€ — c'est un warning uniquement
@@ -2268,24 +2053,14 @@ export default function NouvelleNote({
} }
setSubmitting(true); setSubmitting(true);
try { try {
const depensesBackend = depenses.map(d => { const depensesBackend = depenses.map(d => ({
const storedFiles = getStoredFiles(d.id);
const allFiles = storedFiles.length > 0
? [...d.files, ...storedFiles.filter(sf => !d.files.some(rf => rf.name === sf.name && rf.size === sf.size))]
: d.files;
return {
...d, ...d,
files: allFiles, files: getStoredFiles(d.id).length > 0 ? getStoredFiles(d.id) : d.files,
tvaItems: d.tvaItems.map(tvaItemToBackend), tvaItems: d.tvaItems.map(tvaItemToBackend),
qrFiles: d.qrFiles ?? [], qrFiles: d.qrFiles ?? [],
}; }));
});
await onSubmit(titre, dateDebut, dateFin, commentaire, depensesBackend); await onSubmit(titre, dateDebut, dateFin, commentaire, depensesBackend);
} catch (e: any) { } catch (e: any) { setSubmitError(e.message || "Erreur lors de la soumission"); setSubmitting(false); }
setSubmitError(e.message || "Erreur lors de la soumission");
setSubmitting(false);
scrollToError();
}
}; };
// ✅ Calcul des totaux globaux — gestion du cas MIXED // ✅ Calcul des totaux globaux — gestion du cas MIXED
@@ -2336,7 +2111,7 @@ export default function NouvelleNote({
</div> </div>
)} )}
{libelleInitial && !activeBrouillon && !wasOnceActive && ( {libelleInitial && !activeBrouillon && (
<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>
@@ -2450,7 +2225,7 @@ export default function NouvelleNote({
</div> </div>
</div> </div>
{submitError && <div ref={errorBannerRef} className="nn-error"> {submitError}</div>} {submitError && <div className="nn-error"> {submitError}</div>}
<div className="nn-meta"> <div className="nn-meta">
<div className="nn-meta-field"> <div className="nn-meta-field">
@@ -2484,15 +2259,13 @@ export default function NouvelleNote({
</div> </div>
{depenses.map((d, i) => ( {depenses.map((d, i) => (
<div key={d.id} ref={el => { depenseRefs.current[d.id] = el; }}> <DepenseCard key={d.id} depense={d} index={i} total={depenses.length}
<DepenseCard depense={d} index={i} total={depenses.length}
expanded={expandedId === d.id} expanded={expandedId === d.id}
onToggle={handleToggle} onUpdate={handleUpdateDepense} onToggle={handleToggle} onUpdate={handleUpdateDepense}
onDelete={handleDeleteDepense} onGenerateQR={generateQRForDepense} onDelete={handleDeleteDepense} onGenerateQR={generateQRForDepense}
disabled={submitting} apiBaseUrl={apiBaseUrl} profilVehicule={profilVehicule} disabled={submitting} apiBaseUrl={apiBaseUrl} profilVehicule={profilVehicule}
onNavigateToProfil={onNavigateToProfil} onNavigateToProfil={onNavigateToProfil}
/> />
</div>
))} ))}
<button className="nn-btn-add-card" onClick={handleAddDepense} disabled={submitting}> <button className="nn-btn-add-card" onClick={handleAddDepense} disabled={submitting}>
+8 -31
View File
@@ -36,20 +36,10 @@ 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): number => { const getIndemniteKm = (km: number, cv: number) => {
const BAREME: Record<number, { t1: number; t2_a: number; t2_b: number; t3: number }> = { const BAREME: Record<number, number> = { 3: 0.529, 4: 0.606, 5: 0.636, 6: 0.665, 7: 0.697 };
3: { t1: 0.529, t2_a: 0.316, t2_b: 1061, t3: 0.369 }, return km * (BAREME[Math.min(Math.max(cv, 3), 7)] ?? 0.697);
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));
@@ -700,7 +690,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' }}>
{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-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>}
<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>
@@ -740,7 +730,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(totalAjuste)}</div> <div className={`vf-sum-price ${nbModifs > 0 ? 'adj' : ''}`}>{fmt(note.montant || 0)}</div>
{nbModifs > 0 && <div className="vf-sum-adj">→ {fmt(totalAjuste)} après ajust.</div>} {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">
@@ -892,7 +882,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: '#111827' }}>{fmt(totalAjuste)}</span> <span className="vf-dec-total-val" style={{ color: nbModifs > 0 ? '#15803d' : '#111827' }}>{fmt(nbModifs > 0 ? totalAjuste : (note.montant || 0))}</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>}
@@ -947,20 +937,7 @@ 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' }}> <div style={{ fontSize: 15, fontWeight: 800, color: isRefusee ? '#dc2626' : '#15803d', fontFamily: 'DM Mono,monospace' }}>{fmt(h.montant || 0)}</div>
{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>