Compare commits
7 Commits
prod
..
250378e569
| Author | SHA1 | Date | |
|---|---|---|---|
| 250378e569 | |||
| 08f2261fc3 | |||
| c4d26d4019 | |||
| 9b3e31fec7 | |||
| af414beabd | |||
| 11f374538e | |||
| eb6ba9f78e |
+8
-29
@@ -53,7 +53,7 @@ jobs:
|
|||||||
sed -i '/context:/d' docker-compose.yml
|
sed -i '/context:/d' docker-compose.yml
|
||||||
sed -i '/dockerfile:/d' docker-compose.yml
|
sed -i '/dockerfile:/d' docker-compose.yml
|
||||||
sed -i 's/8024:3024/8025:3024/g' docker-compose.yml
|
sed -i 's/8024:3024/8025:3024/g' docker-compose.yml
|
||||||
sed -i 's/3025:81/3026:81/g' docker-compose.yml
|
sed -i 's/3025:81/3027:81/g' docker-compose.yml
|
||||||
sed -i 's/container_name: ndf-backend/container_name: gitea-ndf-backend/g' docker-compose.yml
|
sed -i 's/container_name: ndf-backend/container_name: gitea-ndf-backend/g' docker-compose.yml
|
||||||
sed -i 's/container_name: ndf-frontend/container_name: gitea-ndf-frontend/g' docker-compose.yml
|
sed -i 's/container_name: ndf-frontend/container_name: gitea-ndf-frontend/g' docker-compose.yml
|
||||||
echo "Contenu du compose après modification :"
|
echo "Contenu du compose après modification :"
|
||||||
@@ -63,9 +63,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
echo "--- 1. Authentification ---"
|
echo "--- 1. Authentification ---"
|
||||||
printf '{"username":"%s","password":"%s"}' "${{ secrets.PORTAINER_USER }}" "${{ secrets.PORTAINER_PASS }}" > auth.json
|
printf '{"username":"%s","password":"%s"}' "${{ secrets.PORTAINER_USER }}" "${{ secrets.PORTAINER_PASS }}" > auth.json
|
||||||
TOKEN=$(curl -s -k -X POST "${PORTAINER_API}/auth" \
|
TOKEN=$(curl -s -k -X POST "${PORTAINER_API}/auth" -H "Content-Type: application/json" -d @auth.json | grep -o '"jwt":"[^"]*"' | cut -d'"' -f4)
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d @auth.json | grep -o '"jwt":"[^"]*"' | cut -d'"' -f4)
|
|
||||||
rm -f auth.json
|
rm -f auth.json
|
||||||
|
|
||||||
if [ -z "$TOKEN" ]; then
|
if [ -z "$TOKEN" ]; then
|
||||||
@@ -75,50 +73,31 @@ jobs:
|
|||||||
echo "Authentification réussie"
|
echo "Authentification réussie"
|
||||||
|
|
||||||
echo "--- 2. Récupération des IDs ---"
|
echo "--- 2. Récupération des IDs ---"
|
||||||
ENDPOINT_ID=$(curl -s -k \
|
ENDPOINT_ID=$(curl -s -k -H "Authorization: Bearer $TOKEN" "${PORTAINER_API}/endpoints" | grep -o '"Id":[0-9]*,"Name":"myportainer-cgy-dev"' | grep -o '[0-9]*' | head -n 1)
|
||||||
-H "Authorization: Bearer $TOKEN" \
|
|
||||||
"${PORTAINER_API}/endpoints" \
|
|
||||||
| grep -o '"Id":[0-9]*,"Name":"myportainer-cgy-dev"' \
|
|
||||||
| grep -o '[0-9]*' | head -n 1)
|
|
||||||
echo "Endpoint ID : $ENDPOINT_ID"
|
echo "Endpoint ID : $ENDPOINT_ID"
|
||||||
|
|
||||||
STACK_ID=$(curl -s -k \
|
STACK_ID=$(curl -s -k -H "Authorization: Bearer $TOKEN" "${PORTAINER_API}/stacks" | grep -o '"Id":[0-9]*,"Name":"gitea-action-dev"' | grep -o '[0-9]*' | head -n 1)
|
||||||
-H "Authorization: Bearer $TOKEN" \
|
|
||||||
"${PORTAINER_API}/stacks" \
|
|
||||||
| grep -o '"Id":[0-9]*,"Name":"gitea-action-dev"' \
|
|
||||||
| grep -o '[0-9]*' | head -n 1)
|
|
||||||
echo "Stack ID : $STACK_ID"
|
echo "Stack ID : $STACK_ID"
|
||||||
|
|
||||||
echo "--- 3. Génération du Payload ---"
|
echo "--- 3. Génération du Payload ---"
|
||||||
node -e '
|
node -e '
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const compose = fs.readFileSync("docker-compose.yml", "utf8");
|
const compose = fs.readFileSync("docker-compose.yml", "utf8");
|
||||||
const payload = {
|
const payload = { stackFileContent: compose, pullImage: true, prune: true };
|
||||||
stackFileContent: compose,
|
|
||||||
pullImage: true,
|
|
||||||
prune: true
|
|
||||||
};
|
|
||||||
fs.writeFileSync("payload.json", JSON.stringify(payload));
|
fs.writeFileSync("payload.json", JSON.stringify(payload));
|
||||||
console.log("Payload size:", JSON.stringify(payload).length, "bytes");
|
console.log("Payload size:", JSON.stringify(payload).length, "bytes");
|
||||||
'
|
'
|
||||||
|
|
||||||
echo "--- 4. Envoi à Portainer ---"
|
echo "--- 4. Envoi à Portainer ---"
|
||||||
RESPONSE=$(curl -s -k \
|
RESPONSE=$(curl -s -k --max-time 60 --retry 3 --retry-delay 5 --retry-all-errors -o response_body.json -w "%{http_code}" -X PUT "${PORTAINER_API}/stacks/${STACK_ID}?endpointId=${ENDPOINT_ID}" -H "Authorization: Bearer ${TOKEN}" -H "Content-Type: application/json" -d @payload.json)
|
||||||
-o response_body.json \
|
|
||||||
-w "%{http_code}" \
|
|
||||||
-X PUT "${PORTAINER_API}/stacks/${STACK_ID}?endpointId=${ENDPOINT_ID}" \
|
|
||||||
-H "Authorization: Bearer ${TOKEN}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d @payload.json)
|
|
||||||
|
|
||||||
echo "HTTP Code : $RESPONSE"
|
echo "HTTP Code : $RESPONSE"
|
||||||
echo "Réponse Portainer :"
|
|
||||||
cat response_body.json
|
cat response_body.json
|
||||||
|
|
||||||
if [ "$RESPONSE" = "200" ]; then
|
if [ "$RESPONSE" = "200" ]; then
|
||||||
echo "DEPLOIEMENT REUSSI - Frontend: 3026 / Backend: 8025"
|
echo "✅ DEPLOIEMENT REUSSI - Frontend: 3026 / Backend: 8025"
|
||||||
else
|
else
|
||||||
echo "Erreur Portainer (Code HTTP $RESPONSE)"
|
echo "❌ ECHEC - Erreur Portainer (Code HTTP $RESPONSE)"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -129,13 +129,7 @@ export function preparerLignesPDF(lignesParsed, tarifKm = 0.697) {
|
|||||||
const km = parseFloat(l.km) || 0;
|
const km = parseFloat(l.km) || 0;
|
||||||
const cv = parseInt(l.chevaux) || 7;
|
const cv = parseInt(l.chevaux) || 7;
|
||||||
|
|
||||||
const indemniteKm = isKm ? (() => {
|
const indemniteKm = isKm ? getIndemniteKm(km, cv) : 0;
|
||||||
const b = BAREME_KM[cv];
|
|
||||||
if (!b || km <= 0) return 0;
|
|
||||||
if (km <= 5000) return parseFloat((km * b.t1).toFixed(2));
|
|
||||||
if (km <= 20000) return parseFloat((km * b.t2_a + b.t2_b).toFixed(2));
|
|
||||||
return parseFloat((km * b.t3).toFixed(2));
|
|
||||||
})() : 0;
|
|
||||||
const tarifKmAffiche = isKm ? (km > 0 ? parseFloat((indemniteKm / km).toFixed(3)) : tarifKm) : 0;
|
const tarifKmAffiche = isKm ? (km > 0 ? parseFloat((indemniteKm / km).toFixed(3)) : tarifKm) : 0;
|
||||||
|
|
||||||
const montantAjuste = l.montantAjuste === true;
|
const montantAjuste = l.montantAjuste === true;
|
||||||
@@ -214,7 +208,6 @@ export function preparerLignesPDF(lignesParsed, tarifKm = 0.697) {
|
|||||||
// generateFicheSignee
|
// generateFicheSignee
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
export async function generateFicheSignee(note, signatures = []) {
|
export async function generateFicheSignee(note, signatures = []) {
|
||||||
console.log('🔍 generateFicheSignee montantServeur reçu =', note.montant);
|
|
||||||
const tarifKm = parseFloat(note.tarifKm) || 0.697;
|
const tarifKm = parseFloat(note.tarifKm) || 0.697;
|
||||||
|
|
||||||
let lignesPDF = [];
|
let lignesPDF = [];
|
||||||
@@ -260,14 +253,13 @@ export async function generateFicheSignee(note, signatures = []) {
|
|||||||
tarifKm,
|
tarifKm,
|
||||||
signatures,
|
signatures,
|
||||||
statut: note.statut || 'enattente',
|
statut: note.statut || 'enattente',
|
||||||
montantServeur: note.montant ? parseFloat(note.montant) : null,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// _buildPDF
|
// _buildPDF
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
function _buildPDF({ reference, nomPrenom, mois, departement, lignes, tarifKm, signatures, montantServeur }) {
|
function _buildPDF({ reference, nomPrenom, mois, departement, lignes, tarifKm, signatures }) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const doc = new PDFDocument({
|
const doc = new PDFDocument({
|
||||||
size: 'A4', layout: 'landscape', margin: 0,
|
size: 'A4', layout: 'landscape', margin: 0,
|
||||||
@@ -375,7 +367,7 @@ function _buildPDF({ reference, nomPrenom, mois, departement, lignes, tarifKm, s
|
|||||||
lib: libelleAffiche,
|
lib: libelleAffiche,
|
||||||
km: km > 0 ? f2(km) : '',
|
km: km > 0 ? f2(km) : '',
|
||||||
tarifKm: tarif > 0 ? f3(tarif) : '',
|
tarifKm: tarif > 0 ? f3(tarif) : '',
|
||||||
sousKm: sousKm > 0 ? f2(sousKm) + ' €' : '',
|
sousKm: sousKm > 0 ? f2(sousKm) : '',
|
||||||
ttc: f2(ttc),
|
ttc: f2(ttc),
|
||||||
tva21: f2(t21), tva55: f2(t55),
|
tva21: f2(t21), tva55: f2(t55),
|
||||||
tva10: f2(t10), tva20: f2(t20),
|
tva10: f2(t10), tva20: f2(t20),
|
||||||
@@ -434,7 +426,7 @@ function _buildPDF({ reference, nomPrenom, mois, departement, lignes, tarifKm, s
|
|||||||
const totMap = {
|
const totMap = {
|
||||||
km: totKm > 0 ? f2(totKm) : '',
|
km: totKm > 0 ? f2(totKm) : '',
|
||||||
tarifKm: '',
|
tarifKm: '',
|
||||||
sousKm: totSousKm > 0 ? f2(totSousKm) + ' €' : '',
|
sousKm: totSousKm > 0 ? f2(totSousKm) : '',
|
||||||
ttc: f2(totTTC),
|
ttc: f2(totTTC),
|
||||||
tva21: f2(totT21), tva55: f2(totT55),
|
tva21: f2(totT21), tva55: f2(totT55),
|
||||||
tva10: f2(totT10), tva20: f2(totT20),
|
tva10: f2(totT10), tva20: f2(totT20),
|
||||||
@@ -452,7 +444,7 @@ function _buildPDF({ reference, nomPrenom, mois, departement, lignes, tarifKm, s
|
|||||||
|
|
||||||
// ── Zone bas ─────────────────────────────────────────────────
|
// ── Zone bas ─────────────────────────────────────────────────
|
||||||
const footY = totalY + ROW_H + 12;
|
const footY = totalY + ROW_H + 12;
|
||||||
const montantR = parseFloat((totTTC + totSousKm).toFixed(2));
|
const montantR = parseFloat((totTTC + totSousKm).toFixed(2));
|
||||||
const bw = 64;
|
const bw = 64;
|
||||||
|
|
||||||
// ✅ Légende proratisation si au moins une ligne ajustée
|
// ✅ Légende proratisation si au moins une ligne ajustée
|
||||||
@@ -477,32 +469,18 @@ const montantR = parseFloat((totTTC + totSousKm).toFixed(2));
|
|||||||
|
|
||||||
const labelOffsetY = hasProrata ? 16 : 0;
|
const labelOffsetY = hasProrata ? 16 : 0;
|
||||||
|
|
||||||
// APRÈS
|
|
||||||
doc.font('Helvetica-Bold').fontSize(9).fillColor(C.dark)
|
doc.font('Helvetica-Bold').fontSize(9).fillColor(C.dark)
|
||||||
.text('Montant total à rembourser', MARGIN, footY + 4 + labelOffsetY, { lineBreak: false });
|
.text('Montant total à rembourser', MARGIN, footY + 4 + labelOffsetY, { lineBreak: false });
|
||||||
|
drawRect(doc, MARGIN + 180, footY + labelOffsetY, bw + 10, 18, C.amountBg, C.border, 0.5);
|
||||||
// Détail calcul km + dépenses
|
|
||||||
doc.font('Helvetica').fontSize(7.5).fillColor(C.grey)
|
|
||||||
.text(
|
|
||||||
totSousKm > 0 && totTTC > 0
|
|
||||||
? `${f2(totSousKm)} € (km) + ${f2(totTTC)} € (dépenses) =`
|
|
||||||
: totSousKm > 0
|
|
||||||
? `${f2(totSousKm)} € (indemnités kilométriques) =`
|
|
||||||
: `${f2(totTTC)} € (dépenses) =`,
|
|
||||||
MARGIN, footY + 17 + labelOffsetY,
|
|
||||||
{ lineBreak: false }
|
|
||||||
);
|
|
||||||
|
|
||||||
drawRect(doc, MARGIN + 220, footY + 13 + labelOffsetY, bw + 10, 18, C.amountBg, C.border, 0.5);
|
|
||||||
doc.font('Helvetica-Bold').fontSize(11).fillColor(C.blue)
|
doc.font('Helvetica-Bold').fontSize(11).fillColor(C.blue)
|
||||||
.text(f2(montantR) + ' €',
|
.text(f2(montantR) + ' €',
|
||||||
MARGIN + 222, footY + 16.5 + labelOffsetY,
|
MARGIN + 182, footY + 3.5 + labelOffsetY,
|
||||||
{ width: bw + 6, align: 'right', lineBreak: false });
|
{ width: bw + 6, align: 'right', lineBreak: false });
|
||||||
|
|
||||||
doc.font('Helvetica').fontSize(6.5).fillColor(C.grey)
|
doc.font('Helvetica').fontSize(6.5).fillColor(C.grey)
|
||||||
.text(
|
.text(
|
||||||
` Sous-total km : ${f2(totSousKm)} € — TTC hors km : ${f2(totTTC)} € — Total : ${f2(totSousKm)} € + ${f2(totTTC)} € = ${f2(montantR)} €`,
|
`Tarif km appliqué : ${tarifKm.toFixed(3)} €/km — Sous-total km : ${f2(totSousKm)} € — TTC hors km : ${f2(totTTC)} €`,
|
||||||
MARGIN, footY + 34 + labelOffsetY, { lineBreak: false }
|
MARGIN, footY + 24 + labelOffsetY, { lineBreak: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── Signatures ────────────────────────────────────────────────
|
// ── Signatures ────────────────────────────────────────────────
|
||||||
|
|||||||
+539
-542
File diff suppressed because it is too large
Load Diff
@@ -142,4 +142,5 @@ export type DocInfo = {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
statut: DocStatut;
|
statut: DocStatut;
|
||||||
commentaire?: string | null;
|
commentaire?: string | null;
|
||||||
|
|
||||||
} | null;
|
} | null;
|
||||||
|
|||||||
+282
-1148
File diff suppressed because it is too large
Load Diff
@@ -449,12 +449,22 @@ function MessageBubble({ msg, onNegativeFeedback }: MessageBubbleProps) {
|
|||||||
gap: 8, alignItems: "flex-end", marginBottom: 10,
|
gap: 8, alignItems: "flex-end", marginBottom: 10,
|
||||||
animation: "ndfFade 0.2s ease",
|
animation: "ndfFade 0.2s ease",
|
||||||
}}>
|
}}>
|
||||||
|
<<<<<<< HEAD
|
||||||
|
{isBot && (
|
||||||
|
<div style={{
|
||||||
|
width: 26, height: 26, borderRadius: "50%",
|
||||||
|
background: "linear-gradient(135deg,#6366f1,#4f46e5)",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
|
fontSize: 12, flexShrink: 0,
|
||||||
|
}}>🤖</div>
|
||||||
|
=======
|
||||||
{isBot && (
|
{isBot && (
|
||||||
<img
|
<img
|
||||||
src="/img/emma-avatar.jpg"
|
src="/img/emma-avatar.jpg"
|
||||||
alt="Emma"
|
alt="Emma"
|
||||||
style={{ width: 26, height: 26, borderRadius: "50%", objectFit: "cover", flexShrink: 0 }}
|
style={{ width: 26, height: 26, borderRadius: "50%", objectFit: "cover", flexShrink: 0 }}
|
||||||
/>
|
/>
|
||||||
|
>>>>>>> 78516cb (version_Rôle_President Version_Chatbot)
|
||||||
)}
|
)}
|
||||||
<div style={{ maxWidth: "83%" }}>
|
<div style={{ maxWidth: "83%" }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -489,11 +499,20 @@ function MessageBubble({ msg, onNegativeFeedback }: MessageBubbleProps) {
|
|||||||
function TypingIndicator() {
|
function TypingIndicator() {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", gap: 8, alignItems: "flex-end", marginBottom: 10 }}>
|
<div style={{ display: "flex", gap: 8, alignItems: "flex-end", marginBottom: 10 }}>
|
||||||
|
<<<<<<< HEAD
|
||||||
|
<div style={{
|
||||||
|
width: 26, height: 26, borderRadius: "50%",
|
||||||
|
background: "linear-gradient(135deg,#6366f1,#4f46e5)",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
|
fontSize: 12, flexShrink: 0,
|
||||||
|
}}>🤖</div>
|
||||||
|
=======
|
||||||
<img
|
<img
|
||||||
src="/img/emma-avatar.png"
|
src="/img/emma-avatar.png"
|
||||||
alt="Emma"
|
alt="Emma"
|
||||||
style={{ width: 26, height: 26, borderRadius: "50%", objectFit: "cover", flexShrink: 0 }}
|
style={{ width: 26, height: 26, borderRadius: "50%", objectFit: "cover", flexShrink: 0 }}
|
||||||
/>
|
/>
|
||||||
|
>>>>>>> 78516cb (version_Rôle_President Version_Chatbot)
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: "11px 15px",
|
padding: "11px 15px",
|
||||||
background: "var(--bg-card,#fff)",
|
background: "var(--bg-card,#fff)",
|
||||||
@@ -577,7 +596,11 @@ export default function NDFChatbot() {
|
|||||||
{
|
{
|
||||||
id: "welcome",
|
id: "welcome",
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
|
<<<<<<< HEAD
|
||||||
|
content: "Bonjour ! 👋 Je suis l'assistant NDF d'ENSUP Group.\n\nJe peux répondre à tes questions sur la plateforme : soumission, justificatifs, validations, remboursements, profil...\n\nComment puis-je t'aider ?",
|
||||||
|
=======
|
||||||
content: "Bonjour ! 👋 Je suis Emma l'assistante NDF d'ENSUP Group.\n\nJe peux répondre à tes questions sur la plateforme : soumission, justificatifs, validations, remboursements, profil...\n\nComment puis-je t'aider ?",
|
content: "Bonjour ! 👋 Je suis Emma l'assistante NDF d'ENSUP Group.\n\nJe peux répondre à tes questions sur la plateforme : soumission, justificatifs, validations, remboursements, profil...\n\nComment puis-je t'aider ?",
|
||||||
|
>>>>>>> 78516cb (version_Rôle_President Version_Chatbot)
|
||||||
topic: null,
|
topic: null,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -721,7 +744,11 @@ export default function NDFChatbot() {
|
|||||||
>✕</button>
|
>✕</button>
|
||||||
<div style={{ fontSize: 12.5, fontWeight: 700, marginBottom: 4, color: "#4f46e5" }}>Hello 👋</div>
|
<div style={{ fontSize: 12.5, fontWeight: 700, marginBottom: 4, color: "#4f46e5" }}>Hello 👋</div>
|
||||||
<div style={{ fontSize: 11.5, lineHeight: 1.45, paddingRight: 12 }}>
|
<div style={{ fontSize: 11.5, lineHeight: 1.45, paddingRight: 12 }}>
|
||||||
|
<<<<<<< HEAD
|
||||||
|
Je suis <strong>NDF BOT</strong>, je peux t'aider si besoin.
|
||||||
|
=======
|
||||||
Je suis <strong>Emma</strong>, je peux t'aider si besoin.
|
Je suis <strong>Emma</strong>, je peux t'aider si besoin.
|
||||||
|
>>>>>>> 78516cb (version_Rôle_President Version_Chatbot)
|
||||||
</div>
|
</div>
|
||||||
<div style={{
|
<div style={{
|
||||||
position: "absolute", bottom: -8, right: 18,
|
position: "absolute", bottom: -8, right: 18,
|
||||||
@@ -778,6 +805,16 @@ export default function NDFChatbot() {
|
|||||||
padding: "13px 16px",
|
padding: "13px 16px",
|
||||||
display: "flex", alignItems: "center", gap: 10, flexShrink: 0,
|
display: "flex", alignItems: "center", gap: 10, flexShrink: 0,
|
||||||
}}>
|
}}>
|
||||||
|
<<<<<<< HEAD
|
||||||
|
<div style={{
|
||||||
|
width: 34, height: 34, borderRadius: "50%",
|
||||||
|
background: "rgba(255,255,255,0.18)",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
|
fontSize: 16, flexShrink: 0,
|
||||||
|
}}>🤖</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ color: "#fff", fontSize: 13, fontWeight: 700 }}>Assistant NDF</div>
|
||||||
|
=======
|
||||||
<img
|
<img
|
||||||
src="/img/emma-avatar.jpg"
|
src="/img/emma-avatar.jpg"
|
||||||
alt="Emma"
|
alt="Emma"
|
||||||
@@ -785,6 +822,7 @@ export default function NDFChatbot() {
|
|||||||
/>
|
/>
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<div style={{ color: "#fff", fontSize: 13, fontWeight: 700 }}>Emma</div>
|
<div style={{ color: "#fff", fontSize: 13, fontWeight: 700 }}>Emma</div>
|
||||||
|
>>>>>>> 78516cb (version_Rôle_President Version_Chatbot)
|
||||||
<div style={{ color: "rgba(255,255,255,0.65)", fontSize: 10, marginTop: 1 }}>
|
<div style={{ color: "rgba(255,255,255,0.65)", fontSize: 10, marginTop: 1 }}>
|
||||||
<span style={{
|
<span style={{
|
||||||
display: "inline-block", width: 5, height: 5, borderRadius: "50%",
|
display: "inline-block", width: 5, height: 5, borderRadius: "50%",
|
||||||
@@ -891,7 +929,11 @@ export default function NDFChatbot() {
|
|||||||
textAlign: "center", fontSize: 9.5,
|
textAlign: "center", fontSize: 9.5,
|
||||||
color: "var(--text-muted,#94a3b8)",
|
color: "var(--text-muted,#94a3b8)",
|
||||||
}}>
|
}}>
|
||||||
|
<<<<<<< HEAD
|
||||||
|
Assistant NDF · ENSUP Group
|
||||||
|
=======
|
||||||
Emma · ENSUP Group
|
Emma · ENSUP Group
|
||||||
|
>>>>>>> 78516cb (version_Rôle_President Version_Chatbot)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+64
-291
@@ -61,7 +61,6 @@ interface NouvelleNoteProps {
|
|||||||
commentaireInitial?: string;
|
commentaireInitial?: string;
|
||||||
depensesInitiales?: any[];
|
depensesInitiales?: any[];
|
||||||
onNavigateToProfil?: () => void;
|
onNavigateToProfil?: () => void;
|
||||||
onBrouillonChange?: (id: number) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── CONSTANTES ────────────────────────────────────────
|
// ── CONSTANTES ────────────────────────────────────────
|
||||||
@@ -90,7 +89,7 @@ const TVA_EXCEL_COLS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
// Catégories utilisant le tableau Excel TVA
|
// Catégories utilisant le tableau Excel TVA
|
||||||
const CATS_EXCEL_TVA = ["repas", "hebergement", "transport", "autre"];
|
const CATS_EXCEL_TVA = ["repas", "hebergement", "transport"];
|
||||||
|
|
||||||
const CAT_ICONS: Record<string, string> = {
|
const CAT_ICONS: Record<string, string> = {
|
||||||
"Deplacement kilometrique": "🚗",
|
"Deplacement kilometrique": "🚗",
|
||||||
@@ -241,7 +240,7 @@ interface ProfilVehiculeData {
|
|||||||
function newDepense(defaultChevaux = 7): Depense {
|
function newDepense(defaultChevaux = 7): Depense {
|
||||||
return {
|
return {
|
||||||
id: Math.random(), categorie: "", date: "", libelle: "", description: "",
|
id: Math.random(), categorie: "", date: "", libelle: "", description: "",
|
||||||
km: "", chevaux: defaultChevaux, tvaItems: [{ taux: "", montantTTC: "" }],
|
km: "", chevaux: defaultChevaux, tvaItems: [{ taux: "20", montantTTC: "" }],
|
||||||
nombreParticipants: "", participants: [{ nom: "", prenom: "", societe: "" }],
|
nombreParticipants: "", participants: [{ nom: "", prenom: "", societe: "" }],
|
||||||
files: [], filesMeta: [], qrLink: null, qrUploaded: false, qrNoteRef: "", qrFiles: [],
|
files: [], filesMeta: [], qrLink: null, qrUploaded: false, qrNoteRef: "", qrFiles: [],
|
||||||
nuits: "",
|
nuits: "",
|
||||||
@@ -249,35 +248,13 @@ function newDepense(defaultChevaux = 7): Depense {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function serializeDepenses(depenses: Depense[]) {
|
function serializeDepenses(depenses: Depense[]) {
|
||||||
return depenses.map(d => {
|
return depenses.map(d => ({
|
||||||
const reactFiles = d.files ?? [];
|
|
||||||
const storedFiles = getStoredFiles(d.id);
|
|
||||||
const allFiles = storedFiles.length > 0 ? storedFiles : reactFiles;
|
|
||||||
|
|
||||||
// Récupérer les metas déjà calculées pour les fichiers en mémoire
|
|
||||||
const inMemoryMetas = (d.filesMeta ?? []).filter(
|
|
||||||
m => allFiles.some(f => f.name === m.name && f.size === m.size)
|
|
||||||
);
|
|
||||||
// Pour les nouveaux fichiers sans meta encore
|
|
||||||
const missingMetas: FileMeta[] = allFiles
|
|
||||||
.filter(f => !inMemoryMetas.some(m => m.name === f.name && m.size === f.size))
|
|
||||||
.map(f => ({ name: f.name, type: f.type, size: f.size }));
|
|
||||||
|
|
||||||
const freshMetas = [...inMemoryMetas, ...missingMetas];
|
|
||||||
|
|
||||||
// Conserver les metas de fichiers déjà uploadés (QR, session précédente)
|
|
||||||
const existingNonMemory = (d.filesMeta ?? []).filter(
|
|
||||||
m => !allFiles.some(f => f.name === m.name && f.size === m.size)
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...d,
|
...d,
|
||||||
files: [],
|
files: [],
|
||||||
filesMeta: [...freshMetas, ...existingNonMemory],
|
filesMeta: d.filesMeta ?? [],
|
||||||
qrFiles: d.qrFiles ?? [],
|
qrFiles: d.qrFiles ?? [],
|
||||||
tvaItems: d.tvaItems.map(tvaItemToBackend),
|
tvaItems: d.tvaItems.map(tvaItemToBackend),
|
||||||
};
|
}));
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── FILES STORE ───────────────────────────────────────
|
// ── FILES STORE ───────────────────────────────────────
|
||||||
@@ -705,32 +682,6 @@ select.nn-input {
|
|||||||
@media(max-width: 900px) { .nn-layout { flex-direction: column; } .nn-side { width: 100%; position: static; } }
|
@media(max-width: 900px) { .nn-layout { flex-direction: column; } .nn-side { width: 100%; position: static; } }
|
||||||
@media(max-width: 600px) { .nn-meta { grid-template-columns: 1fr 1fr; } .nn-tva-row { grid-template-columns: 22px 22px 110px 1fr 70px 60px; } }
|
@media(max-width: 600px) { .nn-meta { grid-template-columns: 1fr 1fr; } .nn-tva-row { grid-template-columns: 22px 22px 110px 1fr 70px 60px; } }
|
||||||
@media(max-width: 480px) { .nn-meta { grid-template-columns: 1fr; } .nn-row.g3 { grid-template-columns: 1fr 1fr; } }
|
@media(max-width: 480px) { .nn-meta { grid-template-columns: 1fr; } .nn-row.g3 { grid-template-columns: 1fr 1fr; } }
|
||||||
.nn-tva-alert {
|
|
||||||
background: rgba(245,158,11,.08);
|
|
||||||
border: 2px solid rgba(245,158,11,.5);
|
|
||||||
border-radius: 9px;
|
|
||||||
padding: 10px;
|
|
||||||
animation: nn-pulse-border 2s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
@keyframes nn-pulse-border {
|
|
||||||
0%, 100% { border-color: rgba(245,158,11,.5); }
|
|
||||||
50% { border-color: rgba(245,158,11,1); }
|
|
||||||
}
|
|
||||||
.nn-tva-alert-banner {
|
|
||||||
display: flex; align-items: center; gap: 8px;
|
|
||||||
background: rgba(245,158,11,.15);
|
|
||||||
border: 1.5px solid rgba(245,158,11,.6);
|
|
||||||
border-radius: 7px; padding: 8px 12px;
|
|
||||||
font-size: 12px; font-weight: 700; color: #92400e;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
.nn-tva-select-highlight {
|
|
||||||
border: 2px solid #f59e0b !important;
|
|
||||||
background: rgba(245,158,11,.07) !important;
|
|
||||||
font-weight: 700 !important;
|
|
||||||
color: #92400e !important;
|
|
||||||
box-shadow: 0 0 0 3px rgba(245,158,11,.2) !important;
|
|
||||||
}
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// ── VIEWER MODAL ──────────────────────────────────────
|
// ── VIEWER MODAL ──────────────────────────────────────
|
||||||
@@ -1163,7 +1114,6 @@ const DepenseCard = React.memo(({
|
|||||||
onDelete: (id: number) => void;
|
onDelete: (id: number) => void;
|
||||||
onGenerateQR?: (id: number) => void;
|
onGenerateQR?: (id: number) => void;
|
||||||
onNavigateToProfil?: () => void;
|
onNavigateToProfil?: () => void;
|
||||||
|
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
apiBaseUrl: string;
|
apiBaseUrl: string;
|
||||||
profilVehicule?: ProfilVehiculeData | null;
|
profilVehicule?: ProfilVehiculeData | null;
|
||||||
@@ -1195,6 +1145,9 @@ const DepenseCard = React.memo(({
|
|||||||
|
|
||||||
// Détection repas événementiel — désactive l'alerte 25€
|
// Détection repas événementiel — désactive l'alerte 25€
|
||||||
const isEvenementiel = isRepas && isRepasEvenementiel(depense.libelle, depense.description);
|
const isEvenementiel = isRepas && isRepasEvenementiel(depense.libelle, depense.description);
|
||||||
|
<<<<<<< HEAD
|
||||||
|
const repasAlerte = isRepas && ttcParPersonne > 25 && !isEvenementiel;
|
||||||
|
=======
|
||||||
const [alerteRepasVue, setAlerteRepasVue] = useState(false);
|
const [alerteRepasVue, setAlerteRepasVue] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1209,6 +1162,7 @@ const DepenseCard = React.memo(({
|
|||||||
|
|
||||||
// Remplace l'ancienne ligne repasAlerte :
|
// Remplace l'ancienne ligne repasAlerte :
|
||||||
const repasAlerte = isRepas && alerteRepasVue && ttcTotal > 25 && !isEvenementiel;
|
const repasAlerte = isRepas && alerteRepasVue && ttcTotal > 25 && !isEvenementiel;
|
||||||
|
>>>>>>> 78516cb (version_Rôle_President Version_Chatbot)
|
||||||
|
|
||||||
// ✅ Calcul HT — gestion du cas MIXED
|
// ✅ Calcul HT — gestion du cas MIXED
|
||||||
const htTotal = isKm
|
const htTotal = isKm
|
||||||
@@ -1302,7 +1256,7 @@ const DepenseCard = React.memo(({
|
|||||||
<div className="nn-repas-info" style={{ marginTop: 10, marginBottom: 0 }}>
|
<div className="nn-repas-info" style={{ marginTop: 10, marginBottom: 0 }}>
|
||||||
<span className="nn-repas-info-icon">🍽️</span>
|
<span className="nn-repas-info-icon">🍽️</span>
|
||||||
<span>
|
<span>
|
||||||
Catégorie <strong>Repas</strong> sélectionnée — plafond <strong>25 € / personne</strong> .
|
Catégorie <strong>Repas</strong> sélectionnée — plafond <strong>25 € / personne</strong> (sauf repas événementiel).
|
||||||
Renseignez le nombre de convives en bas pour contrôler le seuil en temps réel.
|
Renseignez le nombre de convives en bas pour contrôler le seuil en temps réel.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1316,10 +1270,6 @@ const DepenseCard = React.memo(({
|
|||||||
set("categorie", e.target.value);
|
set("categorie", e.target.value);
|
||||||
set("km", "");
|
set("km", "");
|
||||||
set("tvaItems", [{ taux: "20", montantTTC: "" }]);
|
set("tvaItems", [{ taux: "20", montantTTC: "" }]);
|
||||||
// ✅ Ajouter ceci :
|
|
||||||
if (e.target.value.toLowerCase().includes("kilom") && profilVehicule?.chevaux) {
|
|
||||||
set("chevaux", profilVehicule.chevaux);
|
|
||||||
}
|
|
||||||
if (!e.target.value.toLowerCase().includes("repas")) {
|
if (!e.target.value.toLowerCase().includes("repas")) {
|
||||||
set("nombreParticipants", "");
|
set("nombreParticipants", "");
|
||||||
set("participants", [{ nom: "", prenom: "", societe: "" }]);
|
set("participants", [{ nom: "", prenom: "", societe: "" }]);
|
||||||
@@ -1364,7 +1314,11 @@ const DepenseCard = React.memo(({
|
|||||||
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)",
|
||||||
@@ -1425,7 +1379,7 @@ const DepenseCard = React.memo(({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── TVA EXCEL ── */}
|
{/* ── TVA EXCEL ── */}
|
||||||
{!isKm && isExcel && depense.categorie && (
|
{!isKm && isExcel && (
|
||||||
<TvaExcelTable
|
<TvaExcelTable
|
||||||
tvaItems={tvaItems}
|
tvaItems={tvaItems}
|
||||||
onUpdate={items => set("tvaItems", items)}
|
onUpdate={items => set("tvaItems", items)}
|
||||||
@@ -1433,8 +1387,7 @@ const DepenseCard = React.memo(({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── NUITS (Hébergement) ── */}
|
{/* ── NUITS (Hébergement) ── */}
|
||||||
{depense.categorie.toLowerCase().includes("hebergement") && depense.categorie && (
|
{depense.categorie.toLowerCase().includes("hebergement") && (
|
||||||
|
|
||||||
<div className="nn-nuits-box">
|
<div className="nn-nuits-box">
|
||||||
<div>
|
<div>
|
||||||
<div className="nn-nuits-label">🌙 Nombre de nuits</div>
|
<div className="nn-nuits-label">🌙 Nombre de nuits</div>
|
||||||
@@ -1471,94 +1424,46 @@ const DepenseCard = React.memo(({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── TVA CLASSIQUE ── */}
|
{/* ── TVA CLASSIQUE ── */}
|
||||||
{!isKm && !isExcel && depense.categorie && (
|
{!isKm && !isExcel && (
|
||||||
<div className={`nn-tva-box${tvaItems.some(it => !it.taux || it.taux === "") ? " nn-tva-alert" : ""}`}>
|
<div className="nn-tva-box">
|
||||||
<div className="nn-tva-head">
|
<div className="nn-tva-head">
|
||||||
<span className="nn-tva-title">Montants & 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={{
|
<div key={i} style={{ fontSize: 9, fontWeight: 700, textTransform: "uppercase", color: "var(--text-secondary, #9ca3af)", letterSpacing: ".04em" }}>{h}</div>
|
||||||
fontSize: 9, fontWeight: 700, textTransform: "uppercase",
|
|
||||||
color: i === 0 ? "#f59e0b" : "var(--text-secondary, #9ca3af)",
|
|
||||||
letterSpacing: ".04em",
|
|
||||||
}}>
|
|
||||||
{i === 0 ? "⚠ " + h + " *" : h}
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tvaItems.map((item, idx) => {
|
{tvaItems.map((item, idx) => {
|
||||||
const ttc = parseFloat(item.montantTTC) || 0;
|
const ttc = parseFloat(item.montantTTC) || 0;
|
||||||
const taux = parseFloat(item.taux) || 0;
|
const taux = parseFloat(item.taux) || 0;
|
||||||
const ht = ttcToHt(ttc, taux);
|
const ht = ttcToHt(ttc, taux);
|
||||||
const tva = ttcToTva(ttc, taux);
|
const tva = ttcToTva(ttc, taux);
|
||||||
const tauxManquant = !item.taux || item.taux === "";
|
|
||||||
return (
|
return (
|
||||||
<div key={idx} className="nn-tva-row">
|
<div key={idx} className="nn-tva-row">
|
||||||
<button type="button" className="nn-tva-btn add"
|
<button type="button" className="nn-tva-btn add" onClick={() => addTvaAfter(idx)} title="Ajouter une ligne après">+</button>
|
||||||
onClick={() => addTvaAfter(idx)} title="Ajouter une ligne après">+</button>
|
<button type="button" className="nn-tva-btn rm" disabled={tvaItems.length <= 1} onClick={() => removeTva(idx)} title="Supprimer cette ligne">−</button>
|
||||||
<button type="button" className="nn-tva-btn rm"
|
<select className="nn-input" style={{ fontSize: 12, padding: "6px 8px" }}
|
||||||
disabled={tvaItems.length <= 1}
|
value={item.taux} onChange={e => updTva(idx, "taux", e.target.value)}>
|
||||||
onClick={() => removeTva(idx)} title="Supprimer cette ligne">−</button>
|
<option value="">— Taux —</option>
|
||||||
|
|
||||||
{/* ── Select TVA mis en évidence si non renseigné ── */}
|
|
||||||
<div style={{ position: "relative" }}>
|
|
||||||
<select
|
|
||||||
className={`nn-input${tauxManquant ? " nn-tva-select-highlight" : ""}`}
|
|
||||||
style={{ fontSize: 12, padding: "6px 8px" }}
|
|
||||||
value={item.taux}
|
|
||||||
onChange={e => updTva(idx, "taux", e.target.value)}>
|
|
||||||
<option value="">⚠ Choisir un taux *</option>
|
|
||||||
{TAUX_TVA.map((t, i) => (
|
{TAUX_TVA.map((t, i) => (
|
||||||
<option key={i} value={String(t.taux)}>{t.libelle}</option>
|
<option key={i} value={String(t.taux)}>{t.libelle}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
{tauxManquant && (
|
|
||||||
<span style={{
|
|
||||||
position: "absolute", right: 26, top: "50%",
|
|
||||||
transform: "translateY(-50%)",
|
|
||||||
fontSize: 13, pointerEvents: "none",
|
|
||||||
}}>⚠️</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ position: "relative" }}>
|
<div style={{ position: "relative" }}>
|
||||||
<input type="number" min="0" step="0.01" className="nn-input"
|
<input type="number" min="0" step="0.01" className="nn-input"
|
||||||
style={{
|
style={{ fontSize: 12, padding: "6px 22px 6px 8px" }}
|
||||||
fontSize: 12, padding: "6px 22px 6px 8px",
|
|
||||||
borderColor: tauxManquant ? "rgba(245,158,11,.5)" : undefined,
|
|
||||||
background: tauxManquant ? "rgba(245,158,11,.04)" : undefined,
|
|
||||||
}}
|
|
||||||
value={item.montantTTC}
|
value={item.montantTTC}
|
||||||
onChange={e => updTva(idx, "montantTTC", e.target.value)}
|
onChange={e => updTva(idx, "montantTTC", e.target.value)}
|
||||||
placeholder="0,00" />
|
placeholder="0,00" />
|
||||||
<span style={{
|
<span style={{ position: "absolute", right: 7, top: "50%", transform: "translateY(-50%)", fontSize: 11, color: "#9ca3af", pointerEvents: "none" }}>€</span>
|
||||||
position: "absolute", right: 7, top: "50%",
|
|
||||||
transform: "translateY(-50%)", fontSize: 11,
|
|
||||||
color: "#9ca3af", pointerEvents: "none",
|
|
||||||
}}>€</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={`nn-tva-computed ttc${ttc <= 0 ? " na" : ""}`}>
|
|
||||||
{ttc > 0 ? fmt(ht) : "—"}
|
|
||||||
</div>
|
|
||||||
<div className={`nn-tva-computed tva${tva <= 0 ? " na" : ""}`}>
|
|
||||||
{tva > 0 ? fmt(tva) : taux === 0 && ttc > 0 ? "0 €" : "—"}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className={`nn-tva-computed ttc${ttc <= 0 ? " na" : ""}`}>{ttc > 0 ? fmt(ht) : "—"}</div>
|
||||||
|
<div className={`nn-tva-computed tva${tva <= 0 ? " na" : ""}`}>{tva > 0 ? fmt(tva) : taux === 0 && ttc > 0 ? "0 €" : "—"}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -1589,7 +1494,7 @@ const DepenseCard = React.memo(({
|
|||||||
<div className="nn-repas-info">
|
<div className="nn-repas-info">
|
||||||
<span className="nn-repas-info-icon">ℹ️</span>
|
<span className="nn-repas-info-icon">ℹ️</span>
|
||||||
<span>
|
<span>
|
||||||
Un repas professionnel <strong>ne doit pas dépasser 25 € par personne</strong>.
|
Un repas professionnel <strong>ne doit pas dépasser 25 € par personne</strong> (sauf repas événementiel).
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1831,7 +1736,7 @@ export default function NouvelleNote({
|
|||||||
initialBrouillonId = null,
|
initialBrouillonId = null,
|
||||||
libelleInitial = '', dateDebutInitiale = '', commentaireInitial = '',
|
libelleInitial = '', dateDebutInitiale = '', commentaireInitial = '',
|
||||||
depensesInitiales,
|
depensesInitiales,
|
||||||
onNavigateToProfil, onBrouillonChange,
|
onNavigateToProfil,
|
||||||
}: NouvelleNoteProps) {
|
}: NouvelleNoteProps) {
|
||||||
|
|
||||||
const initDepenses = (defaultCv = 7): Depense[] => {
|
const initDepenses = (defaultCv = 7): Depense[] => {
|
||||||
@@ -1859,7 +1764,6 @@ export default function NouvelleNote({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const [titre, setTitre] = useState(libelleInitial || "");
|
const [titre, setTitre] = useState(libelleInitial || "");
|
||||||
const [wasOnceActive, setWasOnceActive] = useState(false);
|
|
||||||
const [dateDebut, setDateDebut] = useState(dateDebutInitiale || "");
|
const [dateDebut, setDateDebut] = useState(dateDebutInitiale || "");
|
||||||
const [dateFin, setDateFin] = useState("");
|
const [dateFin, setDateFin] = useState("");
|
||||||
const [commentaire, setComment] = useState(commentaireInitial || "");
|
const [commentaire, setComment] = useState(commentaireInitial || "");
|
||||||
@@ -1883,24 +1787,6 @@ export default function NouvelleNote({
|
|||||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const isFirstRender = useRef(true);
|
const isFirstRender = useRef(true);
|
||||||
const activeBrouillonIdRef = useRef<number | null>(initialBrouillonId);
|
const activeBrouillonIdRef = useRef<number | null>(initialBrouillonId);
|
||||||
const errorBannerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const depenseRefs = useRef<Record<number, HTMLDivElement | null>>({});
|
|
||||||
|
|
||||||
const isSavingRef = useRef(false);
|
|
||||||
const needsResaveRef = useRef(false);
|
|
||||||
const latestStateRef = useRef({ titre, dateDebut, dateFin, commentaire, depenses });
|
|
||||||
const hasLoadedInitial = useRef(false);
|
|
||||||
const mountInitialIdRef = useRef(initialBrouillonId);
|
|
||||||
const persistRef = useRef<() => void>(() => { });
|
|
||||||
|
|
||||||
// snapshot de l'état toujours à jour pour le flush et la sauvegarde
|
|
||||||
useEffect(() => {
|
|
||||||
latestStateRef.current = { titre, dateDebut, dateFin, commentaire, depenses };
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (activeBrouillonId !== null) setWasOnceActive(true);
|
|
||||||
}, [activeBrouillonId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authToken) return;
|
if (!authToken) return;
|
||||||
@@ -1932,10 +1818,8 @@ export default function NouvelleNote({
|
|||||||
setDepenses(p => p.map(d => d.id === id ? { ...d, [f]: v } : d)), []);
|
setDepenses(p => p.map(d => d.id === id ? { ...d, [f]: v } : d)), []);
|
||||||
const handleDeleteDepense = useCallback((id: number) => { filesStore.delete(id); setDepenses(p => p.filter(d => d.id !== id)); }, []);
|
const handleDeleteDepense = useCallback((id: number) => { filesStore.delete(id); setDepenses(p => p.filter(d => d.id !== id)); }, []);
|
||||||
const handleAddDepense = useCallback(() => {
|
const handleAddDepense = useCallback(() => {
|
||||||
const d = newDepense(profilVehicule?.chevaux ?? 7);
|
const d = newDepense(); setDepenses(p => [...p, d]); setExpandedId(d.id);
|
||||||
setDepenses(p => [...p, d]);
|
}, []);
|
||||||
setExpandedId(d.id);
|
|
||||||
}, [profilVehicule]);
|
|
||||||
|
|
||||||
const generateQRForDepense = useCallback(async (depenseId: number) => {
|
const generateQRForDepense = useCallback(async (depenseId: number) => {
|
||||||
const tempRef = `NDF-DEP-${depenseId}-${Date.now()}`;
|
const tempRef = `NDF-DEP-${depenseId}-${Date.now()}`;
|
||||||
@@ -1951,14 +1835,13 @@ export default function NouvelleNote({
|
|||||||
const s = await r.json();
|
const s = await r.json();
|
||||||
if (s.uploaded) {
|
if (s.uploaded) {
|
||||||
clearInterval(qrPollingRefs.current[depenseId]);
|
clearInterval(qrPollingRefs.current[depenseId]);
|
||||||
delete qrPollingRefs.current[depenseId]; // ✅ empêche un 2e tick concurrent de re-déclencher la fusion
|
|
||||||
setDepenses(prev => prev.map(d => d.id === depenseId
|
setDepenses(prev => prev.map(d => d.id === depenseId
|
||||||
? {
|
? {
|
||||||
...d, qrUploaded: true, qrLink: null,
|
...d, qrUploaded: true, qrLink: null,
|
||||||
qrFiles: [
|
qrFiles: [
|
||||||
...(d.qrFiles ?? []),
|
...(d.qrFiles ?? []),
|
||||||
...(Array.isArray(s.files) ? s.files.map((f: any) => ({ ...f, origin: 'qr' as const })) : [])
|
...(Array.isArray(s.files) ? s.files.map((f: any) => ({ ...f, origin: 'qr' as const })) : [])
|
||||||
].filter((f, i, arr) => arr.findIndex(x => x.uploadUrl === f.uploadUrl) === i) // ✅ dédup par URL
|
]
|
||||||
}
|
}
|
||||||
: d));
|
: d));
|
||||||
}
|
}
|
||||||
@@ -2030,134 +1913,60 @@ export default function NouvelleNote({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (depensesInitiales && depensesInitiales.length > 0) return;
|
if (depensesInitiales && depensesInitiales.length > 0) return;
|
||||||
if (hasLoadedInitial.current) return; // déjà chargé
|
if (!initialBrouillonId || !brouillons.length) return;
|
||||||
if (!mountInitialIdRef.current || !brouillons.length) return;
|
const b = brouillons.find(b => b.id === initialBrouillonId);
|
||||||
const b = brouillons.find(x => x.id === mountInitialIdRef.current);
|
|
||||||
if (b) loadBrouillon(b);
|
if (b) loadBrouillon(b);
|
||||||
hasLoadedInitial.current = true;
|
}, [initialBrouillonId, brouillons]);
|
||||||
}, [brouillons]); // ← plus de dépendance sur initialBrouillonId
|
|
||||||
|
|
||||||
// ── persistBrouillon — envoi correct des fichiers ──
|
const scheduleSave = useCallback((state: any) => {
|
||||||
const persistBrouillon = useCallback(async () => {
|
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||||||
if (isSavingRef.current) {
|
|
||||||
needsResaveRef.current = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
isSavingRef.current = true;
|
|
||||||
setSaveStatus("saving");
|
setSaveStatus("saving");
|
||||||
try {
|
saveTimerRef.current = setTimeout(async () => {
|
||||||
const state = latestStateRef.current;
|
|
||||||
const currentId = activeBrouillonIdRef.current;
|
const currentId = activeBrouillonIdRef.current;
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append("libelle", state.titre || "Sans titre");
|
fd.append("libelle", state.titre || "Sans titre");
|
||||||
fd.append("date", state.dateDebut || new Date().toISOString().split("T")[0]);
|
fd.append("date", state.dateDebut || new Date().toISOString().split("T")[0]);
|
||||||
fd.append("description", state.commentaire || "");
|
fd.append("description", state.commentaire || "");
|
||||||
fd.append("lignes", JSON.stringify(serializeDepenses(state.depenses)));
|
fd.append("lignes", JSON.stringify(serializeDepenses(state.depenses)));
|
||||||
|
|
||||||
// ✅ FIX : lire depuis state.depenses.files ET filesStore
|
|
||||||
for (const dep of state.depenses) {
|
for (const dep of state.depenses) {
|
||||||
// Fichiers en mémoire React (depense.files)
|
for (const file of getStoredFiles(dep.id)) fd.append(`files_${dep.id}`, file);
|
||||||
const reactFiles = dep.files ?? [];
|
|
||||||
// Fichiers dans le store séparé
|
|
||||||
const storedFiles = getStoredFiles(dep.id);
|
|
||||||
|
|
||||||
// Fusionner sans doublons (filesStore peut contenir des copies de dep.files)
|
|
||||||
const allFiles = storedFiles.length > 0 ? storedFiles : reactFiles;
|
|
||||||
|
|
||||||
for (const file of allFiles) {
|
|
||||||
fd.append(`files_${dep.id}`, file);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const headers = { Authorization: `Bearer ${authToken}` };
|
const headers = { Authorization: `Bearer ${authToken}` };
|
||||||
|
try {
|
||||||
if (currentId) {
|
if (currentId) {
|
||||||
const res = await fetch(`${apiBaseUrl}/api/notes/brouillons/${currentId}`, {
|
const res = await fetch(`${apiBaseUrl}/api/notes/brouillons/${currentId}`, { method: "PUT", headers, body: fd });
|
||||||
method: "PUT", headers, body: fd
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.uploadedFiles) {
|
if (data.uploadedFiles) {
|
||||||
setDepenses(prev => prev.map(d => {
|
setDepenses(prev => prev.map(d => {
|
||||||
const uploaded = data.uploadedFiles[d.id];
|
const uploaded = data.uploadedFiles[d.id];
|
||||||
if (!uploaded?.length) return d;
|
if (!uploaded?.length) return d;
|
||||||
const merged = [
|
const newQrFiles = [
|
||||||
...(d.qrFiles ?? []),
|
...(d.qrFiles ?? []),
|
||||||
...uploaded.map((f: any) => ({ ...f, origin: 'upload' as const })),
|
...uploaded.map((f: any) => ({ ...f, origin: 'upload' as const }))
|
||||||
].filter((f, i, arr) => arr.findIndex(x => x.uploadUrl === f.uploadUrl) === i);
|
].filter((f, i, arr) => arr.findIndex(x => x.uploadUrl === f.uploadUrl) === i);
|
||||||
setStoredFiles(d.id, []);
|
return { ...d, qrFiles: newQrFiles };
|
||||||
// ✅ Garder files en état React — seulement vider le store
|
|
||||||
// Les qrFiles contiennent déjà l'URL SharePoint pour le re-submit
|
|
||||||
return { ...d, qrFiles: merged };
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
// ✅ FIX : si pas d'uploadedFiles dans la réponse, vider quand même le store
|
|
||||||
// car le serveur a bien reçu les fichiers
|
|
||||||
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const res = await fetch(`${apiBaseUrl}/api/notes/brouillons`, {
|
const res = await fetch(`${apiBaseUrl}/api/notes/brouillons`, { method: "POST", headers, body: fd });
|
||||||
method: "POST", headers, body: fd
|
|
||||||
});
|
|
||||||
const created = await res.json();
|
const created = await res.json();
|
||||||
activeBrouillonIdRef.current = created.id;
|
activeBrouillonIdRef.current = created.id;
|
||||||
setActiveBrouillonId(created.id);
|
setActiveBrouillonId(created.id);
|
||||||
onBrouillonChange?.(created.id);
|
|
||||||
|
|
||||||
// ✅ FIX : vider le store après création réussie
|
|
||||||
if (created.uploadedFiles) {
|
|
||||||
setDepenses(prev => prev.map(d => {
|
|
||||||
const uploaded = created.uploadedFiles[d.id];
|
|
||||||
if (!uploaded?.length) return d;
|
|
||||||
const merged = [
|
|
||||||
...(d.qrFiles ?? []),
|
|
||||||
...uploaded.map((f: any) => ({ ...f, origin: 'upload' as const })),
|
|
||||||
].filter((f, i, arr) => arr.findIndex(x => x.uploadUrl === f.uploadUrl) === i);
|
|
||||||
setStoredFiles(d.id, []);
|
|
||||||
return { ...d, files: [], qrFiles: merged };
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
await fetchBrouillons();
|
await fetchBrouillons();
|
||||||
setSaveStatus("saved");
|
setSaveStatus("saved");
|
||||||
|
state.depenses.forEach((d: Depense) => setStoredFiles(d.id, []));
|
||||||
} catch {
|
} catch { setSaveStatus("error"); }
|
||||||
setSaveStatus("error");
|
|
||||||
} finally {
|
|
||||||
isSavingRef.current = false;
|
|
||||||
if (needsResaveRef.current) {
|
|
||||||
needsResaveRef.current = false;
|
|
||||||
persistBrouillon();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [apiBaseUrl, authToken, fetchBrouillons, onBrouillonChange]);
|
|
||||||
|
|
||||||
const scheduleSave = useCallback(() => {
|
|
||||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
|
||||||
setSaveStatus("saving");
|
|
||||||
saveTimerRef.current = setTimeout(() => {
|
|
||||||
saveTimerRef.current = null; // marque comme exécuté
|
|
||||||
persistBrouillon();
|
|
||||||
}, 1500);
|
}, 1500);
|
||||||
}, [persistBrouillon]);
|
}, [apiBaseUrl, authToken, fetchBrouillons]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isFirstRender.current) { isFirstRender.current = false; return; }
|
if (isFirstRender.current) { isFirstRender.current = false; return; }
|
||||||
scheduleSave(); // plus besoin de passer l'état
|
scheduleSave({ titre, dateDebut, dateFin, commentaire, depenses });
|
||||||
}, [titre, dateDebut, dateFin, commentaire, depenses, scheduleSave]);
|
}, [titre, dateDebut, dateFin, commentaire, depenses, scheduleSave]);
|
||||||
|
|
||||||
useEffect(() => { persistRef.current = persistBrouillon; });
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (saveTimerRef.current) {
|
|
||||||
clearTimeout(saveTimerRef.current);
|
|
||||||
persistRef.current();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const loadBrouillon = (b: BrouillonServeur) => {
|
const loadBrouillon = (b: BrouillonServeur) => {
|
||||||
setTitre(b.libelle || ""); setComment(b.description || "");
|
setTitre(b.libelle || ""); setComment(b.description || "");
|
||||||
setDateDebut(b.date ? b.date.split("T")[0] : "");
|
setDateDebut(b.date ? b.date.split("T")[0] : "");
|
||||||
@@ -2200,57 +2009,35 @@ export default function NouvelleNote({
|
|||||||
fetchBrouillons();
|
fetchBrouillons();
|
||||||
} catch { }
|
} catch { }
|
||||||
};
|
};
|
||||||
const scrollToError = (depenseId?: number) => {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const target = depenseId ? depenseRefs.current[depenseId] : errorBannerRef.current;
|
|
||||||
if (target) {
|
|
||||||
target.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
||||||
} else {
|
|
||||||
errorBannerRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
setSubmitError("");
|
setSubmitError("");
|
||||||
if (submitting) return;
|
if (submitting) return;
|
||||||
if (!titre.trim()) {
|
if (!titre.trim()) { setSubmitError("Veuillez saisir un titre."); return; }
|
||||||
setSubmitError("Veuillez saisir un titre.");
|
|
||||||
scrollToError();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (dateDebut && isDateFutureMonth(dateDebut)) {
|
if (dateDebut && isDateFutureMonth(dateDebut)) {
|
||||||
setSubmitError("La date de la note ne peut pas être dans un mois futur. Vous ne pouvez créer des notes que pour le mois en cours ou des mois passés.");
|
setSubmitError("La date de la note ne peut pas être dans un mois futur. Vous ne pouvez créer des notes que pour le mois en cours ou des mois passés.");
|
||||||
scrollToError();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (const d of depenses) {
|
for (const d of depenses) {
|
||||||
if (!d.date || !d.libelle.trim()) {
|
if (!d.date || !d.libelle.trim()) {
|
||||||
setSubmitError(`La dépense "${d.libelle || "sans libellé"}" doit avoir une date et un libellé.`);
|
setSubmitError(`La dépense "${d.libelle || "sans libellé"}" doit avoir une date et un libellé.`);
|
||||||
setExpandedId(d.id);
|
setExpandedId(d.id); return;
|
||||||
scrollToError(d.id);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
// ✅ Bloquer les dates dans un mois futur
|
// ✅ Bloquer les dates dans un mois futur
|
||||||
if (isDateFutureMonth(d.date)) {
|
if (isDateFutureMonth(d.date)) {
|
||||||
setSubmitError(`"${d.libelle}" — la date ne peut pas être dans un mois futur. Vous ne pouvez soumettre des frais que pour le mois en cours ou des mois passés.`);
|
setSubmitError(`"${d.libelle}" — la date ne peut pas être dans un mois futur. Vous ne pouvez soumettre des frais que pour le mois en cours ou des mois passés.`);
|
||||||
setExpandedId(d.id);
|
setExpandedId(d.id); return;
|
||||||
scrollToError(d.id);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
const isKmLine = d.categorie.toLowerCase().includes("kilom");
|
const isKmLine = d.categorie.toLowerCase().includes("kilom");
|
||||||
if (isKmLine && (!d.km || parseFloat(d.km) <= 0)) {
|
if (isKmLine && (!d.km || parseFloat(d.km) <= 0)) {
|
||||||
setSubmitError(`"${d.libelle}" doit avoir un kilométrage.`);
|
setSubmitError(`"${d.libelle}" doit avoir un kilométrage.`);
|
||||||
setExpandedId(d.id);
|
setExpandedId(d.id); return;
|
||||||
scrollToError(d.id);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
if (!isKmLine) {
|
if (!isKmLine) {
|
||||||
const hasFiles = d.files.length > 0 || (d.filesMeta ?? []).length > 0 || (d.qrFiles ?? []).length > 0;
|
const hasFiles = d.files.length > 0 || (d.filesMeta ?? []).length > 0 || (d.qrFiles ?? []).length > 0;
|
||||||
if (!hasFiles) {
|
if (!hasFiles) {
|
||||||
setSubmitError(`"${d.libelle}" — un justificatif est obligatoire pour cette catégorie.`);
|
setSubmitError(`"${d.libelle}" — un justificatif est obligatoire pour cette catégorie.`);
|
||||||
setExpandedId(d.id);
|
setExpandedId(d.id); return;
|
||||||
scrollToError(d.id);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (d.categorie.toLowerCase().includes("repas")) {
|
if (d.categorie.toLowerCase().includes("repas")) {
|
||||||
@@ -2258,9 +2045,7 @@ export default function NouvelleNote({
|
|||||||
const p = d.participants[pi];
|
const p = d.participants[pi];
|
||||||
if (!p.nom?.trim() || !p.prenom?.trim()) {
|
if (!p.nom?.trim() || !p.prenom?.trim()) {
|
||||||
setSubmitError(`"${d.libelle}" — le nom et prénom du participant ${pi + 1} sont obligatoires.`);
|
setSubmitError(`"${d.libelle}" — le nom et prénom du participant ${pi + 1} sont obligatoires.`);
|
||||||
setExpandedId(d.id);
|
setExpandedId(d.id); return;
|
||||||
scrollToError(d.id);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Pas de blocage sur le dépassement 25€ — c'est un warning uniquement
|
// Pas de blocage sur le dépassement 25€ — c'est un warning uniquement
|
||||||
@@ -2268,24 +2053,14 @@ export default function NouvelleNote({
|
|||||||
}
|
}
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const depensesBackend = depenses.map(d => {
|
const depensesBackend = depenses.map(d => ({
|
||||||
const storedFiles = getStoredFiles(d.id);
|
|
||||||
const allFiles = storedFiles.length > 0
|
|
||||||
? [...d.files, ...storedFiles.filter(sf => !d.files.some(rf => rf.name === sf.name && rf.size === sf.size))]
|
|
||||||
: d.files;
|
|
||||||
return {
|
|
||||||
...d,
|
...d,
|
||||||
files: allFiles,
|
files: getStoredFiles(d.id).length > 0 ? getStoredFiles(d.id) : d.files,
|
||||||
tvaItems: d.tvaItems.map(tvaItemToBackend),
|
tvaItems: d.tvaItems.map(tvaItemToBackend),
|
||||||
qrFiles: d.qrFiles ?? [],
|
qrFiles: d.qrFiles ?? [],
|
||||||
};
|
}));
|
||||||
});
|
|
||||||
await onSubmit(titre, dateDebut, dateFin, commentaire, depensesBackend);
|
await onSubmit(titre, dateDebut, dateFin, commentaire, depensesBackend);
|
||||||
} catch (e: any) {
|
} catch (e: any) { setSubmitError(e.message || "Erreur lors de la soumission"); setSubmitting(false); }
|
||||||
setSubmitError(e.message || "Erreur lors de la soumission");
|
|
||||||
setSubmitting(false);
|
|
||||||
scrollToError();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ✅ Calcul des totaux globaux — gestion du cas MIXED
|
// ✅ Calcul des totaux globaux — gestion du cas MIXED
|
||||||
@@ -2336,7 +2111,7 @@ export default function NouvelleNote({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{libelleInitial && !activeBrouillon && !wasOnceActive && (
|
{libelleInitial && !activeBrouillon && (
|
||||||
<div style={{ background: "linear-gradient(90deg, #fef2f2, #fff1f1)", border: "1.5px solid #fecaca", borderLeft: "4px solid #ef4444", borderRadius: "0 10px 10px 0", padding: "10px 16px", display: "flex", alignItems: "center", gap: 10 }}>
|
<div style={{ background: "linear-gradient(90deg, #fef2f2, #fff1f1)", border: "1.5px solid #fecaca", borderLeft: "4px solid #ef4444", borderRadius: "0 10px 10px 0", padding: "10px 16px", display: "flex", alignItems: "center", gap: 10 }}>
|
||||||
<span style={{ fontSize: 18, flexShrink: 0 }}>✏️</span>
|
<span style={{ fontSize: 18, flexShrink: 0 }}>✏️</span>
|
||||||
<div>
|
<div>
|
||||||
@@ -2450,7 +2225,7 @@ export default function NouvelleNote({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{submitError && <div ref={errorBannerRef} className="nn-error">⚠️ {submitError}</div>}
|
{submitError && <div className="nn-error">⚠️ {submitError}</div>}
|
||||||
|
|
||||||
<div className="nn-meta">
|
<div className="nn-meta">
|
||||||
<div className="nn-meta-field">
|
<div className="nn-meta-field">
|
||||||
@@ -2484,15 +2259,13 @@ export default function NouvelleNote({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{depenses.map((d, i) => (
|
{depenses.map((d, i) => (
|
||||||
<div key={d.id} ref={el => { depenseRefs.current[d.id] = el; }}>
|
<DepenseCard key={d.id} depense={d} index={i} total={depenses.length}
|
||||||
<DepenseCard depense={d} index={i} total={depenses.length}
|
|
||||||
expanded={expandedId === d.id}
|
expanded={expandedId === d.id}
|
||||||
onToggle={handleToggle} onUpdate={handleUpdateDepense}
|
onToggle={handleToggle} onUpdate={handleUpdateDepense}
|
||||||
onDelete={handleDeleteDepense} onGenerateQR={generateQRForDepense}
|
onDelete={handleDeleteDepense} onGenerateQR={generateQRForDepense}
|
||||||
disabled={submitting} apiBaseUrl={apiBaseUrl} profilVehicule={profilVehicule}
|
disabled={submitting} apiBaseUrl={apiBaseUrl} profilVehicule={profilVehicule}
|
||||||
onNavigateToProfil={onNavigateToProfil}
|
onNavigateToProfil={onNavigateToProfil}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<button className="nn-btn-add-card" onClick={handleAddDepense} disabled={submitting}>
|
<button className="nn-btn-add-card" onClick={handleAddDepense} disabled={submitting}>
|
||||||
|
|||||||
@@ -36,20 +36,10 @@ interface LigneState { status: 'ok' | 'refused' | 'pending'; motif?: string; }
|
|||||||
|
|
||||||
const fmt = (n: number) => new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(n);
|
const fmt = (n: number) => new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(n);
|
||||||
const fmtDate = (d?: string) => d ? new Date(d).toLocaleDateString('fr-FR') : '—';
|
const fmtDate = (d?: string) => d ? new Date(d).toLocaleDateString('fr-FR') : '—';
|
||||||
const getIndemniteKm = (km: number, cv: number): number => {
|
const getIndemniteKm = (km: number, cv: number) => {
|
||||||
const BAREME: Record<number, { t1: number; t2_a: number; t2_b: number; t3: number }> = {
|
const BAREME: Record<number, number> = { 3: 0.529, 4: 0.606, 5: 0.636, 6: 0.665, 7: 0.697 };
|
||||||
3: { t1: 0.529, t2_a: 0.316, t2_b: 1061, t3: 0.369 },
|
return km * (BAREME[Math.min(Math.max(cv, 3), 7)] ?? 0.697);
|
||||||
4: { t1: 0.606, t2_a: 0.340, t2_b: 1330, t3: 0.408 },
|
};
|
||||||
5: { t1: 0.636, t2_a: 0.356, t2_b: 1391, t3: 0.427 },
|
|
||||||
6: { t1: 0.665, t2_a: 0.374, t2_b: 1457, t3: 0.448 },
|
|
||||||
7: { t1: 0.697, t2_a: 0.394, t2_b: 1512, t3: 0.470 },
|
|
||||||
};
|
|
||||||
const b = BAREME[Math.min(Math.max(cv, 3), 7)];
|
|
||||||
if (km <= 0) return 0;
|
|
||||||
if (km <= 5000) return parseFloat((km * b.t1).toFixed(2));
|
|
||||||
if (km <= 20000) return parseFloat((km * b.t2_a + b.t2_b).toFixed(2));
|
|
||||||
return parseFloat((km * b.t3).toFixed(2));
|
|
||||||
};
|
|
||||||
const ligneKey = (noteId: number, idx: number) => `${noteId}-l${idx}`;
|
const ligneKey = (noteId: number, idx: number) => `${noteId}-l${idx}`;
|
||||||
const SYSTEME_KEYWORDS = ['soumission', 'resoumission', 'recap-paiement', 'recap', 'signe-approuve', 'signeapprouve', 'signe_approuve'];
|
const SYSTEME_KEYWORDS = ['soumission', 'resoumission', 'recap-paiement', 'recap', 'signe-approuve', 'signeapprouve', 'signe_approuve'];
|
||||||
const isSystemFile = (f: Fichier) => SYSTEME_KEYWORDS.some(kw => (f.fileName ?? '').toLowerCase().includes(kw));
|
const isSystemFile = (f: Fichier) => SYSTEME_KEYWORDS.some(kw => (f.fileName ?? '').toLowerCase().includes(kw));
|
||||||
@@ -700,7 +690,7 @@ export default function VerificateurFinance4Panels({
|
|||||||
<div className="vf-note-meta">{[note.collaborateur, note.campus, note.departement, fmtDate(note.date)].filter(Boolean).join(' · ')}</div>
|
<div className="vf-note-meta">{[note.collaborateur, note.campus, note.departement, fmtDate(note.date)].filter(Boolean).join(' · ')}</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ textAlign: 'right' }}>
|
<div style={{ textAlign: 'right' }}>
|
||||||
{nbModifs > 0 && <div style={{ fontSize: 9, color: '#6b7280', textDecoration: 'line-through', fontFamily: 'DM Mono,monospace', textAlign: 'right' }}>{fmt(note.montant || 0)}</div>}
|
<div className={`vf-note-amt ${nbModifs > 0 ? 'adj' : ''}`} style={{ color: nbModifs > 0 ? '#15803d' : undefined }}>{nbModifs > 0 ? fmt(totalAjuste) : fmt(note.montant || 0)}</div>
|
||||||
{nbModifs > 0 && <div style={{ fontSize: 9, color: '#6b7280', textDecoration: 'line-through', fontFamily: 'DM Mono,monospace', textAlign: 'right' }}>{fmt(note.montant || 0)}</div>}
|
{nbModifs > 0 && <div style={{ fontSize: 9, color: '#6b7280', textDecoration: 'line-through', fontFamily: 'DM Mono,monospace', textAlign: 'right' }}>{fmt(note.montant || 0)}</div>}
|
||||||
<div className="vf-note-sub">{lignesData.length} ligne{lignesData.length > 1 ? 's' : ''}{nbModifs > 0 ? ` · ${nbModifs} ajust.` : ''}</div>
|
<div className="vf-note-sub">{lignesData.length} ligne{lignesData.length > 1 ? 's' : ''}{nbModifs > 0 ? ` · ${nbModifs} ajust.` : ''}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -740,7 +730,7 @@ export default function VerificateurFinance4Panels({
|
|||||||
<div style={{ fontSize: 11, fontWeight: 700, color: '#111827', marginBottom: 3 }}>{note.libelle}</div>
|
<div style={{ fontSize: 11, fontWeight: 700, color: '#111827', marginBottom: 3 }}>{note.libelle}</div>
|
||||||
<div className="vf-sum-div" />
|
<div className="vf-sum-div" />
|
||||||
<div className="vf-sum-price-lbl">Total demandé</div>
|
<div className="vf-sum-price-lbl">Total demandé</div>
|
||||||
<div className={`vf-sum-price ${nbModifs > 0 ? 'adj' : ''}`}>{fmt(totalAjuste)}</div>
|
<div className={`vf-sum-price ${nbModifs > 0 ? 'adj' : ''}`}>{fmt(note.montant || 0)}</div>
|
||||||
{nbModifs > 0 && <div className="vf-sum-adj">→ {fmt(totalAjuste)} après ajust.</div>}
|
{nbModifs > 0 && <div className="vf-sum-adj">→ {fmt(totalAjuste)} après ajust.</div>}
|
||||||
<div className="vf-sum-div" />
|
<div className="vf-sum-div" />
|
||||||
<div className="vf-sum-meta">
|
<div className="vf-sum-meta">
|
||||||
@@ -892,7 +882,7 @@ export default function VerificateurFinance4Panels({
|
|||||||
</div>
|
</div>
|
||||||
<div className="vf-dec-total">
|
<div className="vf-dec-total">
|
||||||
<span className="vf-dec-total-lbl">Total retenu</span>
|
<span className="vf-dec-total-lbl">Total retenu</span>
|
||||||
<span className="vf-dec-total-val" style={{ color: '#111827' }}>{fmt(totalAjuste)}</span>
|
<span className="vf-dec-total-val" style={{ color: nbModifs > 0 ? '#15803d' : '#111827' }}>{fmt(nbModifs > 0 ? totalAjuste : (note.montant || 0))}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="vf-dec-final">
|
<div className="vf-dec-final">
|
||||||
{canValidate && <p className="vf-dec-hint ok"><CheckCircle size={9} style={{ display: 'inline', marginRight: 2 }} />Toutes conformes{nbModifs > 0 ? ` (${nbModifs} ajust.)` : ''}</p>}
|
{canValidate && <p className="vf-dec-hint ok"><CheckCircle size={9} style={{ display: 'inline', marginRight: 2 }} />Toutes conformes{nbModifs > 0 ? ` (${nbModifs} ajust.)` : ''}</p>}
|
||||||
@@ -947,20 +937,7 @@ export default function VerificateurFinance4Panels({
|
|||||||
<div style={{ fontSize: 9, color: '#6b7280', marginTop: 1 }}>{[h.collaborateur, h.campus, h.departement].filter(Boolean).join(' · ')}</div>
|
<div style={{ fontSize: 9, color: '#6b7280', marginTop: 1 }}>{[h.collaborateur, h.campus, h.departement].filter(Boolean).join(' · ')}</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ textAlign: 'right' }}>
|
<div style={{ textAlign: 'right' }}>
|
||||||
<div style={{ fontSize: 15, fontWeight: 800, color: isRefusee ? '#dc2626' : '#15803d', fontFamily: 'DM Mono,monospace' }}>
|
<div style={{ fontSize: 15, fontWeight: 800, color: isRefusee ? '#dc2626' : '#15803d', fontFamily: 'DM Mono,monospace' }}>{fmt(h.montant || 0)}</div>
|
||||||
{fmt((() => {
|
|
||||||
try {
|
|
||||||
const lignes = JSON.parse(h.lignesJson || '[]');
|
|
||||||
if (!lignes.length) return h.montant || 0;
|
|
||||||
return lignes.reduce((sum: number, l: any) => {
|
|
||||||
const isKm = (l.categorie || '').toLowerCase().includes('kilom');
|
|
||||||
const km = parseFloat(l.km || '0') || 0;
|
|
||||||
const cv = parseInt(l.chevaux || '7') || 7;
|
|
||||||
return sum + (isKm ? getIndemniteKm(km, cv) : (parseFloat(l.montant || '0') || 0));
|
|
||||||
}, 0);
|
|
||||||
} catch { return h.montant || 0; }
|
|
||||||
})())}
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 9, color: '#6b7280' }}>{new Date(h.dateVerification).toLocaleDateString('fr-FR', { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' })}</div>
|
<div style={{ fontSize: 9, color: '#6b7280' }}>{new Date(h.dateVerification).toLocaleDateString('fr-FR', { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' })}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user