Compare commits
4 Commits
c4d26d4019
..
prod
| Author | SHA1 | Date | |
|---|---|---|---|
| 42ab01ed41 | |||
| 3178e3bf40 | |||
| 0329dbc93a | |||
| 78516cb99f |
+29
-8
@@ -53,7 +53,7 @@ jobs:
|
||||
sed -i '/context:/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/3025:81/3027:81/g' docker-compose.yml
|
||||
sed -i 's/3025:81/3026: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-frontend/container_name: gitea-ndf-frontend/g' docker-compose.yml
|
||||
echo "Contenu du compose après modification :"
|
||||
@@ -63,7 +63,9 @@ jobs:
|
||||
run: |
|
||||
echo "--- 1. Authentification ---"
|
||||
printf '{"username":"%s","password":"%s"}' "${{ secrets.PORTAINER_USER }}" "${{ secrets.PORTAINER_PASS }}" > auth.json
|
||||
TOKEN=$(curl -s -k -X POST "${PORTAINER_API}/auth" -H "Content-Type: application/json" -d @auth.json | grep -o '"jwt":"[^"]*"' | cut -d'"' -f4)
|
||||
TOKEN=$(curl -s -k -X POST "${PORTAINER_API}/auth" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d @auth.json | grep -o '"jwt":"[^"]*"' | cut -d'"' -f4)
|
||||
rm -f auth.json
|
||||
|
||||
if [ -z "$TOKEN" ]; then
|
||||
@@ -73,31 +75,50 @@ jobs:
|
||||
echo "Authentification réussie"
|
||||
|
||||
echo "--- 2. Récupération des IDs ---"
|
||||
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)
|
||||
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)
|
||||
echo "Endpoint ID : $ENDPOINT_ID"
|
||||
|
||||
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)
|
||||
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)
|
||||
echo "Stack ID : $STACK_ID"
|
||||
|
||||
echo "--- 3. Génération du Payload ---"
|
||||
node -e '
|
||||
const fs = require("fs");
|
||||
const compose = fs.readFileSync("docker-compose.yml", "utf8");
|
||||
const payload = { stackFileContent: compose, pullImage: true, prune: true };
|
||||
const payload = {
|
||||
stackFileContent: compose,
|
||||
pullImage: true,
|
||||
prune: true
|
||||
};
|
||||
fs.writeFileSync("payload.json", JSON.stringify(payload));
|
||||
console.log("Payload size:", JSON.stringify(payload).length, "bytes");
|
||||
'
|
||||
|
||||
echo "--- 4. Envoi à Portainer ---"
|
||||
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)
|
||||
RESPONSE=$(curl -s -k \
|
||||
-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 "Réponse Portainer :"
|
||||
cat response_body.json
|
||||
|
||||
if [ "$RESPONSE" = "200" ]; then
|
||||
echo "✅ DEPLOIEMENT REUSSI - Frontend: 3026 / Backend: 8025"
|
||||
echo "DEPLOIEMENT REUSSI - Frontend: 3026 / Backend: 8025"
|
||||
else
|
||||
echo "❌ ECHEC - Erreur Portainer (Code HTTP $RESPONSE)"
|
||||
echo "Erreur Portainer (Code HTTP $RESPONSE)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -129,7 +129,13 @@ export function preparerLignesPDF(lignesParsed, tarifKm = 0.697) {
|
||||
const km = parseFloat(l.km) || 0;
|
||||
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 montantAjuste = l.montantAjuste === true;
|
||||
@@ -208,6 +214,7 @@ export function preparerLignesPDF(lignesParsed, tarifKm = 0.697) {
|
||||
// generateFicheSignee
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
export async function generateFicheSignee(note, signatures = []) {
|
||||
console.log('🔍 generateFicheSignee montantServeur reçu =', note.montant);
|
||||
const tarifKm = parseFloat(note.tarifKm) || 0.697;
|
||||
|
||||
let lignesPDF = [];
|
||||
@@ -244,22 +251,23 @@ export async function generateFicheSignee(note, signatures = []) {
|
||||
mois = m.charAt(0).toUpperCase() + m.slice(1);
|
||||
}
|
||||
|
||||
return _buildPDF({
|
||||
reference: note.reference || '',
|
||||
nomPrenom: note.nomPrenom || note.collaborateur || '',
|
||||
mois,
|
||||
departement: note.departement || '',
|
||||
lignes: lignesPDF,
|
||||
tarifKm,
|
||||
signatures,
|
||||
statut: note.statut || 'enattente',
|
||||
});
|
||||
return _buildPDF({
|
||||
reference: note.reference || '',
|
||||
nomPrenom: note.nomPrenom || note.collaborateur || '',
|
||||
mois,
|
||||
departement: note.departement || '',
|
||||
lignes: lignesPDF,
|
||||
tarifKm,
|
||||
signatures,
|
||||
statut: note.statut || 'enattente',
|
||||
montantServeur: note.montant ? parseFloat(note.montant) : null,
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// _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) => {
|
||||
const doc = new PDFDocument({
|
||||
size: 'A4', layout: 'landscape', margin: 0,
|
||||
@@ -367,7 +375,7 @@ function _buildPDF({ reference, nomPrenom, mois, departement, lignes, tarifKm, s
|
||||
lib: libelleAffiche,
|
||||
km: km > 0 ? f2(km) : '',
|
||||
tarifKm: tarif > 0 ? f3(tarif) : '',
|
||||
sousKm: sousKm > 0 ? f2(sousKm) : '',
|
||||
sousKm: sousKm > 0 ? f2(sousKm) + ' €' : '',
|
||||
ttc: f2(ttc),
|
||||
tva21: f2(t21), tva55: f2(t55),
|
||||
tva10: f2(t10), tva20: f2(t20),
|
||||
@@ -426,7 +434,7 @@ function _buildPDF({ reference, nomPrenom, mois, departement, lignes, tarifKm, s
|
||||
const totMap = {
|
||||
km: totKm > 0 ? f2(totKm) : '',
|
||||
tarifKm: '',
|
||||
sousKm: totSousKm > 0 ? f2(totSousKm) : '',
|
||||
sousKm: totSousKm > 0 ? f2(totSousKm) + ' €' : '',
|
||||
ttc: f2(totTTC),
|
||||
tva21: f2(totT21), tva55: f2(totT55),
|
||||
tva10: f2(totT10), tva20: f2(totT20),
|
||||
@@ -444,7 +452,7 @@ function _buildPDF({ reference, nomPrenom, mois, departement, lignes, tarifKm, s
|
||||
|
||||
// ── Zone bas ─────────────────────────────────────────────────
|
||||
const footY = totalY + ROW_H + 12;
|
||||
const montantR = parseFloat((totTTC + totSousKm).toFixed(2));
|
||||
const montantR = parseFloat((totTTC + totSousKm).toFixed(2));
|
||||
const bw = 64;
|
||||
|
||||
// ✅ 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;
|
||||
|
||||
doc.font('Helvetica-Bold').fontSize(9).fillColor(C.dark)
|
||||
.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);
|
||||
doc.font('Helvetica-Bold').fontSize(11).fillColor(C.blue)
|
||||
.text(f2(montantR) + ' €',
|
||||
MARGIN + 182, footY + 3.5 + labelOffsetY,
|
||||
{ width: bw + 6, align: 'right', lineBreak: false });
|
||||
// APRÈS
|
||||
doc.font('Helvetica-Bold').fontSize(9).fillColor(C.dark)
|
||||
.text('Montant total à rembourser', MARGIN, footY + 4 + labelOffsetY, { lineBreak: false });
|
||||
|
||||
// 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)
|
||||
.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)
|
||||
.text(
|
||||
`Tarif km appliqué : ${tarifKm.toFixed(3)} €/km — Sous-total km : ${f2(totSousKm)} € — TTC hors km : ${f2(totTTC)} €`,
|
||||
MARGIN, footY + 24 + labelOffsetY, { lineBreak: false }
|
||||
` Sous-total km : ${f2(totSousKm)} € — TTC hors km : ${f2(totTTC)} € — Total : ${f2(totSousKm)} € + ${f2(totTTC)} € = ${f2(montantR)} €`,
|
||||
MARGIN, footY + 34 + labelOffsetY, { lineBreak: false }
|
||||
);
|
||||
|
||||
// ── Signatures ────────────────────────────────────────────────
|
||||
|
||||
+732
-729
File diff suppressed because it is too large
Load Diff
+1449
-583
File diff suppressed because it is too large
Load Diff
@@ -449,22 +449,12 @@ function MessageBubble({ msg, onNegativeFeedback }: MessageBubbleProps) {
|
||||
gap: 8, alignItems: "flex-end", marginBottom: 10,
|
||||
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 && (
|
||||
<img
|
||||
src="/img/emma-avatar.jpg"
|
||||
alt="Emma"
|
||||
style={{ width: 26, height: 26, borderRadius: "50%", objectFit: "cover", flexShrink: 0 }}
|
||||
/>
|
||||
>>>>>>> 78516cb (version_Rôle_President Version_Chatbot)
|
||||
)}
|
||||
<div style={{ maxWidth: "83%" }}>
|
||||
<div style={{
|
||||
@@ -499,20 +489,11 @@ function MessageBubble({ msg, onNegativeFeedback }: MessageBubbleProps) {
|
||||
function TypingIndicator() {
|
||||
return (
|
||||
<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
|
||||
src="/img/emma-avatar.png"
|
||||
alt="Emma"
|
||||
style={{ width: 26, height: 26, borderRadius: "50%", objectFit: "cover", flexShrink: 0 }}
|
||||
/>
|
||||
>>>>>>> 78516cb (version_Rôle_President Version_Chatbot)
|
||||
<div style={{
|
||||
padding: "11px 15px",
|
||||
background: "var(--bg-card,#fff)",
|
||||
@@ -596,11 +577,7 @@ export default function NDFChatbot() {
|
||||
{
|
||||
id: "welcome",
|
||||
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 ?",
|
||||
>>>>>>> 78516cb (version_Rôle_President Version_Chatbot)
|
||||
topic: null,
|
||||
},
|
||||
]);
|
||||
@@ -744,11 +721,7 @@ export default function NDFChatbot() {
|
||||
>✕</button>
|
||||
<div style={{ fontSize: 12.5, fontWeight: 700, marginBottom: 4, color: "#4f46e5" }}>Hello 👋</div>
|
||||
<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.
|
||||
>>>>>>> 78516cb (version_Rôle_President Version_Chatbot)
|
||||
</div>
|
||||
<div style={{
|
||||
position: "absolute", bottom: -8, right: 18,
|
||||
@@ -805,16 +778,6 @@ export default function NDFChatbot() {
|
||||
padding: "13px 16px",
|
||||
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
|
||||
src="/img/emma-avatar.jpg"
|
||||
alt="Emma"
|
||||
@@ -822,7 +785,6 @@ export default function NDFChatbot() {
|
||||
/>
|
||||
<div style={{ flex: 1 }}>
|
||||
<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 }}>
|
||||
<span style={{
|
||||
display: "inline-block", width: 5, height: 5, borderRadius: "50%",
|
||||
@@ -929,11 +891,7 @@ export default function NDFChatbot() {
|
||||
textAlign: "center", fontSize: 9.5,
|
||||
color: "var(--text-muted,#94a3b8)",
|
||||
}}>
|
||||
<<<<<<< HEAD
|
||||
Assistant NDF · ENSUP Group
|
||||
=======
|
||||
Emma · ENSUP Group
|
||||
>>>>>>> 78516cb (version_Rôle_President Version_Chatbot)
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
+411
-184
@@ -61,6 +61,7 @@ interface NouvelleNoteProps {
|
||||
commentaireInitial?: string;
|
||||
depensesInitiales?: any[];
|
||||
onNavigateToProfil?: () => void;
|
||||
onBrouillonChange?: (id: number) => void;
|
||||
}
|
||||
|
||||
// ── CONSTANTES ────────────────────────────────────────
|
||||
@@ -89,7 +90,7 @@ const TVA_EXCEL_COLS = [
|
||||
];
|
||||
|
||||
// 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> = {
|
||||
"Deplacement kilometrique": "🚗",
|
||||
@@ -240,7 +241,7 @@ interface ProfilVehiculeData {
|
||||
function newDepense(defaultChevaux = 7): Depense {
|
||||
return {
|
||||
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: "" }],
|
||||
files: [], filesMeta: [], qrLink: null, qrUploaded: false, qrNoteRef: "", qrFiles: [],
|
||||
nuits: "",
|
||||
@@ -248,13 +249,35 @@ function newDepense(defaultChevaux = 7): Depense {
|
||||
}
|
||||
|
||||
function serializeDepenses(depenses: Depense[]) {
|
||||
return depenses.map(d => ({
|
||||
...d,
|
||||
files: [],
|
||||
filesMeta: d.filesMeta ?? [],
|
||||
qrFiles: d.qrFiles ?? [],
|
||||
tvaItems: d.tvaItems.map(tvaItemToBackend),
|
||||
}));
|
||||
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,
|
||||
files: [],
|
||||
filesMeta: [...freshMetas, ...existingNonMemory],
|
||||
qrFiles: d.qrFiles ?? [],
|
||||
tvaItems: d.tvaItems.map(tvaItemToBackend),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ── 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: 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; } }
|
||||
.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 ──────────────────────────────────────
|
||||
@@ -1114,6 +1163,7 @@ const DepenseCard = React.memo(({
|
||||
onDelete: (id: number) => void;
|
||||
onGenerateQR?: (id: number) => void;
|
||||
onNavigateToProfil?: () => void;
|
||||
|
||||
disabled?: boolean;
|
||||
apiBaseUrl: string;
|
||||
profilVehicule?: ProfilVehiculeData | null;
|
||||
@@ -1145,9 +1195,6 @@ const DepenseCard = React.memo(({
|
||||
|
||||
// Détection repas événementiel — désactive l'alerte 25€
|
||||
const isEvenementiel = isRepas && isRepasEvenementiel(depense.libelle, depense.description);
|
||||
<<<<<<< HEAD
|
||||
const repasAlerte = isRepas && ttcParPersonne > 25 && !isEvenementiel;
|
||||
=======
|
||||
const [alerteRepasVue, setAlerteRepasVue] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1162,7 +1209,6 @@ const DepenseCard = React.memo(({
|
||||
|
||||
// Remplace l'ancienne ligne repasAlerte :
|
||||
const repasAlerte = isRepas && alerteRepasVue && ttcTotal > 25 && !isEvenementiel;
|
||||
>>>>>>> 78516cb (version_Rôle_President Version_Chatbot)
|
||||
|
||||
// ✅ Calcul HT — gestion du cas MIXED
|
||||
const htTotal = isKm
|
||||
@@ -1256,7 +1302,7 @@ const DepenseCard = React.memo(({
|
||||
<div className="nn-repas-info" style={{ marginTop: 10, marginBottom: 0 }}>
|
||||
<span className="nn-repas-info-icon">🍽️</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.
|
||||
</span>
|
||||
</div>
|
||||
@@ -1266,15 +1312,19 @@ const DepenseCard = React.memo(({
|
||||
<div className="nn-field">
|
||||
<label className="nn-fl">Catégorie <span>*</span></label>
|
||||
<select className="nn-input" value={depense.categorie}
|
||||
onChange={e => {
|
||||
set("categorie", e.target.value);
|
||||
set("km", "");
|
||||
set("tvaItems", [{ taux: "20", montantTTC: "" }]);
|
||||
if (!e.target.value.toLowerCase().includes("repas")) {
|
||||
set("nombreParticipants", "");
|
||||
set("participants", [{ nom: "", prenom: "", societe: "" }]);
|
||||
}
|
||||
}}>
|
||||
onChange={e => {
|
||||
set("categorie", e.target.value);
|
||||
set("km", "");
|
||||
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")) {
|
||||
set("nombreParticipants", "");
|
||||
set("participants", [{ nom: "", prenom: "", societe: "" }]);
|
||||
}
|
||||
}}>
|
||||
<option value="">— Choisir —</option>
|
||||
{CATEGORIES_DEFAULT.map(c => <option key={c} value={c}>{CAT_ICONS[c]} {c}</option>)}
|
||||
</select>
|
||||
@@ -1314,11 +1364,7 @@ const DepenseCard = React.memo(({
|
||||
où vous choisirez le <em>« trajet le plus rapide »</em>, en favorisant les trajets sans section à péage.
|
||||
</div>
|
||||
|
||||
<<<<<<< HEAD
|
||||
{profilVehicule ? (
|
||||
=======
|
||||
{profilVehicule && (
|
||||
>>>>>>> 78516cb (version_Rôle_President Version_Chatbot)
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", gap: 10,
|
||||
background: "rgba(124,58,237,.07)", border: "1px solid rgba(124,58,237,.22)",
|
||||
@@ -1379,7 +1425,7 @@ const DepenseCard = React.memo(({
|
||||
)}
|
||||
|
||||
{/* ── TVA EXCEL ── */}
|
||||
{!isKm && isExcel && (
|
||||
{!isKm && isExcel && depense.categorie && (
|
||||
<TvaExcelTable
|
||||
tvaItems={tvaItems}
|
||||
onUpdate={items => set("tvaItems", items)}
|
||||
@@ -1387,7 +1433,8 @@ const DepenseCard = React.memo(({
|
||||
)}
|
||||
|
||||
{/* ── NUITS (Hébergement) ── */}
|
||||
{depense.categorie.toLowerCase().includes("hebergement") && (
|
||||
{depense.categorie.toLowerCase().includes("hebergement") && depense.categorie && (
|
||||
|
||||
<div className="nn-nuits-box">
|
||||
<div>
|
||||
<div className="nn-nuits-label">🌙 Nombre de nuits</div>
|
||||
@@ -1424,46 +1471,94 @@ const DepenseCard = React.memo(({
|
||||
)}
|
||||
|
||||
{/* ── TVA CLASSIQUE ── */}
|
||||
{!isKm && !isExcel && (
|
||||
<div className="nn-tva-box">
|
||||
{!isKm && !isExcel && depense.categorie && (
|
||||
<div className={`nn-tva-box${tvaItems.some(it => !it.taux || it.taux === "") ? " nn-tva-alert" : ""}`}>
|
||||
<div className="nn-tva-head">
|
||||
<span className="nn-tva-title">Montants & TVA</span>
|
||||
<span style={{ fontSize: 10, color: "var(--text-secondary, #9ca3af)", fontStyle: "italic" }}>
|
||||
Saisir le montant TTC — HT calculé automatiquement
|
||||
</span>
|
||||
</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 /><div />
|
||||
{["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>
|
||||
|
||||
{tvaItems.map((item, idx) => {
|
||||
const ttc = parseFloat(item.montantTTC) || 0;
|
||||
const taux = parseFloat(item.taux) || 0;
|
||||
const ht = ttcToHt(ttc, taux);
|
||||
const tva = ttcToTva(ttc, taux);
|
||||
const tauxManquant = !item.taux || item.taux === "";
|
||||
return (
|
||||
<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 rm" disabled={tvaItems.length <= 1} onClick={() => removeTva(idx)} title="Supprimer cette ligne">−</button>
|
||||
<select className="nn-input" style={{ fontSize: 12, padding: "6px 8px" }}
|
||||
value={item.taux} onChange={e => updTva(idx, "taux", e.target.value)}>
|
||||
<option value="">— Taux —</option>
|
||||
{TAUX_TVA.map((t, i) => (
|
||||
<option key={i} value={String(t.taux)}>{t.libelle}</option>
|
||||
))}
|
||||
</select>
|
||||
<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 rm"
|
||||
disabled={tvaItems.length <= 1}
|
||||
onClick={() => removeTva(idx)} title="Supprimer cette ligne">−</button>
|
||||
|
||||
{/* ── 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) => (
|
||||
<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" }}>
|
||||
<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}
|
||||
onChange={e => updTva(idx, "montantTTC", e.target.value)}
|
||||
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 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>
|
||||
);
|
||||
})}
|
||||
@@ -1494,7 +1589,7 @@ const DepenseCard = React.memo(({
|
||||
<div className="nn-repas-info">
|
||||
<span className="nn-repas-info-icon">ℹ️</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>
|
||||
</div>
|
||||
|
||||
@@ -1736,7 +1831,7 @@ export default function NouvelleNote({
|
||||
initialBrouillonId = null,
|
||||
libelleInitial = '', dateDebutInitiale = '', commentaireInitial = '',
|
||||
depensesInitiales,
|
||||
onNavigateToProfil,
|
||||
onNavigateToProfil, onBrouillonChange,
|
||||
}: NouvelleNoteProps) {
|
||||
|
||||
const initDepenses = (defaultCv = 7): Depense[] => {
|
||||
@@ -1764,6 +1859,7 @@ export default function NouvelleNote({
|
||||
};
|
||||
|
||||
const [titre, setTitre] = useState(libelleInitial || "");
|
||||
const [wasOnceActive, setWasOnceActive] = useState(false);
|
||||
const [dateDebut, setDateDebut] = useState(dateDebutInitiale || "");
|
||||
const [dateFin, setDateFin] = useState("");
|
||||
const [commentaire, setComment] = useState(commentaireInitial || "");
|
||||
@@ -1787,6 +1883,24 @@ export default function NouvelleNote({
|
||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const isFirstRender = useRef(true);
|
||||
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(() => {
|
||||
if (!authToken) return;
|
||||
@@ -1818,8 +1932,10 @@ export default function NouvelleNote({
|
||||
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 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 tempRef = `NDF-DEP-${depenseId}-${Date.now()}`;
|
||||
@@ -1829,24 +1945,25 @@ export default function NouvelleNote({
|
||||
if (!res.ok) { alert("Erreur génération QR"); return; }
|
||||
setDepenses(prev => prev.map(d => d.id === depenseId ? { ...d, qrLink: data.uploadLink, qrNoteRef: tempRef, qrUploaded: false } : d));
|
||||
if (qrPollingRefs.current[depenseId]) clearInterval(qrPollingRefs.current[depenseId]);
|
||||
qrPollingRefs.current[depenseId] = setInterval(async () => {
|
||||
try {
|
||||
const r = await fetch(`${apiBaseUrl}/api/upload/status/${tempRef}`, { headers: getHeaders() });
|
||||
const s = await r.json();
|
||||
if (s.uploaded) {
|
||||
clearInterval(qrPollingRefs.current[depenseId]);
|
||||
setDepenses(prev => prev.map(d => d.id === depenseId
|
||||
? {
|
||||
...d, qrUploaded: true, qrLink: null,
|
||||
qrFiles: [
|
||||
...(d.qrFiles ?? []),
|
||||
...(Array.isArray(s.files) ? s.files.map((f: any) => ({ ...f, origin: 'qr' as const })) : [])
|
||||
]
|
||||
}
|
||||
: d));
|
||||
}
|
||||
} catch { }
|
||||
}, 3000);
|
||||
qrPollingRefs.current[depenseId] = setInterval(async () => {
|
||||
try {
|
||||
const r = await fetch(`${apiBaseUrl}/api/upload/status/${tempRef}`, { headers: getHeaders() });
|
||||
const s = await r.json();
|
||||
if (s.uploaded) {
|
||||
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
|
||||
? {
|
||||
...d, qrUploaded: true, qrLink: null,
|
||||
qrFiles: [
|
||||
...(d.qrFiles ?? []),
|
||||
...(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));
|
||||
}
|
||||
} catch { }
|
||||
}, 3000);
|
||||
} catch { }
|
||||
}, [apiBaseUrl, getHeaders]);
|
||||
|
||||
@@ -1911,61 +2028,135 @@ export default function NouvelleNote({
|
||||
}, [apiBaseUrl, getHeaders]);
|
||||
useEffect(() => { fetchBrouillons(); }, [fetchBrouillons]);
|
||||
|
||||
useEffect(() => {
|
||||
if (depensesInitiales && depensesInitiales.length > 0) return;
|
||||
if (!initialBrouillonId || !brouillons.length) return;
|
||||
const b = brouillons.find(b => b.id === initialBrouillonId);
|
||||
if (b) loadBrouillon(b);
|
||||
}, [initialBrouillonId, brouillons]);
|
||||
useEffect(() => {
|
||||
if (depensesInitiales && depensesInitiales.length > 0) return;
|
||||
if (hasLoadedInitial.current) return; // déjà chargé
|
||||
if (!mountInitialIdRef.current || !brouillons.length) return;
|
||||
const b = brouillons.find(x => x.id === mountInitialIdRef.current);
|
||||
if (b) loadBrouillon(b);
|
||||
hasLoadedInitial.current = true;
|
||||
}, [brouillons]); // ← plus de dépendance sur initialBrouillonId
|
||||
|
||||
const scheduleSave = useCallback((state: any) => {
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||||
setSaveStatus("saving");
|
||||
saveTimerRef.current = setTimeout(async () => {
|
||||
const currentId = activeBrouillonIdRef.current;
|
||||
const fd = new FormData();
|
||||
fd.append("libelle", state.titre || "Sans titre");
|
||||
fd.append("date", state.dateDebut || new Date().toISOString().split("T")[0]);
|
||||
fd.append("description", state.commentaire || "");
|
||||
fd.append("lignes", JSON.stringify(serializeDepenses(state.depenses)));
|
||||
for (const dep of state.depenses) {
|
||||
for (const file of getStoredFiles(dep.id)) fd.append(`files_${dep.id}`, file);
|
||||
}
|
||||
const headers = { Authorization: `Bearer ${authToken}` };
|
||||
try {
|
||||
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 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]);
|
||||
// ── persistBrouillon — envoi correct des fichiers ──
|
||||
const persistBrouillon = useCallback(async () => {
|
||||
if (isSavingRef.current) {
|
||||
needsResaveRef.current = true;
|
||||
return;
|
||||
}
|
||||
isSavingRef.current = true;
|
||||
setSaveStatus("saving");
|
||||
try {
|
||||
const state = latestStateRef.current;
|
||||
const currentId = activeBrouillonIdRef.current;
|
||||
const fd = new FormData();
|
||||
fd.append("libelle", state.titre || "Sans titre");
|
||||
fd.append("date", state.dateDebut || new Date().toISOString().split("T")[0]);
|
||||
fd.append("description", state.commentaire || "");
|
||||
fd.append("lignes", JSON.stringify(serializeDepenses(state.depenses)));
|
||||
|
||||
// ✅ FIX : lire depuis state.depenses.files ET filesStore
|
||||
for (const dep of state.depenses) {
|
||||
// Fichiers en mémoire React (depense.files)
|
||||
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}` };
|
||||
|
||||
useEffect(() => {
|
||||
if (isFirstRender.current) { isFirstRender.current = false; return; }
|
||||
scheduleSave({ titre, dateDebut, dateFin, commentaire, depenses });
|
||||
}, [titre, dateDebut, dateFin, commentaire, depenses, scheduleSave]);
|
||||
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) => {
|
||||
setTitre(b.libelle || ""); setComment(b.description || "");
|
||||
@@ -2009,59 +2200,93 @@ export default function NouvelleNote({
|
||||
fetchBrouillons();
|
||||
} catch { }
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setSubmitError("");
|
||||
if (submitting) return;
|
||||
if (!titre.trim()) { setSubmitError("Veuillez saisir un titre."); return; }
|
||||
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.");
|
||||
return;
|
||||
}
|
||||
for (const d of depenses) {
|
||||
if (!d.date || !d.libelle.trim()) {
|
||||
setSubmitError(`La dépense "${d.libelle || "sans libellé"}" doit avoir une date et un libellé.`);
|
||||
setExpandedId(d.id); return;
|
||||
}
|
||||
// ✅ Bloquer les dates dans un mois futur
|
||||
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.`);
|
||||
setExpandedId(d.id); return;
|
||||
}
|
||||
const isKmLine = d.categorie.toLowerCase().includes("kilom");
|
||||
if (isKmLine && (!d.km || parseFloat(d.km) <= 0)) {
|
||||
setSubmitError(`"${d.libelle}" doit avoir un kilométrage.`);
|
||||
setExpandedId(d.id); return;
|
||||
}
|
||||
if (!isKmLine) {
|
||||
const hasFiles = d.files.length > 0 || (d.filesMeta ?? []).length > 0 || (d.qrFiles ?? []).length > 0;
|
||||
if (!hasFiles) {
|
||||
setSubmitError(`"${d.libelle}" — un justificatif est obligatoire pour cette catégorie.`);
|
||||
setExpandedId(d.id); return;
|
||||
}
|
||||
}
|
||||
if (d.categorie.toLowerCase().includes("repas")) {
|
||||
for (let pi = 0; pi < d.participants.length; pi++) {
|
||||
const p = d.participants[pi];
|
||||
if (!p.nom?.trim() || !p.prenom?.trim()) {
|
||||
setSubmitError(`"${d.libelle}" — le nom et prénom du participant ${pi + 1} sont obligatoires.`);
|
||||
setExpandedId(d.id); return;
|
||||
}
|
||||
}
|
||||
// Pas de blocage sur le dépassement 25€ — c'est un warning uniquement
|
||||
}
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const depensesBackend = depenses.map(d => ({
|
||||
...d,
|
||||
files: getStoredFiles(d.id).length > 0 ? getStoredFiles(d.id) : d.files,
|
||||
tvaItems: d.tvaItems.map(tvaItemToBackend),
|
||||
qrFiles: d.qrFiles ?? [],
|
||||
}));
|
||||
await onSubmit(titre, dateDebut, dateFin, commentaire, depensesBackend);
|
||||
} catch (e: any) { setSubmitError(e.message || "Erreur lors de la soumission"); setSubmitting(false); }
|
||||
};
|
||||
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 () => {
|
||||
setSubmitError("");
|
||||
if (submitting) return;
|
||||
if (!titre.trim()) {
|
||||
setSubmitError("Veuillez saisir un titre.");
|
||||
scrollToError();
|
||||
return;
|
||||
}
|
||||
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.");
|
||||
scrollToError();
|
||||
return;
|
||||
}
|
||||
for (const d of depenses) {
|
||||
if (!d.date || !d.libelle.trim()) {
|
||||
setSubmitError(`La dépense "${d.libelle || "sans libellé"}" doit avoir une date et un libellé.`);
|
||||
setExpandedId(d.id);
|
||||
scrollToError(d.id);
|
||||
return;
|
||||
}
|
||||
// ✅ Bloquer les dates dans un mois futur
|
||||
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.`);
|
||||
setExpandedId(d.id);
|
||||
scrollToError(d.id);
|
||||
return;
|
||||
}
|
||||
const isKmLine = d.categorie.toLowerCase().includes("kilom");
|
||||
if (isKmLine && (!d.km || parseFloat(d.km) <= 0)) {
|
||||
setSubmitError(`"${d.libelle}" doit avoir un kilométrage.`);
|
||||
setExpandedId(d.id);
|
||||
scrollToError(d.id);
|
||||
return;
|
||||
}
|
||||
if (!isKmLine) {
|
||||
const hasFiles = d.files.length > 0 || (d.filesMeta ?? []).length > 0 || (d.qrFiles ?? []).length > 0;
|
||||
if (!hasFiles) {
|
||||
setSubmitError(`"${d.libelle}" — un justificatif est obligatoire pour cette catégorie.`);
|
||||
setExpandedId(d.id);
|
||||
scrollToError(d.id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (d.categorie.toLowerCase().includes("repas")) {
|
||||
for (let pi = 0; pi < d.participants.length; pi++) {
|
||||
const p = d.participants[pi];
|
||||
if (!p.nom?.trim() || !p.prenom?.trim()) {
|
||||
setSubmitError(`"${d.libelle}" — le nom et prénom du participant ${pi + 1} sont obligatoires.`);
|
||||
setExpandedId(d.id);
|
||||
scrollToError(d.id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Pas de blocage sur le dépassement 25€ — c'est un warning uniquement
|
||||
}
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
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,
|
||||
files: allFiles,
|
||||
tvaItems: d.tvaItems.map(tvaItemToBackend),
|
||||
qrFiles: d.qrFiles ?? [],
|
||||
};
|
||||
});
|
||||
await onSubmit(titre, dateDebut, dateFin, commentaire, depensesBackend);
|
||||
} catch (e: any) {
|
||||
setSubmitError(e.message || "Erreur lors de la soumission");
|
||||
setSubmitting(false);
|
||||
scrollToError();
|
||||
}
|
||||
};
|
||||
|
||||
// ✅ Calcul des totaux globaux — gestion du cas MIXED
|
||||
const { totalTTC, totalHT, totalTVA } = useMemo(() => {
|
||||
@@ -2111,7 +2336,7 @@ export default function NouvelleNote({
|
||||
</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 }}>
|
||||
<span style={{ fontSize: 18, flexShrink: 0 }}>✏️</span>
|
||||
<div>
|
||||
@@ -2225,7 +2450,7 @@ export default function NouvelleNote({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{submitError && <div className="nn-error">⚠️ {submitError}</div>}
|
||||
{submitError && <div ref={errorBannerRef} className="nn-error">⚠️ {submitError}</div>}
|
||||
|
||||
<div className="nn-meta">
|
||||
<div className="nn-meta-field">
|
||||
@@ -2258,15 +2483,17 @@ export default function NouvelleNote({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{depenses.map((d, i) => (
|
||||
<DepenseCard key={d.id} depense={d} index={i} total={depenses.length}
|
||||
expanded={expandedId === d.id}
|
||||
onToggle={handleToggle} onUpdate={handleUpdateDepense}
|
||||
onDelete={handleDeleteDepense} onGenerateQR={generateQRForDepense}
|
||||
disabled={submitting} apiBaseUrl={apiBaseUrl} profilVehicule={profilVehicule}
|
||||
onNavigateToProfil={onNavigateToProfil}
|
||||
/>
|
||||
))}
|
||||
{depenses.map((d, i) => (
|
||||
<div key={d.id} ref={el => { depenseRefs.current[d.id] = el; }}>
|
||||
<DepenseCard depense={d} index={i} total={depenses.length}
|
||||
expanded={expandedId === d.id}
|
||||
onToggle={handleToggle} onUpdate={handleUpdateDepense}
|
||||
onDelete={handleDeleteDepense} onGenerateQR={generateQRForDepense}
|
||||
disabled={submitting} apiBaseUrl={apiBaseUrl} profilVehicule={profilVehicule}
|
||||
onNavigateToProfil={onNavigateToProfil}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button className="nn-btn-add-card" onClick={handleAddDepense} disabled={submitting}>
|
||||
+ Ajouter une dépense
|
||||
|
||||
@@ -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 fmtDate = (d?: string) => d ? new Date(d).toLocaleDateString('fr-FR') : '—';
|
||||
const getIndemniteKm = (km: number, cv: number) => {
|
||||
const BAREME: Record<number, number> = { 3: 0.529, 4: 0.606, 5: 0.636, 6: 0.665, 7: 0.697 };
|
||||
return km * (BAREME[Math.min(Math.max(cv, 3), 7)] ?? 0.697);
|
||||
};
|
||||
const getIndemniteKm = (km: number, cv: number): number => {
|
||||
const BAREME: Record<number, { t1: number; t2_a: number; t2_b: number; t3: number }> = {
|
||||
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 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));
|
||||
@@ -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>
|
||||
<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>}
|
||||
<div className="vf-note-sub">{lignesData.length} ligne{lignesData.length > 1 ? 's' : ''}{nbModifs > 0 ? ` · ${nbModifs} ajust.` : ''}</div>
|
||||
</div>
|
||||
@@ -730,7 +740,7 @@ export default function VerificateurFinance4Panels({
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: '#111827', marginBottom: 3 }}>{note.libelle}</div>
|
||||
<div className="vf-sum-div" />
|
||||
<div className="vf-sum-price-lbl">Total demandé</div>
|
||||
<div className={`vf-sum-price ${nbModifs > 0 ? 'adj' : ''}`}>{fmt(note.montant || 0)}</div>
|
||||
<div className={`vf-sum-price ${nbModifs > 0 ? 'adj' : ''}`}>{fmt(totalAjuste)}</div>
|
||||
{nbModifs > 0 && <div className="vf-sum-adj">→ {fmt(totalAjuste)} après ajust.</div>}
|
||||
<div className="vf-sum-div" />
|
||||
<div className="vf-sum-meta">
|
||||
@@ -882,7 +892,7 @@ export default function VerificateurFinance4Panels({
|
||||
</div>
|
||||
<div className="vf-dec-total">
|
||||
<span className="vf-dec-total-lbl">Total retenu</span>
|
||||
<span className="vf-dec-total-val" style={{ color: nbModifs > 0 ? '#15803d' : '#111827' }}>{fmt(nbModifs > 0 ? totalAjuste : (note.montant || 0))}</span>
|
||||
<span className="vf-dec-total-val" style={{ color: '#111827' }}>{fmt(totalAjuste)}</span>
|
||||
</div>
|
||||
<div className="vf-dec-final">
|
||||
{canValidate && <p className="vf-dec-hint ok"><CheckCircle size={9} style={{ display: 'inline', marginRight: 2 }} />Toutes conformes{nbModifs > 0 ? ` (${nbModifs} ajust.)` : ''}</p>}
|
||||
@@ -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>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user