Compare commits

4 Commits

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