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