Ajoutez des fichiers projet.
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,73 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
@@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Notes de Frais</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>Vous devez activer JavaScript pour utiliser cette application.</noscript>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+4469
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "ndf",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@azure/msal-node": "^5.0.4",
|
||||
"axios": "^1.13.5",
|
||||
"cors": "^2.8.6",
|
||||
"dotenv": "^17.3.1",
|
||||
"express": "^5.2.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"mssql": "^12.2.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfkit": "^0.17.2",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-qr-code": "^2.0.20",
|
||||
"react-router-dom": "^6.26.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@types/react": "^18.3.1",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"lucide-react": "^0.577.0",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^5.4.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
AZURE_CLIENT_ID=51a2c5b3-4cea-4752-93d0-bc59ea33be29
|
||||
AZURE_CLIENT_SECRET=uPD8Q~CJbl26DDrRWRgKXvvqwVVm0DtRhzkEqcyT
|
||||
AZURE_TENANT_ID=9840a2a0-6ae1-4688-b03d-d2ec291be0f9
|
||||
JWT_SECRET=un_secret_aleatoire_securise
|
||||
AZURE_GROUP_ID=c1ea877c-6bca-4f47-bfad-f223640813a0
|
||||
|
||||
|
||||
DB_SERVER=192.168.0.3
|
||||
DB_USER=ndf_app
|
||||
DB_PASSWORD=P@ssw0rd2026!
|
||||
DB_NAME=NDF
|
||||
DB_PORT=1433
|
||||
RESPONSABLE_PAIEMENT_EMAIL=aagromayor@ensup.eu
|
||||
MAIL_FROM=ndfnoreply@ensup.eu
|
||||
OAUTH_REDIRECT_URI=https://myndf.ensup-adm.net/api/auth/callback
|
||||
|
||||
PORT=3024
|
||||
|
||||
|
||||
|
||||
SHAREPOINT_SITE_ID=ensup.sharepoint.com,d94abc08-28eb-47ce-8e12-fbbd6f16b9ea,a052c325-d33a-40e3-9e7b-7896a2ea7ab7
|
||||
SHAREPOINT_DRIVE_ID=b!CLxK2esozkeOEvu9bxa56iXDUqA60-NAnnt4lqLqerfjKRsFHmtxSbj0s5KCssZK
|
||||
|
||||
IBAN_ENCRYPTION_KEY=a3f8c2d1e4b7096f5a2e1d8c3b4f7a90e2d1c8b5f3a6e9d0c7b4a1f8e5d2c9b6
|
||||
IBAN_HASH_SALT=b4c7e2a1f9d3086e5c4a2b8f1e7d3c9a
|
||||
|
||||
COMPANY_NAME=ENSUP GROUP
|
||||
COMPANY_IBAN=FR76XXXXXXXXXXXXXXXXXXXXXXXXX
|
||||
COMPANY_BIC=BNPAFRPP
|
||||
COMPANY_ADDRESS=1 RUE DE LA PAIX
|
||||
COMPANY_CP=75009
|
||||
COMPANY_VILLE=PARIS 09
|
||||
COMPANY_PAYS=FR
|
||||
@@ -0,0 +1,18 @@
|
||||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies (including pdfkit)
|
||||
RUN npm ci --only=production
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3024
|
||||
|
||||
# Start the server
|
||||
CMD ["node", "server.js"]
|
||||
@@ -0,0 +1,27 @@
|
||||
require('dotenv').config();
|
||||
|
||||
const msalConfig = {
|
||||
auth: {
|
||||
clientId: process.env.AZURE_CLIENT_ID,
|
||||
authority: `https://login.microsoftonline.com/${process.env.AZURE_TENANT_ID}`,
|
||||
clientSecret: process.env.AZURE_CLIENT_SECRET,
|
||||
},
|
||||
system: {
|
||||
loggerOptions: {
|
||||
loggerCallback(loglevel, message, containsPii) {
|
||||
console.log(message);
|
||||
},
|
||||
piiLoggingEnabled: false,
|
||||
logLevel: 'Info',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const REDIRECT_URI = process.env.AZURE_REDIRECT_URI;
|
||||
const POST_LOGOUT_REDIRECT_URI = process.env.FRONTEND_URL;
|
||||
|
||||
module.exports = {
|
||||
msalConfig,
|
||||
REDIRECT_URI,
|
||||
POST_LOGOUT_REDIRECT_URI,
|
||||
};
|
||||
@@ -0,0 +1,485 @@
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// ndfPdfGenerator.js — v4 (pdfkit pur, 0% Python)
|
||||
// Génère la fiche Note de Frais au format exact du modèle ENSUP
|
||||
// avec signatures électroniques intégrées.
|
||||
// v4 : Tarif km et Sous-total km intégrés dans le tableau après colonne Km
|
||||
//
|
||||
// Prérequis : pdfkit déjà installé (npm install pdfkit)
|
||||
// Copier dans le même dossier que server.js.
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
import PDFDocument from 'pdfkit';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// CONSTANTES
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
const C = {
|
||||
blue: '#1B4F8A', header: '#2563EB',
|
||||
totalBg: '#DBEAFE', altRow: '#EFF6FF',
|
||||
amountBg: '#EEF2FF', greenBg: '#F0FDF4',
|
||||
greenText: '#15803D', headerRow: '#E2E8F0',
|
||||
border: '#CBD5E1', dark: '#0F172A',
|
||||
grey: '#64748B', light: '#94A3B8',
|
||||
white: '#FFFFFF',
|
||||
collabBg: '#F0FDF4', collabBorder: '#A7F3D0', collabText: '#059669',
|
||||
validBg: '#EEF2FF', validBorder: '#C7D2FE', validText: '#4F46E5',
|
||||
refusBg: '#FEF2F2', refusBorder: '#FCA5A5', refusText: '#DC2626',
|
||||
waitBg: '#F8FAFC', waitBorder: '#E2E8F0',
|
||||
kmBg: '#F5F3FF', // fond violet clair pour colonne tarif km
|
||||
kmBorder: '#DDD6FE', // bordure violet clair
|
||||
kmText: '#7C3AED', // texte violet
|
||||
kmTotalBg: '#EDE9FE', // fond sous-total km
|
||||
};
|
||||
|
||||
// Colonnes tableau (largeurs en points)
|
||||
// ── v4 : 'tarifKm' et 'sousKm' insérées après 'km' ──
|
||||
const COLS = [
|
||||
{ key: 'num', label: 'N°pièce', w: 34, align: 'center' },
|
||||
{ key: 'date', label: 'Date', w: 58, align: 'left' },
|
||||
{ key: 'nature', label: 'Nature', w: 70, align: 'left' },
|
||||
{ key: 'lib', label: 'Libellé', w: 140, align: 'left' },
|
||||
{ key: 'km', label: 'Km', w: 36, align: 'right' },
|
||||
{ key: 'tarifKm', label: 'Tarif €/km', w: 46, align: 'right' },
|
||||
{ key: 'sousKm', label: 'S/Total Km', w: 50, align: 'right' },
|
||||
{ key: 'ttc', label: 'TTC', w: 50, align: 'right' },
|
||||
{ key: 'tva21', label: 'TVA 2,1%', w: 46, align: 'right' },
|
||||
{ key: 'tva55', label: 'TVA 5,5%', w: 46, align: 'right' },
|
||||
{ key: 'tva10', label: 'TVA 10%', w: 46, align: 'right' },
|
||||
{ key: 'tva20', label: 'TVA 20%', w: 46, align: 'right' },
|
||||
{ key: 'ht', label: 'HT', w: 50, align: 'right' },
|
||||
];
|
||||
|
||||
// Colonnes km (pour coloration spéciale)
|
||||
const KM_COLS = ['km', 'tarifKm', 'sousKm'];
|
||||
|
||||
const MARGIN = 30;
|
||||
const ROW_H = 16;
|
||||
const HEAD_H = 20;
|
||||
const PAGE_W = 841.89; // A4 largeur
|
||||
const PAGE_H = 595.28; // A4 hauteur
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// HELPERS
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
const f2 = v => (parseFloat(v) || 0).toFixed(2);
|
||||
const f3 = v => (parseFloat(v) || 0).toFixed(3);
|
||||
|
||||
function fmtDate(s) {
|
||||
if (!s) return '';
|
||||
try {
|
||||
const d = new Date(s);
|
||||
if (isNaN(d)) return String(s).slice(0, 10);
|
||||
return `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`;
|
||||
} catch { return ''; }
|
||||
}
|
||||
|
||||
function fmtDateTime(s) {
|
||||
if (!s) return '';
|
||||
try {
|
||||
const d = new Date(s);
|
||||
if (isNaN(d)) return String(s).slice(0, 16);
|
||||
return d.toLocaleString('fr-FR', {
|
||||
timeZone: 'Europe/Paris', day: '2-digit', month: '2-digit',
|
||||
year: 'numeric', hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
} catch { return ''; }
|
||||
}
|
||||
|
||||
function drawRect(doc, x, y, w, h, fill, stroke = null, lw = 0.3) {
|
||||
doc.save();
|
||||
if (stroke) {
|
||||
doc.lineWidth(lw).rect(x, y, w, h).fillAndStroke(fill, stroke);
|
||||
} else {
|
||||
doc.rect(x, y, w, h).fill(fill);
|
||||
}
|
||||
doc.restore();
|
||||
}
|
||||
|
||||
function drawCellText(doc, text, x, y, w, h, font, size, color, align, padX = 3) {
|
||||
text = String(text ?? '');
|
||||
const maxW = w - padX * 2;
|
||||
doc.save().font(font).fontSize(size).fillColor(color);
|
||||
while (text.length > 1 && doc.widthOfString(text) > maxW) text = text.slice(0, -1);
|
||||
const ty = y + h * 0.28;
|
||||
if (align === 'right') {
|
||||
doc.text(text, x + padX, ty, { width: maxW, align: 'right', lineBreak: false });
|
||||
} else if (align === 'center') {
|
||||
doc.text(text, x + padX, ty, { width: maxW, align: 'center', lineBreak: false });
|
||||
} else {
|
||||
doc.text(text, x + padX, ty, { width: maxW, align: 'left', lineBreak: false });
|
||||
}
|
||||
doc.restore();
|
||||
}
|
||||
|
||||
function drawVLine(doc, x, y1, y2) {
|
||||
doc.save().strokeColor(C.border).lineWidth(0.4)
|
||||
.moveTo(x, y1).lineTo(x, y2).stroke().restore();
|
||||
}
|
||||
|
||||
function drawHLine(doc, x1, x2, y) {
|
||||
doc.save().strokeColor(C.border).lineWidth(0.3)
|
||||
.moveTo(x1, y).lineTo(x2, y).stroke().restore();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// preparerLignesPDF — convertit lignes formulaire → lignes PDF
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
export function preparerLignesPDF(lignesParsed, tarifKm = TARIF_KM_DEFAULT) {
|
||||
return (lignesParsed || []).map((l, idx) => {
|
||||
const isKm = (l.categorie || '').toLowerCase().includes('kilom');
|
||||
const km = parseFloat(l.km) || 0;
|
||||
const ttc = isKm ? 0 : (parseFloat(l.montant) || 0);
|
||||
const taux = parseFloat(l.tauxTVA) || 0;
|
||||
let ht = ttc, tva21 = 0, tva55 = 0, tva10 = 0, tva20 = 0;
|
||||
|
||||
if (!isKm && taux > 0 && ttc > 0) {
|
||||
ht = parseFloat((ttc / (1 + taux / 100)).toFixed(2));
|
||||
const tvaM = parseFloat((ttc - ht).toFixed(2));
|
||||
if (Math.abs(taux - 2.1) < 0.01) tva21 = tvaM;
|
||||
else if (Math.abs(taux - 5.5) < 0.01) tva55 = tvaM;
|
||||
else if (Math.abs(taux - 10) < 0.01) tva10 = tvaM;
|
||||
else if (Math.abs(taux - 20) < 0.01) tva20 = tvaM;
|
||||
}
|
||||
|
||||
const indemniteKm = isKm ? parseFloat((km * tarifKm).toFixed(2)) : 0;
|
||||
|
||||
return {
|
||||
numPiece: idx + 1,
|
||||
date: l.date,
|
||||
nature: l.categorie || '',
|
||||
libelle: l.libelle || '',
|
||||
km: isKm ? km : 0,
|
||||
tarifKmVal: isKm ? tarifKm : 0, // ← valeur numérique tarif
|
||||
montantTTC: isKm ? 0 : ttc,
|
||||
tva21, tva55, tva10, tva20,
|
||||
montantHT: isKm ? 0 : ht,
|
||||
indemniteKm,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// generateFicheSignee — point d'entrée appelé depuis server.js
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
export async function generateFicheSignee(note, signatures = []) {
|
||||
const tarifKm = parseFloat(note.tarifKm) || TARIF_KM_DEFAULT;
|
||||
|
||||
let lignesPDF = [];
|
||||
if (note.lignesJson) {
|
||||
try {
|
||||
const parsed = typeof note.lignesJson === 'string'
|
||||
? JSON.parse(note.lignesJson) : note.lignesJson;
|
||||
lignesPDF = preparerLignesPDF(parsed, tarifKm);
|
||||
} catch (e) { console.error('ndfPdfGenerator — Parse lignesJson:', e.message); }
|
||||
} else if (note.lignes && Array.isArray(note.lignes)) {
|
||||
lignesPDF = preparerLignesPDF(note.lignes, tarifKm);
|
||||
} else {
|
||||
const isKm = !!(note.km && parseFloat(note.km) > 0);
|
||||
const km = isKm ? parseFloat(note.km) : 0;
|
||||
lignesPDF = [{
|
||||
numPiece: 1, date: note.date, nature: note.categorie || '', libelle: note.libelle || '',
|
||||
km: isKm ? km : 0,
|
||||
tarifKmVal: isKm ? tarifKm : 0,
|
||||
montantTTC: isKm ? 0 : parseFloat(note.montant || 0),
|
||||
tva21: 0, tva55: 0, tva10: 0, tva20: 0,
|
||||
montantHT: isKm ? 0 : parseFloat(note.montantHT || note.montant || 0),
|
||||
indemniteKm: isKm ? parseFloat((km * tarifKm).toFixed(2)) : 0,
|
||||
}];
|
||||
}
|
||||
|
||||
let mois = note.mois || '';
|
||||
if (!mois && note.date) {
|
||||
const d = new Date(note.date);
|
||||
const m = d.toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' });
|
||||
mois = m.charAt(0).toUpperCase() + m.slice(1);
|
||||
}
|
||||
|
||||
return _buildPDF({
|
||||
reference: note.reference || '',
|
||||
nomPrenom: note.nomPrenom || note.collaborateur || '',
|
||||
mois,
|
||||
departement: note.departement || '',
|
||||
lignes: lignesPDF,
|
||||
tarifKm,
|
||||
signatures,
|
||||
statut: note.statut || 'enattente',
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// _buildPDF — génère le Buffer PDF
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
function _buildPDF({ reference, nomPrenom, mois, departement, lignes, tarifKm, signatures }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const doc = new PDFDocument({
|
||||
size: 'A4',
|
||||
layout: 'landscape',
|
||||
margin: 0,
|
||||
info: {
|
||||
Title: `Note de Frais ${reference}`,
|
||||
Author: `ENSUP — ${nomPrenom}`,
|
||||
Subject: `NDF ${reference}`,
|
||||
Creator: 'NDF ENSUP v4',
|
||||
},
|
||||
});
|
||||
|
||||
const chunks = [];
|
||||
doc.on('data', c => chunks.push(c));
|
||||
doc.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
doc.on('error', e => reject(e));
|
||||
|
||||
// ── Positions X colonnes ─────────────────────────────────────
|
||||
const colX = {};
|
||||
let cx = MARGIN;
|
||||
for (const col of COLS) { colX[col.key] = cx; cx += col.w; }
|
||||
const tableW = cx - MARGIN;
|
||||
|
||||
// ── 2. BANDEAU ───────────────────────────────────────────────
|
||||
const bandY = 44;
|
||||
drawRect(doc, MARGIN, bandY, tableW, 18, C.header);
|
||||
doc.font('Helvetica-Bold').fontSize(11).fillColor(C.white)
|
||||
.text('NOTE DE FRAIS', MARGIN, bandY + 3.5, { width: tableW, align: 'center', lineBreak: false });
|
||||
|
||||
// ── 3. INFOS COLLAB ──────────────────────────────────────────
|
||||
const infoY = bandY + 23;
|
||||
doc.font('Helvetica-Bold').fontSize(9).fillColor(C.dark)
|
||||
.text(`NOM : ${nomPrenom}`, MARGIN, infoY, { lineBreak: false });
|
||||
if (departement)
|
||||
doc.font('Helvetica').fontSize(8).fillColor(C.grey)
|
||||
.text(`Service : ${departement}`, MARGIN + 200, infoY, { lineBreak: false });
|
||||
doc.font('Helvetica').fontSize(8).fillColor(C.grey)
|
||||
.text('Repas, déplacement, autres', MARGIN + 380, infoY, { lineBreak: false });
|
||||
doc.font('Helvetica-Bold').fontSize(9).fillColor(C.dark)
|
||||
.text(`Mois : ${mois}`, MARGIN, infoY + 13, { lineBreak: false });
|
||||
|
||||
// ── 4. EN-TÊTE COLONNES ──────────────────────────────────────
|
||||
const tableTop = infoY + 27;
|
||||
|
||||
// Fond de base
|
||||
drawRect(doc, MARGIN, tableTop, tableW, HEAD_H, C.headerRow, C.border, 0.5);
|
||||
|
||||
// Fond spécial violet pour les 3 colonnes km dans l'en-tête
|
||||
for (const key of KM_COLS) {
|
||||
drawRect(doc, colX[key], tableTop, COLS.find(c => c.key === key).w, HEAD_H, C.kmBg);
|
||||
}
|
||||
|
||||
for (const col of COLS) {
|
||||
const isKmCol = KM_COLS.includes(col.key);
|
||||
drawCellText(
|
||||
doc, col.label,
|
||||
colX[col.key], tableTop, col.w, HEAD_H,
|
||||
'Helvetica-Bold', isKmCol ? 6.5 : 7,
|
||||
isKmCol ? C.kmText : C.dark,
|
||||
col.align
|
||||
);
|
||||
drawVLine(doc, colX[col.key], tableTop, tableTop + HEAD_H);
|
||||
}
|
||||
drawVLine(doc, MARGIN + tableW, tableTop, tableTop + HEAD_H);
|
||||
drawHLine(doc, MARGIN, MARGIN + tableW, tableTop);
|
||||
drawHLine(doc, MARGIN, MARGIN + tableW, tableTop + HEAD_H);
|
||||
|
||||
// ── 5. LIGNES DONNÉES ────────────────────────────────────────
|
||||
const MIN_ROWS = 18;
|
||||
const totalRows = Math.max(MIN_ROWS, lignes.length);
|
||||
let y = tableTop + HEAD_H;
|
||||
let totKm = 0, totTTC = 0, totT21 = 0, totT55 = 0, totT10 = 0, totT20 = 0, totHT = 0, totSousKm = 0;
|
||||
|
||||
for (let i = 0; i < totalRows; i++) {
|
||||
const lig = lignes[i] || null;
|
||||
// Fond de ligne alterné
|
||||
drawRect(doc, MARGIN, y, tableW, ROW_H, i % 2 === 1 ? C.altRow : C.white);
|
||||
|
||||
// Fond violet léger sur les 3 colonnes km (toutes lignes)
|
||||
for (const key of KM_COLS) {
|
||||
const col = COLS.find(c => c.key === key);
|
||||
drawRect(doc, colX[key], y, col.w, ROW_H,
|
||||
i % 2 === 1 ? '#F3F0FF' : '#FAF8FF');
|
||||
}
|
||||
|
||||
if (lig) {
|
||||
const km = parseFloat(lig.km) || 0;
|
||||
const tarif = parseFloat(lig.tarifKmVal) || 0;
|
||||
const sousKm = parseFloat(lig.indemniteKm) || 0;
|
||||
const ttc = parseFloat(lig.montantTTC) || 0;
|
||||
const t21 = parseFloat(lig.tva21) || 0;
|
||||
const t55 = parseFloat(lig.tva55) || 0;
|
||||
const t10 = parseFloat(lig.tva10) || 0;
|
||||
const t20 = parseFloat(lig.tva20) || 0;
|
||||
const ht = parseFloat(lig.montantHT) || 0;
|
||||
|
||||
totKm += km;
|
||||
totTTC += ttc;
|
||||
totT21 += t21; totT55 += t55; totT10 += t10; totT20 += t20;
|
||||
totHT += ht;
|
||||
totSousKm += sousKm;
|
||||
|
||||
const r = {
|
||||
num: String(lig.numPiece || i + 1),
|
||||
date: fmtDate(lig.date),
|
||||
nature: lig.nature || '',
|
||||
lib: lig.libelle || '',
|
||||
km: km > 0 ? f2(km) : '',
|
||||
tarifKm: tarif > 0 ? f3(tarif) : '', // ex: 0.697
|
||||
sousKm: sousKm > 0 ? f2(sousKm) : '',
|
||||
ttc: f2(ttc),
|
||||
tva21: f2(t21), tva55: f2(t55),
|
||||
tva10: f2(t10), tva20: f2(t20),
|
||||
ht: f2(ht),
|
||||
};
|
||||
|
||||
for (const col of COLS) {
|
||||
const isKmCol = KM_COLS.includes(col.key);
|
||||
drawCellText(
|
||||
doc, r[col.key],
|
||||
colX[col.key], y, col.w, ROW_H,
|
||||
'Helvetica', 7,
|
||||
isKmCol ? C.kmText : C.dark,
|
||||
col.align
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Ligne vide — zéros en gris sur colonnes numériques
|
||||
for (const col of COLS) {
|
||||
if (['ttc', 'tva21', 'tva55', 'tva10', 'tva20', 'ht'].includes(col.key))
|
||||
drawCellText(doc, '0.00', colX[col.key], y, col.w, ROW_H, 'Helvetica', 7, C.border, 'right');
|
||||
}
|
||||
}
|
||||
|
||||
// Bordures ligne
|
||||
drawHLine(doc, MARGIN, MARGIN + tableW, y + ROW_H);
|
||||
for (const col of COLS) drawVLine(doc, colX[col.key], y, y + ROW_H);
|
||||
drawVLine(doc, MARGIN + tableW, y, y + ROW_H);
|
||||
y += ROW_H;
|
||||
}
|
||||
|
||||
// ── 6. LIGNE TOTAL ───────────────────────────────────────────
|
||||
const totalY = y;
|
||||
drawRect(doc, MARGIN, totalY, tableW, ROW_H + 2, C.totalBg, C.border, 0.5);
|
||||
|
||||
// Fond violet sur les colonnes km dans la ligne total
|
||||
for (const key of KM_COLS) {
|
||||
const col = COLS.find(c => c.key === key);
|
||||
drawRect(doc, colX[key], totalY, col.w, ROW_H + 2, C.kmTotalBg);
|
||||
}
|
||||
|
||||
doc.font('Helvetica-Bold').fontSize(8).fillColor(C.dark)
|
||||
.text('Total', MARGIN + 3, totalY + 4, { lineBreak: false });
|
||||
|
||||
const totMap = {
|
||||
km: totKm > 0 ? f2(totKm) : '',
|
||||
tarifKm: '', // pas de somme de tarifs
|
||||
sousKm: totSousKm > 0 ? f2(totSousKm) : '',
|
||||
ttc: f2(totTTC),
|
||||
tva21: f2(totT21), tva55: f2(totT55),
|
||||
tva10: f2(totT10), tva20: f2(totT20),
|
||||
ht: f2(totHT),
|
||||
};
|
||||
|
||||
for (const col of COLS) {
|
||||
if (!totMap[col.key] && totMap[col.key] !== '0.00') continue;
|
||||
if (totMap[col.key] === '') continue;
|
||||
const isKmCol = KM_COLS.includes(col.key);
|
||||
drawCellText(
|
||||
doc, totMap[col.key],
|
||||
colX[col.key], totalY, col.w, ROW_H + 2,
|
||||
'Helvetica-Bold', 8,
|
||||
isKmCol ? C.kmText : C.dark,
|
||||
'right'
|
||||
);
|
||||
}
|
||||
|
||||
// ── 7. ZONE BAS (simplifiée — les infos km sont dans le tableau) ──
|
||||
const footY = totalY + ROW_H + 12;
|
||||
const montantR = parseFloat((totTTC + totSousKm).toFixed(2));
|
||||
const bw = 64;
|
||||
|
||||
// Montant à rembourser (simplifié)
|
||||
doc.font('Helvetica-Bold').fontSize(9).fillColor(C.dark)
|
||||
.text('Montant total à rembourser', MARGIN, footY + 4, { lineBreak: false });
|
||||
drawRect(doc, MARGIN + 180, footY, 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, { width: bw + 6, align: 'right', lineBreak: false });
|
||||
|
||||
// Rappel tarif utilisé (petit, discret)
|
||||
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, { lineBreak: false });
|
||||
|
||||
// ── 8. SIGNATURES ─────────────────────────────────────────────
|
||||
const sigStartX = MARGIN + 310;
|
||||
const sigW = (tableW - 313) / 2 - 4;
|
||||
const sigH = 52;
|
||||
const sigY = footY - 2;
|
||||
|
||||
const sigCollab = signatures.find(s => s.niveau === 'COLLAB');
|
||||
const sigManager = signatures.find(s => ['N1', 'N2'].includes(s.niveau));
|
||||
|
||||
_drawSigBox(doc, sigCollab, sigStartX, sigY, sigW, sigH, 'Date et signature Collaborateur', false);
|
||||
_drawSigBox(doc, sigManager, sigStartX + sigW + 6, sigY, sigW, sigH, 'Date et signature', true);
|
||||
|
||||
// ── 9. PIED DE PAGE ───────────────────────────────────────────
|
||||
doc.font('Helvetica').fontSize(6).fillColor(C.light)
|
||||
.text(
|
||||
`Réf. ${reference} — Généré le ${new Date().toLocaleDateString('fr-FR')} — NDF ENSUP Groupe — Document électronique`,
|
||||
MARGIN, PAGE_H - 13, { width: tableW, align: 'center', lineBreak: false }
|
||||
);
|
||||
|
||||
doc.end();
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// _drawSigBox — boîte signature avec ou sans contenu
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
function _drawSigBox(doc, sig, x, y, w, h, label, isManager) {
|
||||
let bg, border, accent, icon;
|
||||
|
||||
if (sig) {
|
||||
const a = sig.action || '';
|
||||
if (a === 'refuser' || a === 'refuse') {
|
||||
bg = C.refusBg; border = C.refusBorder; accent = C.refusText; icon = '✗ REFUSÉ';
|
||||
} else if (isManager) {
|
||||
bg = C.validBg; border = C.validBorder; accent = C.validText; icon = '✓ VALIDÉ';
|
||||
} else {
|
||||
bg = C.collabBg; border = C.collabBorder; accent = C.collabText; icon = '✓ SOUMIS';
|
||||
}
|
||||
} else {
|
||||
bg = C.waitBg; border = C.waitBorder; accent = C.grey; icon = null;
|
||||
}
|
||||
|
||||
drawRect(doc, x, y, w, h, bg, border, 1);
|
||||
|
||||
// Label haut
|
||||
doc.font('Helvetica-Bold').fontSize(6.5).fillColor(C.grey)
|
||||
.text(label, x + 4, y + 4, { width: w - 8, lineBreak: false });
|
||||
|
||||
if (sig) {
|
||||
let nom = String(sig.nomPrenom || '');
|
||||
const ds = fmtDateTime(sig.date);
|
||||
const comment = String(sig.commentaire || '');
|
||||
|
||||
doc.font('Helvetica-Bold').fontSize(8).fillColor(accent)
|
||||
.text(icon, x + 4, y + 14, { lineBreak: false });
|
||||
|
||||
doc.font('Helvetica-Bold').fontSize(9).fillColor(C.dark);
|
||||
while (nom.length > 1 && doc.widthOfString(nom) > w - 10) nom = nom.slice(0, -1);
|
||||
doc.text(nom, x + 4, y + 25, { width: w - 8, lineBreak: false });
|
||||
|
||||
doc.font('Helvetica').fontSize(7).fillColor(C.grey)
|
||||
.text(`Le ${ds}`, x + 4, y + 36, { width: w - 8, lineBreak: false });
|
||||
|
||||
if (comment)
|
||||
doc.font('Helvetica-Oblique').fontSize(6.5).fillColor(C.grey)
|
||||
.text(comment, x + 4, y + 45, { width: w - 8, lineBreak: false });
|
||||
|
||||
doc.save().strokeColor(border).lineWidth(0.5)
|
||||
.moveTo(x + 3, y + h - 9).lineTo(x + w - 3, y + h - 9).stroke().restore();
|
||||
doc.font('Helvetica').fontSize(6).fillColor(C.grey)
|
||||
.text('Signature — NDF ENSUP', x + 4, y + h - 7, { width: w - 8, align: 'center', lineBreak: false });
|
||||
} else {
|
||||
doc.font('Helvetica').fontSize(8).fillColor(C.grey)
|
||||
.text('En attente de signature', x + 4, y + h / 2 - 5, { width: w - 8, align: 'center', lineBreak: false });
|
||||
}
|
||||
}
|
||||
Generated
+3209
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "ndf-backend",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "cross-env TZ=Europe/Paris node server.js",
|
||||
"dev": "cross-env TZ=Europe/Paris nodemon server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@azure/msal-node": "^2.16.3",
|
||||
"@microsoft/microsoft-graph-client": "^3.0.7",
|
||||
"axios": "^1.13.5",
|
||||
"cors": "^2.8.6",
|
||||
"dotenv": "^16.6.1",
|
||||
"express": "^4.22.1",
|
||||
"isomorphic-fetch": "^3.0.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"mssql": "^11.0.1",
|
||||
"multer": "^2.0.2",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfkit": "^0.17.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.2.3",
|
||||
"cross-env": "^10.1.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsx": "^4.21.0"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 865 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,42 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { AuthProvider, useAuth } from './context/AuthContext';
|
||||
import { ThemeProvider } from './context/ThemeContext';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import AuthCallback from './pages/AuthCallback';
|
||||
import PrivateRoute from './components/PrivateRoute';
|
||||
import UploadMobile from './pages/UploadMobile';
|
||||
import RoleSelector from './components/RoleSelector';
|
||||
import Login from './pages/Login';
|
||||
|
||||
// ── Garde intermédiaire pour la sélection de rôle ─────
|
||||
const AppRoutes = (): JSX.Element => {
|
||||
const { user, needsRoleSelection, selectRole, allRoles } = useAuth();
|
||||
|
||||
if (user && needsRoleSelection) {
|
||||
return (
|
||||
<RoleSelector
|
||||
roles={allRoles}
|
||||
userName={user.name}
|
||||
onSelect={selectRole}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/login" replace />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/auth/callback" element={<AuthCallback />} />
|
||||
<Route path="/dashboard" element={
|
||||
<PrivateRoute>
|
||||
<Dashboard />
|
||||
</PrivateRoute>
|
||||
} />
|
||||
<Route path="/upload/:token" element={<UploadMobile />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
);
|
||||
};
|
||||
|
||||
function App(): JSX.Element {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<AppRoutes />
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,370 @@
|
||||
.dashboard-container {
|
||||
min-height: 100vh;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
background: white;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
padding: 1rem 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.navbar-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.navbar h1 {
|
||||
font-size: 1.5rem;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.user-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
padding: 8px 16px;
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.logout-btn:hover {
|
||||
background: #5568d3;
|
||||
}
|
||||
|
||||
.dashboard-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 20px;
|
||||
}
|
||||
|
||||
.stats-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.9rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
padding: 12px 24px;
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.add-btn:hover {
|
||||
background: #5568d3;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.form-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 2rem;
|
||||
animation: slideDown 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.form-card h2 {
|
||||
margin-bottom: 1.5rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
padding: 12px 24px;
|
||||
background: #f0f0f0;
|
||||
color: #333;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.cancel-btn:hover {
|
||||
background: #e0e0e0;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
padding: 12px 24px;
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.submit-btn:hover {
|
||||
background: #5568d3;
|
||||
}
|
||||
|
||||
.expenses-list {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.expenses-list h2 {
|
||||
margin-bottom: 1.5rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.expenses-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.expense-card {
|
||||
border: 2px solid #f0f0f0;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
transition: all 0.3s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.expense-card:hover {
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.1);
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.expense-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.expense-category {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.category-icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.category-label {
|
||||
font-size: 0.9rem;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-pending {
|
||||
background: #fff4e6;
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.status-approved {
|
||||
background: #d1fae5;
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.status-rejected {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.expense-card h3 {
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.expense-description {
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 1rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.expense-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.expense-amount {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.expense-date {
|
||||
font-size: 0.85rem;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
background: #fecaca;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.navbar-content {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.user-section {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.stats-card {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.expenses-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
.login-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
max-width: 500px;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
animation: slideUp 0.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.login-header {
|
||||
background: linear-gradient(135deg, #f5f5dc 0%, #e8d7c3 100%);
|
||||
color: white;
|
||||
padding: 40px 30px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-header h1 {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.login-header p {
|
||||
font-size: 1rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.login-content {
|
||||
padding: 40px 30px;
|
||||
}
|
||||
|
||||
.feature-list {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.feature-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
padding: 12px 0;
|
||||
font-size: 0.95rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
font-size: 1.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.microsoft-login-btn {
|
||||
width: 100%;
|
||||
padding: 16px 24px;
|
||||
background: white;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #5e5e5e;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
transition: all 0.3s ease;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.microsoft-login-btn:hover {
|
||||
border-color: #667eea;
|
||||
background: #f8f9ff;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.2);
|
||||
}
|
||||
|
||||
.microsoft-icon {
|
||||
width: 21px;
|
||||
height: 21px;
|
||||
}
|
||||
|
||||
.login-info {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 600px) {
|
||||
.login-card {
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.login-header {
|
||||
padding: 30px 20px;
|
||||
}
|
||||
|
||||
.login-header h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.login-content {
|
||||
padding: 30px 20px;
|
||||
}
|
||||
|
||||
.feature-item {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.microsoft-login-btn {
|
||||
padding: 14px 20px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
}
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
interface PrivateRouteProps {
|
||||
children: ReactNode;
|
||||
/** Si fourni, accès restreint aux rôles listés */
|
||||
roles?: string[];
|
||||
}
|
||||
|
||||
const PrivateRoute = ({ children, roles }: PrivateRouteProps): JSX.Element => {
|
||||
const { user, hasRole } = useAuth();
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
if (roles && roles.length > 0 && !hasRole(...roles)) {
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default PrivateRoute;
|
||||
@@ -0,0 +1,293 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
// ── TYPES ──────────────────────────────────────────────
|
||||
interface RoleSelectorProps {
|
||||
roles: string[];
|
||||
userName: string;
|
||||
onSelect: (role: string) => void;
|
||||
}
|
||||
|
||||
// ── CONFIG DES RÔLES ───────────────────────────────────
|
||||
const ROLE_CONFIG: Record<string, {
|
||||
label: string;
|
||||
sublabel: string;
|
||||
icon: string;
|
||||
gradient: string;
|
||||
accent: string;
|
||||
features: string[];
|
||||
}> = {
|
||||
Collaborateur: {
|
||||
label: 'Collaborateur',
|
||||
sublabel: 'Soumettre & suivre mes notes',
|
||||
icon: '👤',
|
||||
gradient: 'linear-gradient(135deg, #6366f1 0%, #4f46e5 100%)',
|
||||
accent: '#6366f1',
|
||||
features: ['Créer des notes de frais', 'Suivre mes remboursements', 'Consulter mon historique'],
|
||||
},
|
||||
Collaboratrice: {
|
||||
label: 'Collaboratrice',
|
||||
sublabel: 'Soumettre & suivre mes notes',
|
||||
icon: '👤',
|
||||
gradient: 'linear-gradient(135deg, #6366f1 0%, #4f46e5 100%)',
|
||||
accent: '#6366f1',
|
||||
features: ['Créer des notes de frais', 'Suivre mes remboursements', 'Consulter mon historique'],
|
||||
},
|
||||
Validateur: {
|
||||
label: 'Validateur',
|
||||
sublabel: 'Gérer les demandes à valider',
|
||||
icon: '✅',
|
||||
gradient: 'linear-gradient(135deg, #0ea5e9 0%, #0284c7 100%)',
|
||||
accent: '#0ea5e9',
|
||||
features: ['Valider / refuser des notes', 'Consulter les justificatifs', 'Historique de mes décisions'],
|
||||
},
|
||||
Validatrice: {
|
||||
label: 'Validatrice',
|
||||
sublabel: 'Gérer les demandes à valider',
|
||||
icon: '✅',
|
||||
gradient: 'linear-gradient(135deg, #0ea5e9 0%, #0284c7 100%)',
|
||||
accent: '#0ea5e9',
|
||||
features: ['Valider / refuser des notes', 'Consulter les justificatifs', 'Historique de mes décisions'],
|
||||
},
|
||||
Finance: {
|
||||
label: 'Finance',
|
||||
sublabel: 'Paiements & administration',
|
||||
icon: '💼',
|
||||
gradient: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
||||
accent: '#10b981',
|
||||
features: ['Gestion des paiements', 'Export XML virements', 'Paramètres TVA & km', 'Synchronisation Entra ID'],
|
||||
},
|
||||
superUtilisateur: {
|
||||
label: 'Super Utilisateur',
|
||||
sublabel: 'Supervision globale — lecture seule',
|
||||
icon: '🛡️',
|
||||
gradient: 'linear-gradient(135deg, #7c3aed 0%, #4c1d95 100%)',
|
||||
accent: '#7c3aed',
|
||||
features: ['Voir toutes les notes de l\'organisation', 'Filtrer par collaborateur, statut, mois', 'Visualiser les montants globaux'],
|
||||
},
|
||||
};
|
||||
|
||||
// Normalise un rôle vers sa clé de config
|
||||
const normalizeRole = (role: string): string => {
|
||||
const map: Record<string, string> = {
|
||||
collaborateur: 'Collaborateur',
|
||||
collaboratrice: 'Collaboratrice',
|
||||
validateur: 'Validateur',
|
||||
validatrice: 'Validatrice',
|
||||
finance: 'Finance',
|
||||
};
|
||||
return map[role.toLowerCase()] ?? role;
|
||||
};
|
||||
|
||||
// ── COMPOSANT ──────────────────────────────────────────
|
||||
const RoleSelector = ({ roles, userName, onSelect }: RoleSelectorProps) => {
|
||||
const [hovered, setHovered] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
|
||||
const uniqueRoles = [...new Set(roles.map(normalizeRole))];
|
||||
const initials = userName.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2);
|
||||
|
||||
const handleSelect = (role: string) => {
|
||||
setSelected(role);
|
||||
setTimeout(() => onSelect(role), 280);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(160deg, #0f172a 0%, #1e1b4b 50%, #0f172a 100%)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontFamily: '"DM Sans", "Segoe UI", system-ui, sans-serif',
|
||||
padding: '32px 16px',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{/* Fond décoratif */}
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, zIndex: 0,
|
||||
background: 'radial-gradient(ellipse 60% 40% at 50% 0%, rgba(99,102,241,0.18) 0%, transparent 70%)',
|
||||
pointerEvents: 'none',
|
||||
}} />
|
||||
<div style={{
|
||||
position: 'absolute', bottom: 0, left: 0, right: 0,
|
||||
height: 320,
|
||||
background: 'radial-gradient(ellipse 60% 40% at 50% 100%, rgba(16,185,129,0.08) 0%, transparent 70%)',
|
||||
pointerEvents: 'none',
|
||||
}} />
|
||||
|
||||
{/* Contenu */}
|
||||
<div style={{ position: 'relative', zIndex: 1, width: '100%', maxWidth: 680 }}>
|
||||
|
||||
{/* En-tête */}
|
||||
<div style={{ textAlign: 'center', marginBottom: 48 }}>
|
||||
<div style={{
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 56, height: 56,
|
||||
background: 'rgba(99,102,241,0.2)',
|
||||
border: '1px solid rgba(99,102,241,0.4)',
|
||||
borderRadius: 16, marginBottom: 24, fontSize: 24,
|
||||
}}>📋</div>
|
||||
|
||||
<div style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 10,
|
||||
background: 'rgba(255,255,255,0.06)',
|
||||
border: '1px solid rgba(255,255,255,0.12)',
|
||||
borderRadius: 100, padding: '6px 16px 6px 10px',
|
||||
marginBottom: 20,
|
||||
}}>
|
||||
<div style={{
|
||||
width: 28, height: 28,
|
||||
background: 'linear-gradient(135deg,#6366f1,#4f46e5)',
|
||||
borderRadius: '50%',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: '#fff', fontSize: 11, fontWeight: 800,
|
||||
}}>{initials}</div>
|
||||
<span style={{ color: 'rgba(255,255,255,0.7)', fontSize: 13, fontWeight: 500 }}>
|
||||
{userName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 style={{
|
||||
color: '#fff', fontSize: 28, fontWeight: 800,
|
||||
margin: '0 0 8px', letterSpacing: '-0.5px',
|
||||
}}>
|
||||
Choisir votre mode
|
||||
</h1>
|
||||
<p style={{ color: 'rgba(255,255,255,0.45)', fontSize: 14, margin: 0 }}>
|
||||
Votre compte dispose de {uniqueRoles.length} profils — sélectionnez celui à activer
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Cartes de rôles */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: uniqueRoles.length === 2 ? '1fr 1fr' : uniqueRoles.length === 3 ? '1fr 1fr 1fr' : '1fr 1fr',
|
||||
gap: 16,
|
||||
}}>
|
||||
{uniqueRoles.map(role => {
|
||||
const cfg = ROLE_CONFIG[role] ?? {
|
||||
label: role, sublabel: '', icon: '🔑',
|
||||
gradient: 'linear-gradient(135deg,#64748b,#475569)',
|
||||
accent: '#64748b', features: [],
|
||||
};
|
||||
const isHovered = hovered === role;
|
||||
const isSelected = selected === role;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={role}
|
||||
onClick={() => handleSelect(role)}
|
||||
onMouseEnter={() => setHovered(role)}
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
style={{
|
||||
background: isSelected
|
||||
? cfg.gradient
|
||||
: isHovered
|
||||
? 'rgba(255,255,255,0.09)'
|
||||
: 'rgba(255,255,255,0.05)',
|
||||
border: `1.5px solid ${isHovered || isSelected ? cfg.accent : 'rgba(255,255,255,0.1)'}`,
|
||||
borderRadius: 20,
|
||||
padding: '28px 24px',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
transition: 'all 0.22s cubic-bezier(0.34,1.56,0.64,1)',
|
||||
transform: isSelected ? 'scale(1.03)' : isHovered ? 'translateY(-3px)' : 'none',
|
||||
boxShadow: isHovered || isSelected
|
||||
? `0 20px 40px rgba(0,0,0,0.4), 0 0 0 1px ${cfg.accent}33`
|
||||
: '0 4px 16px rgba(0,0,0,0.2)',
|
||||
fontFamily: 'inherit',
|
||||
outline: 'none',
|
||||
}}
|
||||
>
|
||||
{/* Icône + label */}
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 14, marginBottom: 16 }}>
|
||||
<div style={{
|
||||
width: 44, height: 44, borderRadius: 12, flexShrink: 0,
|
||||
background: isSelected ? 'rgba(255,255,255,0.2)' : `${cfg.accent}22`,
|
||||
border: `1px solid ${isSelected ? 'rgba(255,255,255,0.3)' : cfg.accent + '44'}`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 20,
|
||||
transition: 'all 0.2s',
|
||||
}}>{cfg.icon}</div>
|
||||
<div>
|
||||
<div style={{
|
||||
color: '#fff', fontSize: 16, fontWeight: 700,
|
||||
marginBottom: 3, letterSpacing: '-0.2px',
|
||||
}}>{cfg.label}</div>
|
||||
<div style={{
|
||||
color: isSelected ? 'rgba(255,255,255,0.75)' : 'rgba(255,255,255,0.4)',
|
||||
fontSize: 12, fontWeight: 500,
|
||||
transition: 'color 0.2s',
|
||||
}}>{cfg.sublabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
|
||||
{cfg.features.map((f, i) => (
|
||||
<div key={i} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
}}>
|
||||
<div style={{
|
||||
width: 5, height: 5, borderRadius: '50%',
|
||||
background: isSelected ? 'rgba(255,255,255,0.7)' : cfg.accent,
|
||||
flexShrink: 0,
|
||||
transition: 'background 0.2s',
|
||||
}} />
|
||||
<span style={{
|
||||
color: isSelected ? 'rgba(255,255,255,0.75)' : 'rgba(255,255,255,0.35)',
|
||||
fontSize: 12, fontWeight: 400,
|
||||
transition: 'color 0.2s',
|
||||
}}>{f}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* CTA */}
|
||||
<div style={{
|
||||
marginTop: 20,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
}}>
|
||||
<span style={{
|
||||
fontSize: 12, fontWeight: 700,
|
||||
color: isSelected ? '#fff' : cfg.accent,
|
||||
textTransform: 'uppercase', letterSpacing: '0.8px',
|
||||
transition: 'color 0.2s',
|
||||
}}>
|
||||
{isSelected ? 'Ouverture...' : 'Accéder →'}
|
||||
</span>
|
||||
{isSelected && (
|
||||
<div style={{
|
||||
width: 18, height: 18, borderRadius: '50%',
|
||||
border: '2px solid rgba(255,255,255,0.6)',
|
||||
borderTopColor: '#fff',
|
||||
animation: 'spin 0.6s linear infinite',
|
||||
}} />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<p style={{
|
||||
textAlign: 'center', color: 'rgba(255,255,255,0.2)',
|
||||
fontSize: 11, marginTop: 32,
|
||||
}}>
|
||||
Vous pourrez changer de mode à tout moment depuis l'interface
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RoleSelector;
|
||||
export { ROLE_CONFIG, normalizeRole };
|
||||
export type { RoleSelectorProps };
|
||||
@@ -0,0 +1,191 @@
|
||||
import { useState } from 'react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
// ── CONFIG ROLES (couleurs & icônes) ──────────────────
|
||||
const ROLE_STYLE: Record<string, { icon: string; color: string; bg: string; label: string }> = {
|
||||
Collaborateur: { icon: '👤', color: '#a5b4fc', bg: 'rgba(99,102,241,0.2)', label: 'Collaborateur' },
|
||||
Collaboratrice: { icon: '👤', color: '#a5b4fc', bg: 'rgba(99,102,241,0.2)', label: 'Collaboratrice' },
|
||||
Validateur: { icon: '✅', color: '#7dd3fc', bg: 'rgba(14,165,233,0.2)', label: 'Validateur' },
|
||||
Validatrice: { icon: '✅', color: '#7dd3fc', bg: 'rgba(14,165,233,0.2)', label: 'Validatrice' },
|
||||
Finance: { icon: '💼', color: '#6ee7b7', bg: 'rgba(16,185,129,0.2)', label: 'Finance' },
|
||||
superUtilisateur: { icon: '🛡️', color: '#c4b5fd', bg: 'rgba(124,58,237,0.2)', label: 'Super Utilisateur' },
|
||||
VerificateurFinance: { icon: '🔍', color: '#c4b5fd', bg: 'rgba(124,58,237,0.2)', label: 'Vérificateur Finance' },
|
||||
ValidateurFinance: { icon: '💳', color: '#fca5a5', bg: 'rgba(185,28,28,0.2)', label: 'Validateur Finance' },
|
||||
};
|
||||
|
||||
const getStyle = (role: string) =>
|
||||
ROLE_STYLE[role] ?? { icon: '🔑', color: '#cbd5e1', bg: 'rgba(100,116,139,0.2)', label: role };
|
||||
|
||||
// ══════════════════════════════════════════════════════
|
||||
// VERSION SIDEBAR (intégrée dans la sidebar existante)
|
||||
// ══════════════════════════════════════════════════════
|
||||
export const RoleSwitcherSidebar = () => {
|
||||
const { activeRole, allRoles, switchRole, isMultiRole } = useAuth();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
if (!isMultiRole || !activeRole) return null;
|
||||
|
||||
const current = getStyle(activeRole);
|
||||
const others = allRoles.filter(r => r !== activeRole);
|
||||
|
||||
return (
|
||||
<div style={{ padding: '0 10px', marginBottom: 4, position: 'relative' }}>
|
||||
{/* Bouton mode actif */}
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
style={{
|
||||
width: '100%', display: 'flex', alignItems: 'center', gap: 10,
|
||||
padding: '10px 12px', borderRadius: 8, border: `1px solid ${current.color}44`,
|
||||
background: current.bg, cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 12, textAlign: 'left',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 14 }}>{current.icon}</span>
|
||||
<div style={{ flex: 1, overflow: 'hidden' }}>
|
||||
<div style={{ color: 'rgba(255,255,255,0.5)', fontSize: 9, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.6px' }}>Mode actif</div>
|
||||
<div style={{ color: current.color, fontWeight: 700, fontSize: 12, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{current.label}
|
||||
</div>
|
||||
</div>
|
||||
<span style={{
|
||||
color: 'rgba(255,255,255,0.4)', fontSize: 10,
|
||||
transform: open ? 'rotate(180deg)' : 'rotate(0deg)',
|
||||
transition: 'transform 0.2s', display: 'inline-block',
|
||||
}}>▼</span>
|
||||
</button>
|
||||
|
||||
{/* Dropdown des autres rôles */}
|
||||
{open && (
|
||||
<div style={{
|
||||
position: 'absolute', left: 10, right: 10, top: '100%', marginTop: 4,
|
||||
background: '#1e293b', border: '1px solid rgba(255,255,255,0.1)',
|
||||
borderRadius: 10, overflow: 'hidden', zIndex: 300,
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
|
||||
}}>
|
||||
<div style={{ padding: '8px 12px 6px', fontSize: 10, fontWeight: 700, color: 'rgba(255,255,255,0.3)', textTransform: 'uppercase', letterSpacing: '0.6px' }}>
|
||||
Changer de mode
|
||||
</div>
|
||||
{others.map(role => {
|
||||
const s = getStyle(role);
|
||||
return (
|
||||
<button
|
||||
key={role}
|
||||
onClick={() => { switchRole(role); setOpen(false); }}
|
||||
style={{
|
||||
width: '100%', display: 'flex', alignItems: 'center', gap: 10,
|
||||
padding: '10px 12px', border: 'none', background: 'transparent',
|
||||
cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left',
|
||||
transition: 'background 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { (e.currentTarget as HTMLButtonElement).style.background = 'rgba(255,255,255,0.06)'; }}
|
||||
onMouseLeave={e => { (e.currentTarget as HTMLButtonElement).style.background = 'transparent'; }}
|
||||
>
|
||||
<div style={{
|
||||
width: 30, height: 30, borderRadius: 8, background: s.bg,
|
||||
border: `1px solid ${s.color}44`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 14, flexShrink: 0,
|
||||
}}>{s.icon}</div>
|
||||
<div>
|
||||
<div style={{ color: '#fff', fontSize: 13, fontWeight: 600 }}>{s.label}</div>
|
||||
<div style={{ color: 'rgba(255,255,255,0.35)', fontSize: 10 }}>Basculer vers ce mode</div>
|
||||
</div>
|
||||
<span style={{ marginLeft: 'auto', color: s.color, fontSize: 11 }}>→</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ══════════════════════════════════════════════════════
|
||||
// VERSION BADGE (dans le header, compact)
|
||||
// ══════════════════════════════════════════════════════
|
||||
export const RoleSwitcherBadge = () => {
|
||||
const { activeRole, allRoles, switchRole, isMultiRole } = useAuth();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
if (!activeRole) return null;
|
||||
|
||||
const current = getStyle(activeRole);
|
||||
const others = allRoles.filter(r => r !== activeRole);
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button
|
||||
onClick={() => isMultiRole && setOpen(!open)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
padding: '6px 12px 6px 8px',
|
||||
background: current.bg,
|
||||
border: `1px solid ${current.color}55`,
|
||||
borderRadius: 20, cursor: isMultiRole ? 'pointer' : 'default',
|
||||
fontFamily: 'inherit', fontSize: 12, fontWeight: 700,
|
||||
color: current.color, transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 14 }}>{current.icon}</span>
|
||||
<span>{current.label}</span>
|
||||
{isMultiRole && (
|
||||
<span style={{
|
||||
fontSize: 8, opacity: 0.6,
|
||||
transform: open ? 'rotate(180deg)' : 'none',
|
||||
transition: 'transform 0.2s',
|
||||
}}>▼</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && isMultiRole && (
|
||||
<>
|
||||
{/* Overlay pour fermer */}
|
||||
<div
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 199 }}
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
<div style={{
|
||||
position: 'absolute', right: 0, top: '100%', marginTop: 6,
|
||||
minWidth: 200, background: '#fff',
|
||||
border: '1px solid #e2e8f0', borderRadius: 12, overflow: 'hidden',
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.12)', zIndex: 200,
|
||||
}}>
|
||||
<div style={{ padding: '8px 12px 6px', fontSize: 10, fontWeight: 700, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.6px', borderBottom: '1px solid #f1f5f9' }}>
|
||||
Changer de mode
|
||||
</div>
|
||||
{others.map(role => {
|
||||
const s = getStyle(role);
|
||||
return (
|
||||
<button
|
||||
key={role}
|
||||
onClick={() => { switchRole(role); setOpen(false); }}
|
||||
style={{
|
||||
width: '100%', display: 'flex', alignItems: 'center', gap: 10,
|
||||
padding: '10px 14px', border: 'none', background: 'transparent',
|
||||
cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left',
|
||||
transition: 'background 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { (e.currentTarget as HTMLButtonElement).style.background = '#f8fafc'; }}
|
||||
onMouseLeave={e => { (e.currentTarget as HTMLButtonElement).style.background = 'transparent'; }}
|
||||
>
|
||||
<div style={{
|
||||
width: 32, height: 32, borderRadius: 8,
|
||||
background: s.bg.replace('0.2', '0.15'),
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 16, flexShrink: 0,
|
||||
}}>{s.icon}</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: '#0f172a' }}>{s.label}</div>
|
||||
<div style={{ fontSize: 11, color: '#94a3b8' }}>Basculer vers ce mode</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Export default = version sidebar (la plus courante)
|
||||
export default RoleSwitcherSidebar;
|
||||
@@ -0,0 +1,101 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { Upload, FileText, Receipt } from 'lucide-react';
|
||||
|
||||
interface FileUploadZoneProps {
|
||||
files: File[];
|
||||
setFiles: (f: File[]) => void;
|
||||
}
|
||||
|
||||
const fileSize = (b: number) =>
|
||||
b < 1048576 ? `${(b / 1024).toFixed(1)} Ko` : `${(b / 1048576).toFixed(1)} Mo`;
|
||||
|
||||
export const FileUploadZone = ({ files, setFiles }: FileUploadZoneProps) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const cameraRef = useRef<HTMLInputElement>(null);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const allowed = ['image/jpeg', 'image/jpg', 'image/png', 'application/pdf'];
|
||||
|
||||
const add = (list: FileList | null) => {
|
||||
if (!list) return;
|
||||
const filtered = Array.from(list).filter(f => allowed.includes(f.type));
|
||||
setFiles([...files, ...filtered]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
onDragOver={e => { e.preventDefault(); setDragOver(true); }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={e => { e.preventDefault(); setDragOver(false); add(e.dataTransfer.files); }}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
style={{
|
||||
border: `2px dashed ${dragOver ? '#6366f1' : '#cbd5e1'}`,
|
||||
borderRadius: 10, padding: '28px 20px', textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
background: dragOver ? 'rgba(99,102,241,.05)' : '#f8fafc',
|
||||
transition: 'all 0.2s',
|
||||
}}>
|
||||
<div style={{ fontSize: 28, marginBottom: 8, display: 'flex', justifyContent: 'center', color: 'var(--text-muted)' }}>
|
||||
<Upload size={32} />
|
||||
</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)', marginBottom: 4 }}>
|
||||
Glissez vos fichiers ici
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-secondary)' }}>
|
||||
ou <span style={{ color: '#6366f1', fontWeight: 600 }}>cliquez pour parcourir</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 8 }}>
|
||||
PDF, JPG, PNG — max 10 Mo
|
||||
</div>
|
||||
<input ref={inputRef} type="file" multiple accept=".pdf,.jpg,.jpeg,.png"
|
||||
onChange={e => add(e.target.files)} style={{ display: 'none' }} />
|
||||
</div>
|
||||
|
||||
<button type="button" onClick={() => cameraRef.current?.click()} style={{
|
||||
marginTop: 10, width: '100%', display: 'flex', alignItems: 'center',
|
||||
justifyContent: 'center', gap: 8, padding: '11px 14px',
|
||||
background: '#f1f5f9', border: '1px solid #e2e8f0', borderRadius: 8,
|
||||
cursor: 'pointer', fontFamily: '"Inter","Segoe UI",sans-serif',
|
||||
fontSize: 13, color: 'var(--text-primary)', fontWeight: 600,
|
||||
}}>
|
||||
📷 Photo mobile
|
||||
</button>
|
||||
<input ref={cameraRef} type="file" accept="image/*" capture="environment"
|
||||
onChange={e => add(e.target.files)} style={{ display: 'none' }} />
|
||||
|
||||
{files.length > 0 && (
|
||||
<div style={{ marginTop: 12, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{files.map((f, i) => (
|
||||
<div key={i} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
padding: '10px 14px', background: 'var(--bg-input)',
|
||||
border: '1px solid #e2e8f0', borderRadius: 8,
|
||||
}}>
|
||||
<span style={{ fontSize: 16, display: 'flex', alignItems: 'center' }}>
|
||||
{f.type === 'application/pdf'
|
||||
? <FileText size={18} color="#6366f1" />
|
||||
: <Receipt size={18} color="#10b981" />}
|
||||
</span>
|
||||
<div style={{ flex: 1, overflow: 'hidden' }}>
|
||||
<div style={{
|
||||
fontSize: 13, fontWeight: 600, color: 'var(--text-primary)',
|
||||
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
|
||||
}}>{f.name}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{fileSize(f.size)}</div>
|
||||
</div>
|
||||
{f.type.startsWith('image/') && (
|
||||
<img src={URL.createObjectURL(f)} alt={f.name}
|
||||
style={{ width: 40, height: 40, objectFit: 'cover', borderRadius: 6, border: '1px solid #e2e8f0' }} />
|
||||
)}
|
||||
<button onClick={() => setFiles(files.filter((_, j) => j !== i))}
|
||||
style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
color: '#ef4444', fontSize: 16, padding: '2px 6px',
|
||||
}}>✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
import React from 'react';
|
||||
|
||||
interface JustificatifPopupProps {
|
||||
file: File | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const JustificatifPopup = ({ file, onClose }: JustificatifPopupProps) => {
|
||||
if (!file) return null;
|
||||
const url = URL.createObjectURL(file);
|
||||
const isPDF = file.type === 'application/pdf';
|
||||
const isImage = file.type.startsWith('image/');
|
||||
|
||||
return (
|
||||
<div onClick={onClose} style={{
|
||||
position: 'fixed', inset: 0, zIndex: 9999,
|
||||
background: 'rgba(0,0,0,0.75)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
backdropFilter: 'blur(4px)',
|
||||
}}>
|
||||
<div onClick={e => e.stopPropagation()} style={{
|
||||
background: '#fff', borderRadius: 16,
|
||||
boxShadow: '0 24px 80px rgba(0,0,0,.4)',
|
||||
maxWidth: '90vw', maxHeight: '90vh',
|
||||
width: isPDF ? 800 : 'auto',
|
||||
display: 'flex', flexDirection: 'column', overflow: 'hidden',
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '14px 20px', borderBottom: '1px solid #e2e8f0',
|
||||
background: 'linear-gradient(135deg,#6366f1,#4f46e5)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{ fontSize: 18 }}>{isPDF ? '📄' : '🖼️'}</span>
|
||||
<div>
|
||||
<div style={{ color: '#fff', fontWeight: 700, fontSize: 14 }}>{file.name}</div>
|
||||
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: 11 }}>
|
||||
{file.type} — {file.size < 1048576
|
||||
? `${(file.size / 1024).toFixed(1)} Ko`
|
||||
: `${(file.size / 1048576).toFixed(1)} Mo`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} style={{
|
||||
background: 'rgba(255,255,255,0.2)', border: 'none',
|
||||
borderRadius: 8, width: 34, height: 34, cursor: 'pointer',
|
||||
color: '#fff', fontSize: 18, display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
}}>✕</button>
|
||||
</div>
|
||||
{/* Body */}
|
||||
<div style={{
|
||||
flex: 1, overflow: 'auto', display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
padding: 16, background: '#f8fafc',
|
||||
}}>
|
||||
{isPDF && (
|
||||
<iframe src={url}
|
||||
style={{ width: '100%', height: '75vh', border: 'none', borderRadius: 8 }}
|
||||
title={file.name} />
|
||||
)}
|
||||
{isImage && (
|
||||
<img src={url} alt={file.name} style={{
|
||||
maxWidth: '80vw', maxHeight: '75vh',
|
||||
objectFit: 'contain', borderRadius: 8,
|
||||
boxShadow: '0 4px 24px rgba(0,0,0,0.15)',
|
||||
}} />
|
||||
)}
|
||||
</div>
|
||||
{/* Footer */}
|
||||
<div style={{
|
||||
padding: '12px 20px', borderTop: '1px solid #e2e8f0',
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
background: '#fff',
|
||||
}}>
|
||||
<span style={{ fontSize: 12, color: '#64748b' }}>Cliquez en dehors pour fermer</span>
|
||||
<a href={url} download={file.name} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
padding: '7px 14px', background: '#eef2ff',
|
||||
border: '1px solid #c7d2fe', borderRadius: 8,
|
||||
color: '#6366f1', fontSize: 12, fontWeight: 600, textDecoration: 'none',
|
||||
}}>⬇ Télécharger</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,769 @@
|
||||
import { useState, useRef } from "react";
|
||||
|
||||
const BAREME_KM = [
|
||||
{ cv: 3, label: "3 CV et moins", t1: 0.529, t2_a: 0.316, t2_b: 1061, t3: 0.369 },
|
||||
{ cv: 4, label: "4 CV", t1: 0.606, t2_a: 0.340, t2_b: 1330, t3: 0.408 },
|
||||
{ cv: 5, label: "5 CV", t1: 0.636, t2_a: 0.356, t2_b: 1391, t3: 0.427 },
|
||||
{ cv: 6, label: "6 CV", t1: 0.665, t2_a: 0.374, t2_b: 1457, t3: 0.448 },
|
||||
{ cv: 7, label: "7 CV et plus", t1: 0.697, t2_a: 0.394, t2_b: 1512, t3: 0.470 },
|
||||
];
|
||||
|
||||
const CATEGORIES_DEFAULT = ["Deplacement kilometrique", "Repas", "Hebergement", "Transport", "Autre"];
|
||||
|
||||
const TAUX_TVA = [
|
||||
{ taux: 0, libelle: "Exonéré (0%)" },
|
||||
{ taux: 10, libelle: "Taux réduit (10%)" },
|
||||
{ taux: 15, libelle: "Taux intermédiaire (15%)" },
|
||||
{ taux: 20, libelle: "Taux normal (20%)" },
|
||||
];
|
||||
|
||||
function fmt(val) {
|
||||
return new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR" }).format(val);
|
||||
}
|
||||
function getHT(ttc, taux) { return ttc / (1 + taux / 100); }
|
||||
function getTVA(ttc, taux) { return ttc - getHT(ttc, taux); }
|
||||
function calcIndemnite(km, cv) {
|
||||
const b = BAREME_KM.find(r => r.cv === cv) ?? BAREME_KM[4];
|
||||
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));
|
||||
}
|
||||
function getTranche(km) {
|
||||
if (km <= 0) return 0;
|
||||
if (km <= 5000) return 1;
|
||||
if (km <= 20000) return 2;
|
||||
return 3;
|
||||
}
|
||||
|
||||
function newDepense() {
|
||||
return {
|
||||
id: Math.random(),
|
||||
categorie: "Repas",
|
||||
date: "",
|
||||
libelle: "",
|
||||
description: "",
|
||||
km: "",
|
||||
// FIX 1 : chevaux stocké en number pour cohérence avec BAREME_KM
|
||||
chevaux: 7,
|
||||
// FIX 2 : taux initial aligné avec les options TAUX_TVA (on utilise "" pour forcer un choix explicite)
|
||||
tvaItems: [{ taux: "", montantTTC: "" }],
|
||||
nombreParticipants: "",
|
||||
participants: [{ nom: "", prenom: "", societe: "" }],
|
||||
files: [],
|
||||
};
|
||||
}
|
||||
|
||||
const css = `
|
||||
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700;800&display=swap');
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--bg: #f4f4f5;
|
||||
--bg-card: #ffffff;
|
||||
--bg-input: #f9f9fa;
|
||||
--bg-sidebar: #ffffff;
|
||||
--border: rgba(0,0,0,0.07);
|
||||
--border-light: rgba(0,0,0,0.11);
|
||||
--text-primary: #111111;
|
||||
--text-secondary: #6b7280;
|
||||
--accent-orange: #c8933a;
|
||||
--accent-purple: #7c3aed;
|
||||
--accent-green: #16a34a;
|
||||
--accent-indigo: #4f46e5;
|
||||
--accent-yellow: #d97706;
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
}
|
||||
|
||||
body { background: var(--bg); color: var(--text-primary); min-height: 100vh; }
|
||||
|
||||
.app { display: flex; min-height: 100vh; }
|
||||
|
||||
/* ── Main ── */
|
||||
.main { flex: 1; padding: 28px; max-width: 740px; }
|
||||
|
||||
/* ── Header ── */
|
||||
.header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 28px; }
|
||||
.header-left { display: flex; align-items: center; gap: 14px; }
|
||||
.header-title { font-size: 20px; font-weight: 800; color: var(--text-primary); }
|
||||
.badge-brouillon {
|
||||
background: rgba(200,147,58,0.12);
|
||||
color: var(--accent-orange);
|
||||
border: 1px solid rgba(200,147,58,0.35);
|
||||
border-radius: 20px;
|
||||
padding: 4px 14px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.btn-save {
|
||||
background: #111111;
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
padding: 10px 24px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.btn-save:hover { opacity: 0.85; }
|
||||
|
||||
/* ── Meta fields ── */
|
||||
.meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr 100px;
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
margin-bottom: 24px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 14px;
|
||||
padding: 18px;
|
||||
}
|
||||
.meta-label { font-size: 11px; font-weight: 700; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.05em; line-height: 1.3; margin-bottom: 6px; }
|
||||
.meta-label span { color: #ef4444; }
|
||||
|
||||
.input-field {
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
color: var(--text-primary);
|
||||
width: 100%;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.input-field:focus { border-color: rgba(0,0,0,0.25); }
|
||||
.input-field::placeholder { color: var(--text-secondary); }
|
||||
|
||||
.logo-box {
|
||||
width: 52px; height: 52px;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 10px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
cursor: pointer;
|
||||
font-size: 22px;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.logo-box:hover { border-color: rgba(0,0,0,0.25); }
|
||||
|
||||
/* ── Section Dépenses ── */
|
||||
.section-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
|
||||
.section-title { font-size: 11px; font-weight: 800; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.1em; }
|
||||
.btn-add-depense {
|
||||
background: #111111;
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
padding: 10px 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.btn-add-depense:hover { opacity: 0.85; }
|
||||
|
||||
/* ── Dépense card ── */
|
||||
.depense-card {
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
background: var(--bg-card);
|
||||
margin-bottom: 16px;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.card-header-left { display: flex; align-items: center; gap: 10px; }
|
||||
.dot-orange { width: 10px; height: 10px; border-radius: 50%; background: var(--accent-orange); flex-shrink: 0; }
|
||||
.card-title { font-size: 13px; font-weight: 700; color: var(--text-primary); line-height: 1.2; }
|
||||
.card-subtitle { font-size: 11px; color: var(--text-secondary); margin-top: 2px; }
|
||||
.card-header-right { display: flex; align-items: center; gap: 10px; }
|
||||
.status-chip { font-size: 12px; color: var(--text-secondary); font-weight: 500; }
|
||||
|
||||
.card-body { padding: 18px; display: flex; flex-direction: column; gap: 16px; }
|
||||
|
||||
.form-grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.form-label { font-size: 11px; font-weight: 700; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.04em; display: block; margin-bottom: 6px; }
|
||||
|
||||
select.input-field { appearance: none; cursor: pointer; padding-right: 32px; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 10px center; }
|
||||
|
||||
/* ── TVA block ── */
|
||||
.tva-block {
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 14px;
|
||||
}
|
||||
.tva-block-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
|
||||
.tva-block-title { font-size: 12px; font-weight: 700; color: var(--text-secondary); }
|
||||
.btn-ajouter-taux {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 6px 14px;
|
||||
background: rgba(255,255,255,0.07);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 7px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-ajouter-taux:hover { background: rgba(0,0,0,0.05); }
|
||||
|
||||
.tva-cols { display: grid; grid-template-columns: 1fr 1fr 80px 80px 32px; gap: 8px; margin-bottom: 6px; align-items: center; }
|
||||
.tva-col-label { font-size: 10px; font-weight: 700; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
|
||||
.tva-val { display: flex; align-items: center; justify-content: center; height: 38px; font-size: 13px; font-weight: 700; }
|
||||
.tva-val.green { color: #7c3aed; }
|
||||
.tva-val.indigo { color: #4f46e5; }
|
||||
.tva-val.muted { color: var(--text-secondary); }
|
||||
|
||||
.btn-remove {
|
||||
width: 28px; height: 28px; border-radius: 6px; border: none;
|
||||
background: rgba(220,38,38,0.1);
|
||||
color: #dc2626;
|
||||
cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 13px;
|
||||
transition: background 0.15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-remove:hover { background: rgba(220,38,38,0.2); }
|
||||
.btn-remove:disabled { background: transparent; color: rgba(0,0,0,0.15); cursor: default; }
|
||||
|
||||
.totals-row {
|
||||
margin-top: 12px; padding: 10px 14px;
|
||||
background: rgba(0,0,0,0.02);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px;
|
||||
}
|
||||
.total-col { text-align: center; }
|
||||
.total-col + .total-col { border-left: 1px solid var(--border); }
|
||||
.total-col-label { font-size: 9px; font-weight: 700; color: var(--text-secondary); text-transform: uppercase; margin-bottom: 3px; }
|
||||
.total-col-val { font-size: 14px; font-weight: 900; }
|
||||
|
||||
/* ── Repas block ── */
|
||||
.repas-block {
|
||||
background: rgba(124,58,237,0.05);
|
||||
border: 1.5px solid rgba(124,58,237,0.2);
|
||||
border-radius: 10px; padding: 14px;
|
||||
}
|
||||
.repas-title { font-size: 12px; font-weight: 700; color: #7c3aed; margin-bottom: 10px; display: flex; align-items: center; justify-content: space-between; }
|
||||
|
||||
/* ── KM block ── */
|
||||
.km-block {
|
||||
background: rgba(124,58,237,0.05);
|
||||
border: 1.5px solid rgba(124,58,237,0.2);
|
||||
border-radius: 10px; padding: 16px;
|
||||
}
|
||||
.km-title { font-size: 12px; font-weight: 800; color: #7c3aed; margin-bottom: 14px; }
|
||||
.km-table { width: 100%; border-collapse: collapse; font-size: 12px; overflow: hidden; border-radius: 8px; margin-bottom: 16px; }
|
||||
.km-table thead tr { background: #7c3aed; }
|
||||
.km-table thead th { padding: 8px 12px; color: #fff; font-weight: 700; text-align: left; }
|
||||
.km-table thead th:not(:first-child) { text-align: center; }
|
||||
.km-table tbody tr { cursor: pointer; transition: background 0.1s; }
|
||||
.km-table tbody td { padding: 10px 12px; color: var(--text-secondary); }
|
||||
.km-table tbody td:not(:first-child) { text-align: center; }
|
||||
.km-table tbody tr.selected { background: rgba(124,58,237,0.12); outline: 2px solid #7c3aed; outline-offset: -1px; }
|
||||
.km-table tbody tr.selected td { color: #7c3aed; font-weight: 800; }
|
||||
.km-active-badge { font-size: 9px; color: #7c3aed; font-weight: 700; }
|
||||
|
||||
.indemnite-box {
|
||||
padding: 10px 16px;
|
||||
min-height: 42px;
|
||||
border-radius: 8px;
|
||||
border: 1.5px solid rgba(124,58,237,0.3);
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
}
|
||||
.indemnite-box.active { background: linear-gradient(135deg, #7c3aed, #5b21b6); }
|
||||
.indemnite-box.empty { background: rgba(124,58,237,0.05); }
|
||||
|
||||
/* ── Justificatif ── */
|
||||
.drop-zone {
|
||||
border: 1.5px dashed rgba(0,0,0,0.15);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.drop-zone:hover { border-color: rgba(0,0,0,0.3); }
|
||||
.drop-zone.has-files { border-color: rgba(22,163,74,0.5); color: var(--accent-green); }
|
||||
|
||||
/* ── Btn supprimer ── */
|
||||
.btn-supprimer {
|
||||
width: 100%; padding: 12px;
|
||||
background: rgba(0,0,0,0.02);
|
||||
border: 1px solid rgba(0,0,0,0.08);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px; font-weight: 600;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-supprimer:hover { background: rgba(220,38,38,0.06); border-color: rgba(220,38,38,0.3); color: #dc2626; }
|
||||
|
||||
/* ── Sidebar ── */
|
||||
.sidebar {
|
||||
width: 280px;
|
||||
background: var(--bg-sidebar);
|
||||
border-left: 1px solid var(--border);
|
||||
padding: 24px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.sidebar-title { font-size: 11px; font-weight: 800; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.1em; margin-bottom: 8px; }
|
||||
|
||||
.recap-item {
|
||||
display: flex; align-items: flex-start; gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
background: rgba(0,0,0,0.03);
|
||||
}
|
||||
.recap-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent-orange); flex-shrink: 0; margin-top: 3px; }
|
||||
.recap-label { font-size: 13px; font-weight: 700; color: var(--text-primary); }
|
||||
.recap-cat { font-size: 11px; color: var(--text-secondary); margin-top: 1px; }
|
||||
|
||||
.sidebar-totals { margin-top: auto; }
|
||||
.sidebar-totals-row { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid var(--border); font-size: 13px; color: var(--text-secondary); font-weight: 500; }
|
||||
.sidebar-totals-main { display: flex; justify-content: space-between; padding: 12px 0; font-size: 15px; font-weight: 800; color: var(--text-primary); }
|
||||
|
||||
.btn-soumettre {
|
||||
width: 100%;
|
||||
padding: 13px;
|
||||
background: #111111;
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 14px; font-weight: 800;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: opacity 0.15s;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.btn-soumettre:hover { opacity: 0.85; }
|
||||
.btn-soumettre-note { font-size: 11px; color: var(--text-secondary); text-align: center; margin-top: 6px; }
|
||||
|
||||
.input-with-suffix { position: relative; }
|
||||
.input-with-suffix .suffix { position: absolute; right: 12px; top: 50%; transform: translateY(-50%); font-size: 11px; color: var(--accent-purple); font-weight: 700; pointer-events: none; }
|
||||
.input-with-suffix .suffix-eur { color: var(--text-secondary); }
|
||||
`;
|
||||
|
||||
function DepenseCard({ depense, total, onChange, onDelete }) {
|
||||
const fileRef = useRef();
|
||||
const isKm = depense.categorie.toLowerCase().includes("kilom");
|
||||
const isRepas = depense.categorie.toLowerCase().includes("repas");
|
||||
|
||||
const kmNum = parseFloat(depense.km) || 0;
|
||||
// FIX 1 : chevaux est maintenant un number, parseInt reste en sécurité
|
||||
const cv = typeof depense.chevaux === "number" ? depense.chevaux : parseInt(depense.chevaux) || 7;
|
||||
const tranche = getTranche(kmNum);
|
||||
const indemnite = calcIndemnite(kmNum, cv);
|
||||
|
||||
const tvaItems = depense.tvaItems?.length ? depense.tvaItems : [{ taux: "", montantTTC: "" }];
|
||||
const totalTTC = isKm ? indemnite : tvaItems.reduce((s, i) => s + (parseFloat(i.montantTTC) || 0), 0);
|
||||
const totalHT = isKm ? indemnite : tvaItems.reduce((s, i) => {
|
||||
const t = parseFloat(i.montantTTC) || 0; const tx = parseFloat(i.taux) || 0;
|
||||
return s + (tx > 0 ? getHT(t, tx) : t);
|
||||
}, 0);
|
||||
const totalTVA = isKm ? 0 : tvaItems.reduce((s, i) => {
|
||||
const t = parseFloat(i.montantTTC) || 0; const tx = parseFloat(i.taux) || 0;
|
||||
return s + (tx > 0 ? getTVA(t, tx) : 0);
|
||||
}, 0);
|
||||
|
||||
const updateTvaItem = (idx, field, val) => {
|
||||
const copy = [...tvaItems];
|
||||
copy[idx] = { ...copy[idx], [field]: val };
|
||||
// FIX 2 : suppression du onChange("montant", ...) — ce champ n'existe pas dans le modèle
|
||||
onChange("tvaItems", copy);
|
||||
};
|
||||
|
||||
const addTvaItem = () => onChange("tvaItems", [...tvaItems, { taux: "", montantTTC: "" }]);
|
||||
const removeTvaItem = (idx) => {
|
||||
if (tvaItems.length <= 1) return;
|
||||
const copy = tvaItems.filter((_, i) => i !== idx);
|
||||
// FIX 2 (suite) : suppression du onChange("montant", ...) orphelin
|
||||
onChange("tvaItems", copy);
|
||||
};
|
||||
|
||||
const updateParticipant = (i, field, val) => {
|
||||
const updated = [...depense.participants];
|
||||
updated[i] = { ...updated[i], [field]: val };
|
||||
onChange("participants", updated);
|
||||
};
|
||||
|
||||
const addFiles = (list) => {
|
||||
if (!list) return;
|
||||
const allowed = ["image/jpeg", "image/jpg", "image/png", "application/pdf"];
|
||||
onChange("files", [...depense.files, ...Array.from(list).filter(f => allowed.includes(f.type))]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="depense-card">
|
||||
{/* Header */}
|
||||
<div className="card-header">
|
||||
<div className="card-header-left">
|
||||
<div className="dot-orange" />
|
||||
<div>
|
||||
<div className="card-title">Nouvelle dépense</div>
|
||||
<div className="card-subtitle">{depense.categorie || "Sans catégorie"}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-header-right">
|
||||
<span className="status-chip">À saisir</span>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#888" strokeWidth="2"><path d="M18 15l-6-6-6 6" /></svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="card-body">
|
||||
{/* Catégorie + Date */}
|
||||
<div className="form-grid-2">
|
||||
<div>
|
||||
<label className="form-label">Catégorie *</label>
|
||||
<select className="input-field" value={depense.categorie}
|
||||
onChange={e => {
|
||||
onChange("categorie", e.target.value);
|
||||
onChange("km", "");
|
||||
onChange("tvaItems", [{ taux: "", montantTTC: "" }]);
|
||||
}}>
|
||||
{CATEGORIES_DEFAULT.map(c => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Date *</label>
|
||||
<input type="date" className="input-field" value={depense.date} onChange={e => onChange("date", e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Libellé */}
|
||||
<div>
|
||||
<label className="form-label">Libellé *</label>
|
||||
<input className="input-field" value={depense.libelle} onChange={e => onChange("libelle", e.target.value)} placeholder="Ex: Déjeuner client, Train Paris-Lyon..." />
|
||||
</div>
|
||||
|
||||
{/* Commentaire */}
|
||||
<div>
|
||||
<label className="form-label">Commentaire (optionnel)</label>
|
||||
<input className="input-field" value={depense.description} onChange={e => onChange("description", e.target.value)} placeholder="Précision, contexte..." />
|
||||
</div>
|
||||
|
||||
{/* KM block */}
|
||||
{isKm && (
|
||||
<div className="km-block">
|
||||
<div className="km-title">🚗 Barème kilométrique fiscal</div>
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<table className="km-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Puissance fiscale</th>
|
||||
<th>≤ 5 000 km</th>
|
||||
<th>5 001 → 20 000 km</th>
|
||||
<th>> 20 000 km</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{BAREME_KM.map((b) => {
|
||||
const sel = cv === b.cv;
|
||||
return (
|
||||
<tr key={b.cv} className={sel ? "selected" : ""}
|
||||
// FIX 1 : on stocke un number, pas une string
|
||||
onClick={() => onChange("chevaux", b.cv)}>
|
||||
<td>{b.label}</td>
|
||||
<td>d × {b.t1.toString().replace(".", ",")}{sel && tranche === 1 && <div className="km-active-badge">▲ active</div>}</td>
|
||||
<td>(d × {b.t2_a.toString().replace(".", ",")}) + {b.t2_b.toLocaleString("fr-FR")}{sel && tranche === 2 && <div className="km-active-badge">▲ active</div>}</td>
|
||||
<td>d × {b.t3.toString().replace(".", ",")}{sel && tranche === 3 && <div className="km-active-badge">▲ active</div>}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="form-grid-2">
|
||||
<div>
|
||||
<label className="form-label" style={{ color: "#7c3aed" }}>Nombre de kilomètres *</label>
|
||||
<div className="input-with-suffix">
|
||||
<input type="number" min="0" className="input-field" style={{ borderColor: "rgba(124,58,237,0.4)", paddingRight: 40 }} value={depense.km} onChange={e => onChange("km", e.target.value)} placeholder="0" />
|
||||
<span className="suffix">km</span>
|
||||
</div>
|
||||
{kmNum > 0 && tranche > 0 && (
|
||||
<div style={{ marginTop: 5, fontSize: 10, color: "#7c3aed", fontWeight: 600 }}>
|
||||
🔢 Tranche {tranche} = <strong>{fmt(indemnite)}</strong>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label" style={{ color: "#7c3aed" }}>Indemnité calculée</label>
|
||||
<div className={`indemnite-box ${indemnite > 0 ? "active" : "empty"}`}>
|
||||
<span style={{ fontSize: 18, fontWeight: 900, color: indemnite > 0 ? "#fff" : "#6b7280" }}>
|
||||
{indemnite > 0 ? fmt(indemnite) : "—"}
|
||||
</span>
|
||||
{kmNum > 0 && tranche > 0 && (
|
||||
<span style={{ fontSize: 10, color: "rgba(255,255,255,0.8)", fontWeight: 600, textAlign: "right" }}>
|
||||
{cv} CV<br />Tranche {tranche}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TVA block */}
|
||||
{!isKm && (
|
||||
<div className="tva-block">
|
||||
<div className="tva-block-header">
|
||||
<span className="tva-block-title">Montant & TVA</span>
|
||||
<button type="button" className="btn-ajouter-taux" onClick={addTvaItem}>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><path d="M12 5v14M5 12h14" /></svg>
|
||||
+ Ajouter un taux
|
||||
</button>
|
||||
</div>
|
||||
<div className="tva-cols" style={{ marginBottom: 8 }}>
|
||||
<div className="tva-col-label">Taux TVA</div>
|
||||
<div className="tva-col-label">Montant TTC</div>
|
||||
<div className="tva-col-label">HT</div>
|
||||
<div className="tva-col-label">TVA</div>
|
||||
<div />
|
||||
</div>
|
||||
{tvaItems.map((item, idx) => {
|
||||
const ttc = parseFloat(item.montantTTC) || 0;
|
||||
const taux = parseFloat(item.taux) || 0;
|
||||
const ht = taux > 0 ? getHT(ttc, taux) : ttc;
|
||||
const tva = taux > 0 ? getTVA(ttc, taux) : 0;
|
||||
return (
|
||||
<div key={idx} className="tva-cols" style={{ marginBottom: 8, alignItems: "center" }}>
|
||||
{/* FIX 2 : option vide cohérente avec le state initial taux: "" */}
|
||||
<select className="input-field" value={item.taux} onChange={e => updateTvaItem(idx, "taux", e.target.value)}>
|
||||
<option value="">— Choisir un taux —</option>
|
||||
{TAUX_TVA.map((t, i) => <option key={i} value={String(t.taux)}>{t.taux}% — {t.libelle}</option>)}
|
||||
</select>
|
||||
<div className="input-with-suffix">
|
||||
<input type="number" min="0" step="0.01" className="input-field" style={{ paddingRight: 28 }} value={item.montantTTC} onChange={e => updateTvaItem(idx, "montantTTC", e.target.value)} placeholder="0,00" />
|
||||
<span className="suffix suffix-eur">€</span>
|
||||
</div>
|
||||
<div className={`tva-val ${ttc > 0 ? "green" : "muted"}`}>{ttc > 0 ? fmt(ht) : "—"}</div>
|
||||
<div className={`tva-val ${tva > 0 ? "indigo" : "muted"}`}>{tva > 0 ? fmt(tva) : "—"}</div>
|
||||
<button type="button" className="btn-remove" disabled={tvaItems.length <= 1} onClick={() => removeTvaItem(idx)}>✕</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{tvaItems.length > 1 && totalTTC > 0 && (
|
||||
<div className="totals-row">
|
||||
<div className="total-col"><div className="total-col-label">Total HT</div><div className="total-col-val" style={{ color: "#7c3aed" }}>{fmt(totalHT)}</div></div>
|
||||
<div className="total-col"><div className="total-col-label">Total TVA</div><div className="total-col-val" style={{ color: "#4f46e5" }}>{fmt(totalTVA)}</div></div>
|
||||
<div className="total-col"><div className="total-col-label">Total TTC</div><div className="total-col-val" style={{ color: "var(--text-primary)" }}>{fmt(totalTTC)}</div></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Participants (repas) */}
|
||||
{isRepas && (
|
||||
<div className="repas-block">
|
||||
<div className="repas-title">
|
||||
🍽️ Participants au repas
|
||||
</div>
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
<label className="form-label" style={{ color: "#7c3aed" }}>Nombre de participants *</label>
|
||||
<input type="number" min="1" className="input-field" style={{ borderColor: "rgba(124,58,237,0.3)" }} value={depense.nombreParticipants} onChange={e => onChange("nombreParticipants", e.target.value)} placeholder="Ex: 3" />
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{depense.participants.map((p, i) => (
|
||||
<div key={i} style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr auto", gap: 8, alignItems: "end" }}>
|
||||
<div>
|
||||
{i === 0 && <div className="form-label" style={{ color: "#7c3aed" }}>Nom *</div>}
|
||||
<input className="input-field" style={{ borderColor: "rgba(124,58,237,0.3)" }} value={p.nom} onChange={e => updateParticipant(i, "nom", e.target.value)} placeholder="Nom" />
|
||||
</div>
|
||||
<div>
|
||||
{i === 0 && <div className="form-label" style={{ color: "#7c3aed" }}>Prénom *</div>}
|
||||
<input className="input-field" style={{ borderColor: "rgba(124,58,237,0.3)" }} value={p.prenom} onChange={e => updateParticipant(i, "prenom", e.target.value)} placeholder="Prénom" />
|
||||
</div>
|
||||
<div>
|
||||
{i === 0 && <div className="form-label" style={{ color: "#7c3aed" }}>Société</div>}
|
||||
<input className="input-field" style={{ borderColor: "rgba(124,58,237,0.3)" }} value={p.societe || ""} onChange={e => updateParticipant(i, "societe", e.target.value)} placeholder="Société (opt.)" />
|
||||
</div>
|
||||
{depense.participants.length > 1 && (
|
||||
<button className="btn-remove" onClick={() => onChange("participants", depense.participants.filter((_, j) => j !== i))}>✕</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button type="button" onClick={() => onChange("participants", [...depense.participants, { nom: "", prenom: "", societe: "" }])}
|
||||
style={{ marginTop: 8, background: "none", border: "1px dashed rgba(124,58,237,0.35)", borderRadius: 6, padding: "6px 12px", cursor: "pointer", fontSize: 11, color: "#7c3aed", fontWeight: 600, fontFamily: "inherit" }}>
|
||||
+ Ajouter un participant
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Justificatif */}
|
||||
<div>
|
||||
<label className="form-label">Justificatif{isKm ? " (facultatif)" : ""}</label>
|
||||
<div className={`drop-zone ${depense.files.length > 0 ? "has-files" : ""}`}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
onDragOver={e => e.preventDefault()}
|
||||
onDrop={e => { e.preventDefault(); addFiles(e.dataTransfer.files); }}>
|
||||
{depense.files.length > 0 ? `${depense.files.length} fichier${depense.files.length > 1 ? "s" : ""} joint${depense.files.length > 1 ? "s" : ""}` : "Glisser un fichier ou cliquer — PDF, JPG, PNG"}
|
||||
<input ref={fileRef} type="file" multiple accept=".pdf,.jpg,.jpeg,.png" onChange={e => addFiles(e.target.files)} style={{ display: "none" }} />
|
||||
</div>
|
||||
{isKm && depense.files.length === 0 && (
|
||||
<div style={{ marginTop: 8, background: "rgba(124,58,237,0.05)", border: "1px solid rgba(124,58,237,0.2)", borderRadius: 8, padding: "8px 12px", fontSize: 11, color: "#7c3aed", fontWeight: 600 }}>
|
||||
Déplacement kilométrique — le justificatif est facultatif
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Supprimer */}
|
||||
{total > 1 && (
|
||||
<button className="btn-supprimer" onClick={onDelete}>Supprimer cette dépense</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
// FIX 3 : titre est maintenant lié à un vrai <input> dans le JSX
|
||||
const [titre, setTitre] = useState("");
|
||||
const [dateDebut, setDateDebut] = useState("");
|
||||
const [dateFin, setDateFin] = useState("");
|
||||
const [commentaire, setCommentaire] = useState("");
|
||||
const [depenses, setDepenses] = useState([newDepense()]);
|
||||
|
||||
const updateDepense = (id, field, val) => {
|
||||
setDepenses(prev => prev.map(d => d.id === id ? { ...d, [field]: val } : d));
|
||||
};
|
||||
const deleteDepense = (id) => setDepenses(prev => prev.filter(d => d.id !== id));
|
||||
const addDepense = () => setDepenses(prev => [...prev, newDepense()]);
|
||||
|
||||
// Totaux sidebar
|
||||
const totalTTC = depenses.reduce((s, d) => {
|
||||
const isKm = d.categorie.toLowerCase().includes("kilom");
|
||||
if (isKm) return s + calcIndemnite(parseFloat(d.km) || 0, typeof d.chevaux === "number" ? d.chevaux : parseInt(d.chevaux) || 7);
|
||||
return s + (d.tvaItems || []).reduce((ss, i) => ss + (parseFloat(i.montantTTC) || 0), 0);
|
||||
}, 0);
|
||||
const totalHT = depenses.reduce((s, d) => {
|
||||
const isKm = d.categorie.toLowerCase().includes("kilom");
|
||||
if (isKm) return s + calcIndemnite(parseFloat(d.km) || 0, typeof d.chevaux === "number" ? d.chevaux : parseInt(d.chevaux) || 7);
|
||||
return s + (d.tvaItems || []).reduce((ss, i) => {
|
||||
const t = parseFloat(i.montantTTC) || 0; const tx = parseFloat(i.taux) || 0;
|
||||
return ss + (tx > 0 ? getHT(t, tx) : t);
|
||||
}, 0);
|
||||
}, 0);
|
||||
const totalTVA = totalTTC - totalHT;
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{css}</style>
|
||||
<div className="app">
|
||||
{/* Main */}
|
||||
<div className="main">
|
||||
{/* Header */}
|
||||
<div className="header">
|
||||
<div className="header-left">
|
||||
<span className="header-title">Nouvelle note de frais</span>
|
||||
<span className="badge-brouillon">Brouillon</span>
|
||||
</div>
|
||||
<button className="btn-save">Enregistrer</button>
|
||||
</div>
|
||||
|
||||
{/* Meta — FIX 3 : champ titre désormais fonctionnel avec input lié */}
|
||||
<div className="meta-grid">
|
||||
<div>
|
||||
<div className="meta-label">Titre <span>*</span></div>
|
||||
<input
|
||||
className="input-field"
|
||||
value={titre}
|
||||
onChange={e => setTitre(e.target.value)}
|
||||
placeholder="Ex: Mission Lyon mars"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: "var(--text-secondary)", marginBottom: 6, textTransform: "uppercase" }}>Période</div>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<input type="date" className="input-field" value={dateDebut} onChange={e => setDateDebut(e.target.value)} style={{ flex: 1 }} />
|
||||
<input type="date" className="input-field" value={dateFin} onChange={e => setDateFin(e.target.value)} style={{ flex: 1 }} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: "var(--text-secondary)", marginBottom: 6, textTransform: "uppercase" }}>Commentaire</div>
|
||||
<input className="input-field" value={commentaire} onChange={e => setCommentaire(e.target.value)} placeholder="Projet" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="logo-box" title="Ajouter un logo">🏢</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dépenses */}
|
||||
<div className="section-header">
|
||||
<span className="section-title">Dépenses</span>
|
||||
<button className="btn-add-depense" onClick={addDepense}>+ Ajouter une dépense</button>
|
||||
</div>
|
||||
|
||||
{depenses.map(d => (
|
||||
<DepenseCard
|
||||
key={d.id}
|
||||
depense={d}
|
||||
total={depenses.length}
|
||||
onChange={(field, val) => updateDepense(d.id, field, val)}
|
||||
onDelete={() => deleteDepense(d.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="sidebar">
|
||||
<div className="sidebar-title">Récapitulatif</div>
|
||||
{depenses.map(d => (
|
||||
<div className="recap-item" key={d.id}>
|
||||
<div className="recap-dot" />
|
||||
<div>
|
||||
<div className="recap-label">{d.libelle || "Sans libellé"}</div>
|
||||
<div className="recap-cat">{d.categorie}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="sidebar-totals">
|
||||
<div className="sidebar-totals-row">
|
||||
<span>Total HT</span>
|
||||
<span style={{ color: "var(--text-primary)", fontWeight: 700 }}>{totalHT > 0 ? fmt(totalHT) : "—"}</span>
|
||||
</div>
|
||||
<div className="sidebar-totals-row">
|
||||
<span>Total TVA</span>
|
||||
<span style={{ color: "var(--text-primary)", fontWeight: 700 }}>{totalTVA > 0 ? fmt(totalTVA) : "—"}</span>
|
||||
</div>
|
||||
<div className="sidebar-totals-main">
|
||||
<span>Total TTC</span>
|
||||
<span>{totalTTC > 0 ? fmt(totalTTC) : "—"}</span>
|
||||
</div>
|
||||
<button className="btn-soumettre">Soumettre la note ↗</button>
|
||||
<div className="btn-soumettre-note">Justificatifs vérifiés avant validation</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import React from 'react';
|
||||
import type { Note } from './types';
|
||||
import { fmt } from './helpers';
|
||||
import { tagStatut } from './TagStatut';
|
||||
|
||||
const API = '';
|
||||
|
||||
interface NotesTableProps {
|
||||
notes: Note[];
|
||||
}
|
||||
|
||||
const getJustificatifUrl = (note: Note): string | null => {
|
||||
try {
|
||||
const isKmOnly = note.lignesJson
|
||||
? JSON.parse(note.lignesJson || '[]').every((l: any) =>
|
||||
(l.categorie || '').toLowerCase().includes('kilom') &&
|
||||
parseFloat(l.montant || 0) === 0)
|
||||
: (note.km ?? 0) > 0 && parseFloat(String(note.montantTTC || 0)) === 0;
|
||||
if (isKmOnly) return 'KILOMETRIQUE';
|
||||
} catch { }
|
||||
|
||||
let files: { fileName: string; uploadUrl: string }[] = note.sharepointFiles || [];
|
||||
if (!files.length && note.fichiers) {
|
||||
try { files = JSON.parse(note.fichiers); } catch { files = []; }
|
||||
}
|
||||
if (!Array.isArray(files)) files = [];
|
||||
|
||||
const isApprouve = ['approuve', 'payee'].includes(note.statut || '');
|
||||
const file = files.find(f =>
|
||||
f.fileName.includes(isApprouve ? 'signe_approuve' : 'soumission')
|
||||
);
|
||||
return file?.uploadUrl || null;
|
||||
};
|
||||
|
||||
export const NotesTable = ({ notes }: NotesTableProps) => {
|
||||
if (!notes.length) return (
|
||||
<div style={{ padding: '48px 24px', textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||
<div style={{ fontSize: 36, marginBottom: 12 }}>—</div>
|
||||
<p style={{ fontSize: 14 }}>Aucune note de frais</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const HEADERS = ['Référence', 'Libellé', 'Catégorie', 'Date', 'Montant TTC', 'HT', 'TVA', 'Statut', 'Justificatif'];
|
||||
|
||||
return (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ background: 'var(--bg-thead)' }}>
|
||||
{HEADERS.map(h => (
|
||||
<th key={h} style={{
|
||||
padding: '10px 16px', textAlign: 'left', fontSize: 11,
|
||||
fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase',
|
||||
letterSpacing: '0.6px', borderBottom: '1px solid var(--border-divider)',
|
||||
}}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{notes.map((n, i) => (
|
||||
<tr key={i} style={{ borderTop: '1px solid var(--border-divider)' }}>
|
||||
<td style={{ padding: '13px 16px', color: '#6366f1', fontWeight: 700, fontFamily: 'monospace', fontSize: 12 }}>
|
||||
{n.reference || `#${n.id}`}
|
||||
</td>
|
||||
<td style={{ padding: '13px 16px', fontWeight: 600, fontSize: 13, color: 'var(--text-primary)' }}>
|
||||
{n.libelle || '—'}
|
||||
{n.nombreParticipants && (
|
||||
<span style={{ marginLeft: 6, fontSize: 10, background: '#fef3c7', color: '#92400e', padding: '1px 6px', borderRadius: 4 }}>
|
||||
{n.nombreParticipants} pers.
|
||||
</span>
|
||||
)}
|
||||
{n.km && (
|
||||
<span style={{ marginLeft: 6, fontSize: 10, background: '#ede9fe', color: '#7c3aed', padding: '1px 6px', borderRadius: 4 }}>
|
||||
{n.km} km
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '13px 16px', fontSize: 13, color: 'var(--text-secondary)' }}>{n.categorie || '—'}</td>
|
||||
<td style={{ padding: '13px 16px', fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{n.date ? new Date(n.date).toLocaleDateString('fr-FR') : '—'}
|
||||
</td>
|
||||
<td style={{ padding: '13px 16px', fontWeight: 800, fontSize: 14, color: 'var(--text-primary)' }}>
|
||||
{fmt(n.montant || 0)}
|
||||
</td>
|
||||
<td style={{ padding: '13px 16px', fontSize: 12, color: '#15803d' }}>
|
||||
{n.montantHT ? fmt(n.montantHT) : '—'}
|
||||
</td>
|
||||
<td style={{ padding: '13px 16px', fontSize: 12, color: '#6366f1' }}>
|
||||
{n.tauxTVA ? `${n.tauxTVA}%` : n.km ? 'Km' : '—'}
|
||||
</td>
|
||||
<td style={{ padding: '13px 16px' }}>{tagStatut(n.statut || 'brouillon')}</td>
|
||||
<td style={{ padding: '13px 16px' }}>
|
||||
{(() => {
|
||||
const url = getJustificatifUrl(n);
|
||||
if (url === 'KILOMETRIQUE')
|
||||
return <span style={{ color: '#7c3aed', fontSize: 12, fontWeight: 600 }}>Kilométrique</span>;
|
||||
if (url)
|
||||
return (
|
||||
<a href={url} target="_blank" rel="noreferrer"
|
||||
style={{ color: '#6366f1', fontSize: 12, fontWeight: 600, textDecoration: 'none' }}>
|
||||
{['approuve', 'payee'].includes(n.statut || '')
|
||||
? 'Justificatif approuvé'
|
||||
: 'Justificatif soumission'}
|
||||
</a>
|
||||
);
|
||||
if (n.justificatif)
|
||||
return (
|
||||
<a href={`${API}/uploads/${n.justificatif}`} target="_blank" rel="noreferrer"
|
||||
style={{ color: '#6366f1', fontSize: 12, fontWeight: 600, textDecoration: 'none' }}>
|
||||
Justificatif de soumission
|
||||
</a>
|
||||
);
|
||||
return <span style={{ color: '#cbd5e1', fontSize: 12 }}>—</span>;
|
||||
})()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
import React from 'react';
|
||||
import { normalizeStatut } from './helpers';
|
||||
|
||||
const STEPS = [
|
||||
{ key: 'enattente', label: 'Soumise', icon: '\u{1F4CB}' },
|
||||
{ key: 'validen1', label: 'Validée N1', icon: '\u2705' },
|
||||
{ key: 'validen2', label: 'Validée N2', icon: '\u2705' },
|
||||
{ key: 'approuve', label: 'Approuvée', icon: '\u{1F389}' },
|
||||
{ key: 'paiementenattente', label: 'En att. paiement', icon: '\u23F3' },
|
||||
{ key: 'payee', label: 'Payée', icon: '\u{1F4B6}' },
|
||||
];
|
||||
|
||||
function getActiveIndex(s: string): number {
|
||||
if (s === 'refuse' || s === 'refus') return -1;
|
||||
if (s === 'payee') return 5;
|
||||
if (s === 'paiementenattente' || s === 'paiement_en_attente') return 4;
|
||||
if (s === 'approuve') return 3;
|
||||
if (['validen2', 'valide_n2', 'validn2'].includes(s)) return 2;
|
||||
if (['validen1', 'valide_n1', 'validn1'].includes(s)) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export const StatutStepper = ({ statut }: { statut: string }) => {
|
||||
const s = normalizeStatut(statut);
|
||||
const active = getActiveIndex(s);
|
||||
|
||||
if (active === -1) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
padding: '10px 16px', background: '#fef2f2',
|
||||
border: '1.5px solid #fecaca', borderRadius: 12, marginBottom: 16,
|
||||
}}>
|
||||
<span style={{ fontSize: 18 }}>\u274C</span>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, color: '#dc2626' }}>Note refusée</div>
|
||||
<div style={{ fontSize: 11, color: '#ef4444' }}>Consultez le motif de refus ci-dessous</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
padding: '14px 20px', background: 'var(--bg-card)',
|
||||
border: '1px solid var(--border-card)', borderRadius: 14,
|
||||
marginBottom: 16, overflowX: 'auto',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', minWidth: 500 }}>
|
||||
{STEPS.map((step, i) => {
|
||||
const isDone = i < active;
|
||||
const isCurrent = i === active;
|
||||
return (
|
||||
<div key={step.key} style={{ display: 'flex', alignItems: 'center', flex: i < STEPS.length - 1 ? 1 : 0 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6 }}>
|
||||
<div style={{
|
||||
width: 36, height: 36, borderRadius: '50%',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: isCurrent ? 18 : 14,
|
||||
background: isDone ? '#dcfce7' : isCurrent ? 'linear-gradient(135deg,#6366f1,#4f46e5)' : 'var(--bg-input)',
|
||||
color: isDone ? '#15803d' : isCurrent ? '#fff' : 'var(--text-muted)',
|
||||
border: isCurrent ? '2px solid #4f46e5' : isDone ? '2px solid #86efac' : '2px solid var(--border-input)',
|
||||
boxShadow: isCurrent ? '0 0 0 4px rgba(99,102,241,0.15)' : 'none',
|
||||
flexShrink: 0, transition: 'all 0.3s',
|
||||
}}>
|
||||
{isDone ? '\u2713' : step.icon}
|
||||
</div>
|
||||
<span style={{
|
||||
fontSize: 10, fontWeight: isCurrent ? 700 : 500,
|
||||
color: isDone ? '#15803d' : isCurrent ? '#4f46e5' : 'var(--text-muted)',
|
||||
whiteSpace: 'nowrap', textAlign: 'center', letterSpacing: '0.2px',
|
||||
}}>
|
||||
{step.label}
|
||||
</span>
|
||||
</div>
|
||||
{i < STEPS.length - 1 && (
|
||||
<div style={{
|
||||
flex: 1, height: 3, marginBottom: 22, marginLeft: 4, marginRight: 4,
|
||||
borderRadius: 99,
|
||||
background: i < active ? 'linear-gradient(90deg,#86efac,#4ade80)' : 'var(--border-input)',
|
||||
transition: 'background 0.3s',
|
||||
}} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import { normalizeStatut } from './helpers';
|
||||
|
||||
const STATUT_MAP: Record<string, { bg: string; color: string; label: string }> = {
|
||||
enattente: { bg: '#fef9c3', color: '#a16207', label: 'En attente' },
|
||||
en_attente: { bg: '#fef9c3', color: '#a16207', label: 'En attente' },
|
||||
'en attente': { bg: '#fef9c3', color: '#a16207', label: 'En attente' },
|
||||
validn1: { bg: '#dbeafe', color: '#1d4ed8', label: 'Validé N1' },
|
||||
validen1: { bg: '#dbeafe', color: '#1d4ed8', label: 'Validé N1' },
|
||||
valide_n1: { bg: '#dbeafe', color: '#1d4ed8', label: 'Validé N1' },
|
||||
validn2: { bg: '#ede9fe', color: '#7c3aed', label: 'Validé N2' },
|
||||
validen2: { bg: '#ede9fe', color: '#7c3aed', label: 'Validé N2' },
|
||||
valide_n2: { bg: '#ede9fe', color: '#7c3aed', label: 'Validé N2' },
|
||||
approuve: { bg: '#dcfce7', color: '#15803d', label: 'Approuvé' },
|
||||
paiementenattente: { bg: '#fef9c3', color: '#b45309', label: 'Paiement en attente' },
|
||||
paiement_en_attente: { bg: '#fef9c3', color: '#b45309', label: 'Paiement en attente' },
|
||||
payee: { bg: '#f0fdf4', color: '#16a34a', label: 'Payée' },
|
||||
refuse: { bg: '#fee2e2', color: '#dc2626', label: 'Refusée' },
|
||||
refus: { bg: '#fee2e2', color: '#dc2626', label: 'Refusée' },
|
||||
brouillon: { bg: '#f1f5f9', color: 'var(--text-secondary)', label: 'Brouillon' },
|
||||
valide: { bg: '#dcfce7', color: '#15803d', label: 'Validé' },
|
||||
};
|
||||
|
||||
export const TagStatut = ({ statut }: { statut: string }) => {
|
||||
const key = normalizeStatut(statut);
|
||||
const s = STATUT_MAP[key] ?? STATUT_MAP['brouillon'];
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-block', fontSize: 11, fontWeight: 700,
|
||||
padding: '3px 10px', borderRadius: 20, whiteSpace: 'nowrap',
|
||||
background: s.bg, color: s.color,
|
||||
}}>
|
||||
{s.label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
/** Alias fonction pour compatibilité avec l'ancien code inline */
|
||||
export const tagStatut = (statut: string) => <TagStatut statut={statut} />;
|
||||
@@ -0,0 +1,109 @@
|
||||
import React from 'react';
|
||||
|
||||
const API = '';
|
||||
|
||||
interface UrlPreviewPopupProps {
|
||||
item: { url: string; name: string } | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const UrlPreviewPopup = ({ item, onClose }: UrlPreviewPopupProps) => {
|
||||
if (!item) return null;
|
||||
const isPDF = /\.pdf$/i.test(item.name);
|
||||
const isImage = /\.(jpg|jpeg|png|gif|webp)$/i.test(item.name);
|
||||
|
||||
return (
|
||||
<div onClick={onClose} style={{
|
||||
position: 'fixed', inset: 0, zIndex: 10000,
|
||||
background: 'rgba(0,0,0,0.8)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
backdropFilter: 'blur(6px)',
|
||||
}}>
|
||||
<div onClick={e => e.stopPropagation()} style={{
|
||||
background: '#fff', borderRadius: 16,
|
||||
boxShadow: '0 32px 100px rgba(0,0,0,.5)',
|
||||
width: isPDF ? 860 : 'auto',
|
||||
maxWidth: '94vw', maxHeight: '94vh',
|
||||
display: 'flex', flexDirection: 'column', overflow: 'hidden',
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '14px 20px',
|
||||
background: 'linear-gradient(135deg,#6366f1,#4f46e5)',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
|
||||
<span style={{ fontSize: 20, flexShrink: 0 }}>
|
||||
{isPDF ? '📄' : isImage ? '🖼️' : '📎'}
|
||||
</span>
|
||||
<div style={{
|
||||
color: '#fff', fontWeight: 700, fontSize: 13,
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>{item.name}</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0, marginLeft: 16 }}>
|
||||
<a href={item.url} target="_blank" rel="noreferrer" style={{
|
||||
padding: '6px 14px', background: 'rgba(255,255,255,0.2)',
|
||||
border: '1px solid rgba(255,255,255,0.35)', borderRadius: 8,
|
||||
color: '#fff', fontSize: 12, fontWeight: 600, textDecoration: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>↗ Ouvrir dans un onglet</a>
|
||||
<button onClick={onClose} style={{
|
||||
background: 'rgba(255,255,255,0.2)', border: 'none',
|
||||
borderRadius: 8, width: 36, height: 36, cursor: 'pointer',
|
||||
color: '#fff', fontSize: 20, display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||
}}>✕</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Body */}
|
||||
<div style={{
|
||||
flex: 1, overflow: 'auto',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: isPDF ? 0 : 24, background: '#f1f5f9', minHeight: 300,
|
||||
}}>
|
||||
{isPDF && (
|
||||
<iframe
|
||||
src={`${API}/api/proxy-pdf?url=${encodeURIComponent(item.url)}`}
|
||||
style={{ width: '100%', height: '80vh', border: 'none' }}
|
||||
title={item.name} />
|
||||
)}
|
||||
{isImage && (
|
||||
<img src={item.url} alt={item.name} style={{
|
||||
maxWidth: '85vw', maxHeight: '78vh',
|
||||
objectFit: 'contain', borderRadius: 10,
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.2)',
|
||||
}} />
|
||||
)}
|
||||
{!isPDF && !isImage && (
|
||||
<div style={{ textAlign: 'center', padding: 48 }}>
|
||||
<div style={{ fontSize: 56, marginBottom: 20 }}>📎</div>
|
||||
<div style={{ fontSize: 14, color: '#64748b', marginBottom: 20, fontWeight: 600 }}>{item.name}</div>
|
||||
<a href={item.url} target="_blank" rel="noreferrer" style={{
|
||||
padding: '12px 24px',
|
||||
background: 'linear-gradient(135deg,#6366f1,#4f46e5)',
|
||||
color: '#fff', borderRadius: 10, textDecoration: 'none',
|
||||
fontWeight: 700, fontSize: 14,
|
||||
}}>Ouvrir le fichier</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Footer */}
|
||||
<div style={{
|
||||
padding: '10px 20px', borderTop: '1px solid #e2e8f0',
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
background: '#fff', flexShrink: 0,
|
||||
}}>
|
||||
<span style={{ fontSize: 11, color: '#94a3b8' }}>Cliquez en dehors pour fermer</span>
|
||||
<a href={item.url} download style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
padding: '6px 14px', background: '#eef2ff',
|
||||
border: '1px solid #c7d2fe', borderRadius: 8,
|
||||
color: '#6366f1', fontSize: 12, fontWeight: 600, textDecoration: 'none',
|
||||
}}>⬇ Télécharger</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
// ── HELPERS & UTILITAIRES NDF ──────────────────────────────────────────────
|
||||
import type { LigneNote } from './types';
|
||||
|
||||
export const fmt = (n: number) =>
|
||||
new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(n || 0);
|
||||
|
||||
export function getHT(ttc: number, taux: number): number {
|
||||
if (!taux) return ttc;
|
||||
return parseFloat((ttc / (1 + taux / 100)).toFixed(2));
|
||||
}
|
||||
|
||||
export function getMontantTVA(ttc: number, taux: number): number {
|
||||
return parseFloat((ttc - getHT(ttc, taux)).toFixed(2));
|
||||
}
|
||||
|
||||
export const normalizeStatut = (s?: string): string =>
|
||||
s?.trim().toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '') ?? '';
|
||||
|
||||
export const BAREME_KM: Record<number, { t1: number; t2_a: number; t2_b: number; t3: number; label: string }> = {
|
||||
3: { t1: 0.529, t2_a: 0.316, t2_b: 1061, t3: 0.369, label: '3 CV et moins' },
|
||||
4: { t1: 0.606, t2_a: 0.340, t2_b: 1330, t3: 0.408, label: '4 CV' },
|
||||
5: { t1: 0.636, t2_a: 0.356, t2_b: 1391, t3: 0.427, label: '5 CV' },
|
||||
6: { t1: 0.665, t2_a: 0.374, t2_b: 1457, t3: 0.448, label: '6 CV' },
|
||||
7: { t1: 0.697, t2_a: 0.394, t2_b: 1512, t3: 0.470, label: '7 CV et plus' },
|
||||
};
|
||||
|
||||
export function getIndemniteKm(kmTotal: number, chevaux: number | string): number {
|
||||
const cv = Math.min(Math.max(parseInt(String(chevaux)) || 7, 3), 7) as keyof typeof BAREME_KM;
|
||||
const b = BAREME_KM[cv];
|
||||
if (kmTotal <= 0) return 0;
|
||||
if (kmTotal <= 5000) return parseFloat((kmTotal * b.t1).toFixed(2));
|
||||
if (kmTotal <= 20000) return parseFloat((kmTotal * b.t2_a + b.t2_b).toFixed(2));
|
||||
return parseFloat((kmTotal * b.t3).toFixed(2));
|
||||
}
|
||||
|
||||
export function getTarifUnitaireKm(kmTotal: number, chevaux: number | string): number {
|
||||
if (kmTotal <= 0) return 0;
|
||||
return parseFloat((getIndemniteKm(kmTotal, chevaux) / kmTotal).toFixed(4));
|
||||
}
|
||||
|
||||
export function newLigne(date: string): LigneNote {
|
||||
return {
|
||||
idTemp: crypto.randomUUID(),
|
||||
date,
|
||||
categorie: '',
|
||||
libelle: '',
|
||||
montant: '',
|
||||
tvaItems: [{ taux: '', montantTTC: '' }],
|
||||
tauxTVA: '',
|
||||
km: '',
|
||||
chevaux: '7',
|
||||
description: '',
|
||||
files: [],
|
||||
participants: [{ nom: '', prenom: '', societe: '' }],
|
||||
nombreParticipants: '',
|
||||
qrLink: null,
|
||||
qrUploaded: false,
|
||||
qrNoteRef: '',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
// ── TYPES & INTERFACES NDF ─────────────────────────────────────────────────
|
||||
|
||||
export interface Note {
|
||||
id: number;
|
||||
reference?: string;
|
||||
libelle?: string;
|
||||
categorie?: string;
|
||||
date?: string;
|
||||
montant?: number;
|
||||
statut?: string;
|
||||
collaborateur?: string;
|
||||
collaborateurEmail?: string;
|
||||
departement?: string;
|
||||
campus?: string;
|
||||
justificatif?: string;
|
||||
sharepointUrl?: string;
|
||||
fichiers?: string;
|
||||
participants?: string;
|
||||
nombreParticipants?: number;
|
||||
validateurN1Id?: number;
|
||||
validateurN2Id?: number;
|
||||
nomValidateurN1?: string;
|
||||
nomValidateurN2?: string;
|
||||
datePaiement?: string;
|
||||
moisPaiement?: number;
|
||||
anneePaiement?: number;
|
||||
commentaireN1?: string;
|
||||
commentaireN2?: string;
|
||||
motifRefus?: string;
|
||||
montantHT?: number;
|
||||
tauxTVA?: number;
|
||||
km?: number;
|
||||
indemniteKm?: number;
|
||||
lignesJson?: string;
|
||||
montantTTC?: number;
|
||||
sharepointFiles?: { fileName: string; uploadUrl: string }[];
|
||||
}
|
||||
|
||||
export interface Profile {
|
||||
prenom?: string;
|
||||
nom?: string;
|
||||
email?: string;
|
||||
role?: string;
|
||||
departement?: string;
|
||||
campus?: string;
|
||||
poste?: string;
|
||||
TypeContrat?: string;
|
||||
DateEntree?: string;
|
||||
}
|
||||
|
||||
export interface Categorie {
|
||||
id: number;
|
||||
Nom: string;
|
||||
Detail?: string;
|
||||
Icone?: string;
|
||||
PlafondParPersonne?: number;
|
||||
DescriptionPlafond?: string;
|
||||
}
|
||||
|
||||
export interface TauxTVA {
|
||||
taux: number;
|
||||
libelle: string;
|
||||
categorie: string | null;
|
||||
}
|
||||
|
||||
export interface Notification {
|
||||
id: number;
|
||||
Type: string;
|
||||
Titre: string;
|
||||
Message: string;
|
||||
NoteDeFraisId?: number;
|
||||
Lu: boolean;
|
||||
DateCreation: string;
|
||||
}
|
||||
|
||||
export interface Participant {
|
||||
nom: string;
|
||||
prenom: string;
|
||||
societe?: string;
|
||||
}
|
||||
|
||||
export interface Exception {
|
||||
id: number;
|
||||
reference?: string;
|
||||
montant?: number;
|
||||
libelle?: string;
|
||||
collaborateur?: string;
|
||||
email?: string;
|
||||
Motif?: string;
|
||||
Statut?: string;
|
||||
DateDemande?: string;
|
||||
}
|
||||
|
||||
export interface PaiementConfig {
|
||||
JourPaiement: number;
|
||||
}
|
||||
|
||||
export interface HistoriqueValidation {
|
||||
id: number;
|
||||
reference: string;
|
||||
libelle: string;
|
||||
montant: number;
|
||||
categorie: string;
|
||||
collaborateur: string;
|
||||
departement?: string;
|
||||
campus?: string;
|
||||
Niveau: string;
|
||||
Action: string;
|
||||
Commentaire?: string;
|
||||
MotifRefus?: string;
|
||||
NouveauStatut: string;
|
||||
DateAction: string;
|
||||
}
|
||||
|
||||
export interface LigneNote {
|
||||
idTemp: string;
|
||||
date: string;
|
||||
categorie: string;
|
||||
libelle: string;
|
||||
montant: string;
|
||||
/** TVA multiple : tableau de { taux, montantTTC } */
|
||||
tvaItems: { taux: string; montantTTC: string }[];
|
||||
/** Rétrocompatibilité anciens brouillons */
|
||||
tauxTVA?: string;
|
||||
km: string;
|
||||
/** Puissance fiscale 3-7 CV */
|
||||
chevaux: string;
|
||||
description: string;
|
||||
files: File[];
|
||||
participants: Participant[];
|
||||
nombreParticipants: string;
|
||||
qrLink: string | null;
|
||||
qrUploaded: boolean;
|
||||
qrNoteRef: string;
|
||||
}
|
||||
|
||||
export type DocStatut = 'en_attente' | 'valide' | 'refuse' | 'absent';
|
||||
|
||||
export type DocInfo = {
|
||||
fileName: string;
|
||||
sharepointUrl: string;
|
||||
updatedAt: string;
|
||||
statut: DocStatut;
|
||||
commentaire?: string | null;
|
||||
} | null;
|
||||
@@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
|
||||
export const inputStyle: React.CSSProperties = {
|
||||
width: '100%', padding: '10px 14px',
|
||||
border: '1.5px solid var(--border-input)', borderRadius: 8,
|
||||
background: 'var(--bg-input)', fontFamily: '"Inter","Segoe UI",sans-serif',
|
||||
fontSize: 14, color: 'var(--text-primary)', outline: 'none',
|
||||
boxSizing: 'border-box', transition: 'border-color 0.2s',
|
||||
};
|
||||
|
||||
export const inputSm: React.CSSProperties = {
|
||||
...inputStyle,
|
||||
padding: '9px 12px',
|
||||
fontSize: 13,
|
||||
background: 'var(--bg-input-sm)',
|
||||
};
|
||||
|
||||
export const labelStyle: React.CSSProperties = {
|
||||
display: 'block', fontSize: 12, fontWeight: 700,
|
||||
color: 'var(--text-secondary)', textTransform: 'uppercase',
|
||||
letterSpacing: '0.6px', marginBottom: 6,
|
||||
};
|
||||
|
||||
export const labelSm: React.CSSProperties = {
|
||||
...labelStyle,
|
||||
fontSize: 10,
|
||||
color: 'var(--text-muted)',
|
||||
marginBottom: 4,
|
||||
};
|
||||
|
||||
export const cardStyle: React.CSSProperties = {
|
||||
background: 'var(--bg-card)', borderRadius: 16,
|
||||
border: '1px solid var(--border-card)',
|
||||
boxShadow: 'var(--shadow-card)',
|
||||
overflow: 'hidden',
|
||||
};
|
||||
|
||||
export const btnPrimary: React.CSSProperties = {
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '11px 22px',
|
||||
background: 'linear-gradient(135deg,#6366f1,#4f46e5)',
|
||||
color: '#fff', border: 'none', borderRadius: 10,
|
||||
cursor: 'pointer', fontFamily: '"Inter","Segoe UI",sans-serif',
|
||||
fontSize: 14, fontWeight: 600,
|
||||
boxShadow: '0 4px 14px rgba(99,102,241,.4)',
|
||||
whiteSpace: 'nowrap',
|
||||
};
|
||||
|
||||
export const btnSuccess: React.CSSProperties = {
|
||||
padding: '7px 16px', background: '#dcfce7', color: '#15803d',
|
||||
border: '1px solid #bbf7d0', borderRadius: 7, cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 13, fontWeight: 600,
|
||||
};
|
||||
|
||||
export const btnDanger: React.CSSProperties = {
|
||||
padding: '7px 16px', background: '#fee2e2', color: '#dc2626',
|
||||
border: '1px solid #fecaca', borderRadius: 7, cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 13, fontWeight: 600,
|
||||
};
|
||||
@@ -0,0 +1,233 @@
|
||||
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
|
||||
// ── TYPES ──────────────────────────────────────────────
|
||||
interface User {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
role: string; // chaîne brute ex: "Collaboratrice,Finance"
|
||||
roles: string[]; // tableau ex: ["Collaboratrice", "Finance"]
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
activeRole: string | null;
|
||||
allRoles: string[];
|
||||
isMultiRole: boolean;
|
||||
needsRoleSelection: boolean;
|
||||
|
||||
handleCallback: (token: string) => void;
|
||||
logout: () => void;
|
||||
selectRole: (role: string) => void;
|
||||
switchRole: (role: string) => void;
|
||||
|
||||
hasRole: (...roles: string[]) => boolean;
|
||||
isCollaborateur: boolean;
|
||||
isValidateur: boolean;
|
||||
isFinance: boolean;
|
||||
isSuperUser: boolean;
|
||||
isVerificateurFinance: boolean;
|
||||
isValidateurFinance: boolean;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
const API = 'myndf.ensup-adm.net';
|
||||
|
||||
const VALID_ROLES = [
|
||||
'Collaborateur', 'Collaboratrice',
|
||||
'Validateur', 'Validatrice',
|
||||
'Finance', 'VerificateurFinance', 'ValidateurFinance',
|
||||
'superUtilisateur'
|
||||
];
|
||||
|
||||
function parseRoles(input: string | string[]): string[] {
|
||||
// ✅ Gère les deux cas : tableau JWT ou chaîne BDD
|
||||
if (Array.isArray(input)) {
|
||||
return input.filter(r => VALID_ROLES.includes(r));
|
||||
}
|
||||
if (!input) return [];
|
||||
return input
|
||||
.split(',')
|
||||
.map(r => r.trim())
|
||||
.filter(r => VALID_ROLES.includes(r));
|
||||
}
|
||||
|
||||
// ── PROVIDER ───────────────────────────────────────────
|
||||
export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
||||
|
||||
const [user, setUser] = useState<User | null>(() => {
|
||||
const saved = localStorage.getItem('user');
|
||||
if (!saved) return null;
|
||||
const u = JSON.parse(saved);
|
||||
if (!u.roles || !u.roles.length) u.roles = parseRoles(u.role || '');
|
||||
return u;
|
||||
});
|
||||
|
||||
const [activeRole, setActiveRole] = useState<string | null>(() => {
|
||||
return localStorage.getItem('activeRole');
|
||||
});
|
||||
|
||||
const [needsRoleSelection, setNeedsRoleSelection] = useState<boolean>(() => {
|
||||
const saved = localStorage.getItem('user');
|
||||
const savedRole = localStorage.getItem('activeRole');
|
||||
if (!saved) return false;
|
||||
const u = JSON.parse(saved);
|
||||
const roles = parseRoles(u.roles || u.role || '');
|
||||
return roles.length > 1 && !savedRole;
|
||||
});
|
||||
|
||||
// ── Revalidation du profil au démarrage ───────────
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) return;
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||
if (payload.exp * 1000 < Date.now()) {
|
||||
_clearSession();
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
_clearSession();
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`${API}/api/profile`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
})
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(profile => {
|
||||
if (!profile) return;
|
||||
|
||||
// ✅ On lit profile.roles (tableau depuis UtilisateurRoles)
|
||||
// avec fallback sur profile.role (ancienne colonne)
|
||||
const roles = parseRoles(profile.roles || profile.role || '');
|
||||
const rawRole = roles.join(',');
|
||||
|
||||
const refreshed: User = {
|
||||
id: profile.id,
|
||||
name: `${profile.prenom} ${profile.nom}`.trim(),
|
||||
email: profile.email,
|
||||
role: rawRole,
|
||||
roles,
|
||||
};
|
||||
setUser(refreshed);
|
||||
localStorage.setItem('user', JSON.stringify(refreshed));
|
||||
|
||||
const savedRole = localStorage.getItem('activeRole');
|
||||
if (savedRole && !roles.includes(savedRole)) {
|
||||
localStorage.removeItem('activeRole');
|
||||
setActiveRole(null);
|
||||
setNeedsRoleSelection(roles.length > 1);
|
||||
}
|
||||
})
|
||||
.catch(() => { });
|
||||
}, []);
|
||||
|
||||
// ── handleCallback ────────────────────────────────
|
||||
const handleCallback = (token: string) => {
|
||||
localStorage.setItem('token', token);
|
||||
|
||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||
|
||||
// ✅ Le JWT contient `roles` (tableau), pas `role` (chaîne)
|
||||
const roles = parseRoles(payload.roles || payload.role || '');
|
||||
const rawRole = roles.join(',');
|
||||
|
||||
const newUser: User = {
|
||||
id: payload.id,
|
||||
name: `${payload.prenom} ${payload.nom}`.trim(),
|
||||
email: payload.email,
|
||||
role: rawRole,
|
||||
roles,
|
||||
};
|
||||
|
||||
setUser(newUser);
|
||||
localStorage.setItem('user', JSON.stringify(newUser));
|
||||
|
||||
if (roles.length > 1) {
|
||||
localStorage.removeItem('activeRole');
|
||||
setActiveRole(null);
|
||||
setNeedsRoleSelection(true);
|
||||
} else {
|
||||
const single = roles[0] ?? 'Collaborateur';
|
||||
setActiveRole(single);
|
||||
localStorage.setItem('activeRole', single);
|
||||
setNeedsRoleSelection(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ── selectRole ────────────────────────────────────
|
||||
const selectRole = (role: string) => {
|
||||
setActiveRole(role);
|
||||
localStorage.setItem('activeRole', role);
|
||||
setNeedsRoleSelection(false);
|
||||
setUser(prev => prev ? { ...prev, role } : prev);
|
||||
};
|
||||
|
||||
// ── switchRole ────────────────────────────────────
|
||||
const switchRole = (role: string) => {
|
||||
selectRole(role);
|
||||
};
|
||||
|
||||
// ── logout ────────────────────────────────────────
|
||||
const logout = () => {
|
||||
_clearSession();
|
||||
window.location.href = '/login';
|
||||
};
|
||||
|
||||
const _clearSession = () => {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
localStorage.removeItem('activeRole');
|
||||
setUser(null);
|
||||
setActiveRole(null);
|
||||
setNeedsRoleSelection(false);
|
||||
};
|
||||
|
||||
// ── Dérivés ───────────────────────────────────────
|
||||
const allRoles = user?.roles ?? [];
|
||||
const isMultiRole = allRoles.length > 1;
|
||||
|
||||
const hasRole = (...roles: string[]): boolean =>
|
||||
!!activeRole && roles.includes(activeRole);
|
||||
|
||||
const isCollaborateur = hasRole('Collaborateur', 'Collaboratrice');
|
||||
const isValidateur = hasRole('Validateur', 'Validatrice');
|
||||
const isFinance = hasRole('Finance');
|
||||
const isSuperUser = hasRole('superUtilisateur');
|
||||
const isVerificateurFinance = hasRole('VerificateurFinance');
|
||||
const isValidateurFinance = hasRole('ValidateurFinance');
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{
|
||||
user,
|
||||
activeRole,
|
||||
allRoles,
|
||||
isMultiRole,
|
||||
needsRoleSelection,
|
||||
handleCallback,
|
||||
logout,
|
||||
selectRole,
|
||||
switchRole,
|
||||
hasRole,
|
||||
isCollaborateur,
|
||||
isValidateur,
|
||||
isFinance,
|
||||
isSuperUser,
|
||||
isVerificateurFinance,
|
||||
isValidateurFinance
|
||||
}}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -0,0 +1,162 @@
|
||||
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import { Sun, Moon } from 'lucide-react';
|
||||
|
||||
// ── TYPES ──────────────────────────────────────────────
|
||||
type Theme = 'light' | 'dark';
|
||||
|
||||
interface ThemeContextType {
|
||||
theme: Theme;
|
||||
toggleTheme: () => void;
|
||||
isDark: boolean;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||
|
||||
// ── TOKENS CSS PAR THÈME ──────────────────────────────
|
||||
const THEMES = {
|
||||
light: {
|
||||
// Backgrounds
|
||||
'--bg-app': '#f1f5f9',
|
||||
'--bg-main': '#ffffff',
|
||||
'--bg-card': '#ffffff',
|
||||
'--bg-input': '#f8fafc',
|
||||
'--bg-input-sm': '#ffffff',
|
||||
'--bg-hover': '#f8fafc',
|
||||
'--bg-thead': '#f8fafc',
|
||||
'--bg-row-hover': '#f8fafc',
|
||||
'--bg-notif-unread': '#f0f9ff',
|
||||
'--bg-sidebar': 'linear-gradient(160deg,#6366f1 0%,#4f46e5 100%)',
|
||||
|
||||
// Borders
|
||||
'--border-main': '#e2e8f0',
|
||||
'--border-input': '#e2e8f0',
|
||||
'--border-card': '#e2e8f0',
|
||||
'--border-divider': '#f1f5f9',
|
||||
|
||||
// Text
|
||||
'--text-primary': '#0f172a',
|
||||
'--text-secondary': '#64748b',
|
||||
'--text-muted': '#94a3b8',
|
||||
'--text-heading': '#0f172a',
|
||||
|
||||
// Shadows
|
||||
'--shadow-card': '0 1px 3px rgba(0,0,0,.06), 0 4px 16px rgba(0,0,0,.04)',
|
||||
'--shadow-notif': '0 8px 32px rgba(0,0,0,.12)',
|
||||
|
||||
// Stat cards
|
||||
'--stat-bg': '#ffffff',
|
||||
|
||||
// Header
|
||||
'--header-bg': 'transparent',
|
||||
'--header-btn-bg': '#ffffff',
|
||||
'--header-btn-border': '#e2e8f0',
|
||||
},
|
||||
dark: {
|
||||
// Backgrounds
|
||||
'--bg-app': '#0f172a',
|
||||
'--bg-main': '#1e293b',
|
||||
'--bg-card': '#1e293b',
|
||||
'--bg-input': '#0f172a',
|
||||
'--bg-input-sm': '#0f172a',
|
||||
'--bg-hover': '#334155',
|
||||
'--bg-thead': '#0f172a',
|
||||
'--bg-row-hover': '#0f172a',
|
||||
'--bg-notif-unread': '#1e3a5f',
|
||||
'--bg-sidebar': 'linear-gradient(160deg,#312e81 0%,#1e1b4b 100%)',
|
||||
|
||||
// Borders
|
||||
'--border-main': '#334155',
|
||||
'--border-input': '#334155',
|
||||
'--border-card': '#334155',
|
||||
'--border-divider': '#1e293b',
|
||||
|
||||
// Text
|
||||
'--text-primary': '#f1f5f9',
|
||||
'--text-secondary': '#94a3b8',
|
||||
'--text-muted': '#64748b',
|
||||
'--text-heading': '#f8fafc',
|
||||
|
||||
// Shadows
|
||||
'--shadow-card': '0 1px 3px rgba(0,0,0,.3), 0 4px 16px rgba(0,0,0,.2)',
|
||||
'--shadow-notif': '0 8px 32px rgba(0,0,0,.5)',
|
||||
|
||||
// Stat cards
|
||||
'--stat-bg': '#1e293b',
|
||||
|
||||
// Header
|
||||
'--header-bg': 'transparent',
|
||||
'--header-btn-bg': '#1e293b',
|
||||
'--header-btn-border': '#334155',
|
||||
},
|
||||
};
|
||||
|
||||
// ── INJECTION CSS VARIABLES ───────────────────────────
|
||||
function applyTheme(theme: Theme) {
|
||||
const root = document.documentElement;
|
||||
const tokens = THEMES[theme];
|
||||
Object.entries(tokens).forEach(([key, val]) => {
|
||||
root.style.setProperty(key, val);
|
||||
});
|
||||
root.setAttribute('data-theme', theme);
|
||||
}
|
||||
|
||||
// ── PROVIDER ──────────────────────────────────────────
|
||||
export const ThemeProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [theme, setTheme] = useState<Theme>(() => {
|
||||
const saved = localStorage.getItem('theme') as Theme | null;
|
||||
if (saved === 'light' || saved === 'dark') return saved;
|
||||
// Détecte la préférence système
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
});
|
||||
|
||||
// Applique les tokens au montage et à chaque changement
|
||||
useEffect(() => {
|
||||
applyTheme(theme);
|
||||
localStorage.setItem('theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
const toggleTheme = () => setTheme(t => t === 'light' ? 'dark' : 'light');
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, toggleTheme, isDark }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// ── HOOK ──────────────────────────────────────────────
|
||||
export const useTheme = () => {
|
||||
const ctx = useContext(ThemeContext);
|
||||
if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
|
||||
return ctx;
|
||||
};
|
||||
|
||||
// ── BOUTON TOGGLE (réutilisable partout) ──────────────
|
||||
export const ThemeToggleButton = ({ style }: { style?: React.CSSProperties }) => {
|
||||
const { toggleTheme, isDark } = useTheme();
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
title={isDark ? 'Passer en mode clair' : 'Passer en mode sombre'}
|
||||
style={{
|
||||
background: 'var(--header-btn-bg)',
|
||||
border: '1px solid var(--header-btn-border)',
|
||||
borderRadius: 10,
|
||||
width: 40, height: 40,
|
||||
cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: 'var(--text-secondary)',
|
||||
transition: 'all 0.2s',
|
||||
flexShrink: 0,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{isDark
|
||||
? <Sun size={18} color="#fbbf24" />
|
||||
: <Moon size={18} color="#6366f1" />
|
||||
}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background: linear-gradient(135deg, #f5f5dc 0%, #e8d7c3 100%);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
const AuthCallback = (): JSX.Element => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const { handleCallback } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
const token = searchParams.get('token');
|
||||
const error = searchParams.get('error');
|
||||
|
||||
if (error) {
|
||||
navigate(`/login?error=${error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (token) {
|
||||
handleCallback(token);
|
||||
navigate('/dashboard', { replace: true });
|
||||
} else {
|
||||
navigate('/login?error=no_token');
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontFamily: 'sans-serif',
|
||||
fontSize: '18px',
|
||||
color: '#f5f5dc',
|
||||
}}>
|
||||
⏳ Connexion en cours...
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthCallback;
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,168 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
const API = '';
|
||||
|
||||
export default function UploadMobile() {
|
||||
const { token } = useParams<{ token: string }>();
|
||||
const [status, setStatus] = useState<'checking' | 'ready' | 'uploading' | 'done' | 'error'>('checking');
|
||||
const [noteRef, setNoteRef] = useState('');
|
||||
const [nomPrenom, setNomPrenom] = useState('');
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API}/api/upload/check/${token}`)
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
if (d.valid) {
|
||||
setNoteRef(d.noteRef);
|
||||
setNomPrenom(d.nomPrenom.replace('_', ' '));
|
||||
setStatus('ready');
|
||||
} else {
|
||||
setErrorMsg(d.error || 'Lien invalide');
|
||||
setStatus('error');
|
||||
}
|
||||
})
|
||||
.catch(() => { setErrorMsg('Erreur réseau'); setStatus('error'); });
|
||||
}, [token]);
|
||||
|
||||
const addFiles = (newFiles: FileList | null) => {
|
||||
if (!newFiles) return;
|
||||
const allowed = ['image/jpeg', 'image/jpg', 'image/png', 'application/pdf'];
|
||||
const filtered = Array.from(newFiles).filter(f => allowed.includes(f.type));
|
||||
setFiles(prev => [...prev, ...filtered]);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!files.length) return;
|
||||
setStatus('uploading');
|
||||
const form = new FormData();
|
||||
files.forEach(f => form.append('files', f));
|
||||
try {
|
||||
const res = await fetch(`${API}/api/upload/submit/${token}`, { method: 'POST', body: form });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error);
|
||||
setStatus('done');
|
||||
} catch (e: any) {
|
||||
setErrorMsg(e.message);
|
||||
setStatus('error');
|
||||
}
|
||||
};
|
||||
|
||||
// ── Écrans ──
|
||||
if (status === 'checking') return (
|
||||
<Screen><div style={{ textAlign: 'center' }}><Spinner />Vérification du lien...</div></Screen>
|
||||
);
|
||||
|
||||
if (status === 'error') return (
|
||||
<Screen>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 56, marginBottom: 12 }}>❌</div>
|
||||
<h2 style={{ color: '#dc2626', margin: '0 0 8px' }}>Lien invalide</h2>
|
||||
<p style={{ color: '#64748b', fontSize: 14 }}>{errorMsg}</p>
|
||||
<p style={{ color: '#94a3b8', fontSize: 12 }}>Ce lien a peut-être expiré (30 min) ou a déjà été utilisé.</p>
|
||||
</div>
|
||||
</Screen>
|
||||
);
|
||||
|
||||
if (status === 'done') return (
|
||||
<Screen>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 64, marginBottom: 12 }}>✅</div>
|
||||
<h2 style={{ color: '#15803d', margin: '0 0 8px' }}>Justificatif envoyé !</h2>
|
||||
<p style={{ color: '#374151', fontSize: 15 }}>
|
||||
Document joint à la note <strong style={{ color: '#6366f1' }}>{noteRef}</strong>
|
||||
</p>
|
||||
<p style={{ color: '#64748b', fontSize: 13 }}>Vous pouvez fermer cette page.</p>
|
||||
</div>
|
||||
</Screen>
|
||||
);
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<h2 style={{ margin: '0 0 4px', fontSize: 22, fontWeight: 800, color: '#0f172a' }}>
|
||||
📎 Justificatif
|
||||
</h2>
|
||||
<p style={{ margin: 0, fontSize: 13, color: '#64748b' }}>
|
||||
Pour <strong>{nomPrenom}</strong> — note <strong style={{ color: '#6366f1' }}>{noteRef}</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Bouton caméra arrière */}
|
||||
<label style={uploadZone('#eef2ff', '#6366f1')}>
|
||||
<span style={{ fontSize: 40, display: 'block', marginBottom: 8 }}>📷</span>
|
||||
<strong style={{ fontSize: 16, color: '#4f46e5' }}>Prendre une photo</strong>
|
||||
<span style={{ fontSize: 12, color: '#64748b', display: 'block', marginTop: 4 }}>
|
||||
Caméra arrière — ticket, facture, reçu
|
||||
</span>
|
||||
<input type="file" accept="image/*" capture="environment" multiple
|
||||
style={{ display: 'none' }} onChange={e => addFiles(e.target.files)} />
|
||||
</label>
|
||||
|
||||
{/* Bouton galerie / PDF */}
|
||||
<label style={{ ...uploadZone('#f8fafc', '#cbd5e1'), marginTop: 12 }}>
|
||||
<span style={{ fontSize: 32, display: 'block', marginBottom: 4 }}>📁</span>
|
||||
<strong style={{ fontSize: 14, color: '#374151' }}>Depuis la galerie ou PDF</strong>
|
||||
<input type="file" accept="image/*,application/pdf" multiple
|
||||
style={{ display: 'none' }} onChange={e => addFiles(e.target.files)} />
|
||||
</label>
|
||||
|
||||
{/* Aperçu fichiers */}
|
||||
{files.length > 0 && (
|
||||
<div style={{ marginTop: 16, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{files.map((f, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 14px', background: '#f0fdf4', border: '1px solid #a7f3d0', borderRadius: 12 }}>
|
||||
<span style={{ fontSize: 20 }}>{f.type === 'application/pdf' ? '📄' : '🖼️'}</span>
|
||||
<div style={{ flex: 1, overflow: 'hidden' }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: '#0f172a', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{f.name}</div>
|
||||
<div style={{ fontSize: 11, color: '#64748b' }}>
|
||||
{f.size < 1048576 ? `${(f.size / 1024).toFixed(0)} Ko` : `${(f.size / 1048576).toFixed(1)} Mo`}
|
||||
</div>
|
||||
</div>
|
||||
{f.type.startsWith('image/') && (
|
||||
<img src={URL.createObjectURL(f)} alt="" style={{ width: 48, height: 48, objectFit: 'cover', borderRadius: 8 }} />
|
||||
)}
|
||||
<button onClick={() => setFiles(files.filter((_, j) => j !== i))}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#ef4444', fontSize: 20, padding: '0 4px' }}>✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={files.length === 0 || status === 'uploading'}
|
||||
style={{
|
||||
marginTop: 24, width: '100%', padding: '16px',
|
||||
background: files.length === 0 ? '#e2e8f0' : 'linear-gradient(135deg,#6366f1,#4f46e5)',
|
||||
color: files.length === 0 ? '#94a3b8' : '#fff',
|
||||
border: 'none', borderRadius: 14, fontSize: 16, fontWeight: 700,
|
||||
cursor: files.length === 0 ? 'not-allowed' : 'pointer',
|
||||
fontFamily: '"Inter","Segoe UI",sans-serif',
|
||||
}}>
|
||||
{status === 'uploading' ? '⏳ Envoi en cours...' : `📤 Envoyer${files.length > 0 ? ` (${files.length} fichier${files.length > 1 ? 's' : ''})` : ''}`}
|
||||
</button>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
function Screen({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', background: '#f1f5f9', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20, fontFamily: '"Inter","Segoe UI",sans-serif' }}>
|
||||
<div style={{ background: '#fff', borderRadius: 24, padding: 32, maxWidth: 420, width: '100%', boxShadow: '0 8px 32px rgba(0,0,0,.12)' }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Spinner() {
|
||||
return <div style={{ width: 32, height: 32, border: '3px solid #e2e8f0', borderTop: '3px solid #6366f1', borderRadius: '50%', animation: 'spin 0.8s linear infinite', margin: '0 auto 12px' }} />;
|
||||
}
|
||||
|
||||
function uploadZone(bg: string, border: string): React.CSSProperties {
|
||||
return { display: 'block', background: bg, border: `2px dashed ${border}`, borderRadius: 16, padding: '24px 20px', textAlign: 'center', cursor: 'pointer' };
|
||||
}
|
||||
@@ -0,0 +1,859 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Eye, ChevronDown, ChevronUp, Clock, CheckCircle, XCircle, History, FileText, Car, Utensils, Package, MapPin } from 'lucide-react';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
interface Fichier { fileName: string; uploadUrl: string; }
|
||||
interface LigneDepense {
|
||||
qrNoteRef?: string; categorie?: string; libelle?: string;
|
||||
date?: string; montant?: string; km?: string; chevaux?: string;
|
||||
tauxTVA?: string; description?: string;
|
||||
nombreParticipants?: string; participants?: { nom: string; prenom: string; societe?: string }[];
|
||||
tvaItems?: { taux: string; montantTTC: string }[];
|
||||
}
|
||||
|
||||
interface NonConforme {
|
||||
fileName: string;
|
||||
motif: string;
|
||||
statut: string;
|
||||
dateSignalement: string;
|
||||
}
|
||||
|
||||
interface NoteAVerifier {
|
||||
id: number; reference?: string; libelle?: string; collaborateur?: string;
|
||||
campus?: string; departement?: string; date?: string; montant?: number;
|
||||
statut?: string; lignesJson?: string; fichiers?: string;
|
||||
nonConformes?: NonConforme[];
|
||||
}
|
||||
interface JustifState { status: 'ok' | 'nok' | 'pending'; comment?: string; }
|
||||
interface ModalNok { noteId: number; fileKey: string; fileName: string; }
|
||||
interface VerifHistorique {
|
||||
id: number; reference?: string; libelle?: string; collaborateur?: string;
|
||||
campus?: string; departement?: string; montant?: number;
|
||||
dateVerification: string; commentaire?: string;
|
||||
nbJustifs: number; nbConformes: number; nbNonConformes: number;
|
||||
lignesJson?: string; fichiers?: string;
|
||||
nonConformes?: NonConforme[];
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
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 buildKey = (noteId: number, scope: string, idx: number) => `${noteId}-${scope}-${idx}`;
|
||||
|
||||
const SYSTEME_KEYWORDS = ['soumission', 'resoumission', 'recap-paiement', 'recap', 'signe-approuve', 'signeapprouve', 'signe_approuve'];
|
||||
const isSystemFile = (f: Fichier) => SYSTEME_KEYWORDS.some(kw => (f.fileName ?? '').toLowerCase().includes(kw));
|
||||
const isApprovalFile = (f: Fichier) => {
|
||||
const n = (f.fileName ?? '').toLowerCase();
|
||||
return (n.includes('signe') || n.includes('signé')) && (n.includes('approuve') || n.includes('approuvé'));
|
||||
};
|
||||
|
||||
const getCatMeta = (cat?: string) => {
|
||||
const c = (cat || '').toLowerCase();
|
||||
if (c.includes('kilom')) return { icon: Car, color: '#7c3aed', bg: '#ede9fe', emoji: '🚗' };
|
||||
if (c.includes('repas') || c.includes('restaurant')) return { icon: Utensils, color: '#d97706', bg: '#fef3c7', emoji: '🍽️' };
|
||||
if (c.includes('transport') || c.includes('avion') || c.includes('train')) return { icon: MapPin, color: '#0369a1', bg: '#e0f2fe', emoji: '🚆' };
|
||||
if (c.includes('hebergement') || c.includes('hotel')) return { icon: Package, color: '#059669', bg: '#dcfce7', emoji: '🏨' };
|
||||
return { icon: Package, color: '#6366f1', bg: '#eef2ff', emoji: '📋' };
|
||||
};
|
||||
|
||||
interface Props {
|
||||
notesAVerifier: NoteAVerifier[];
|
||||
onVerified: (noteId: number) => void;
|
||||
API: string;
|
||||
hdrs: Record<string, string>;
|
||||
proxyUrl?: (url: string) => string;
|
||||
setPreviewUrl?: (v: { url: string; name: string }) => void;
|
||||
}
|
||||
|
||||
const CSS = `
|
||||
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500;600;700&family=DM+Mono:wght@400;500&display=swap');
|
||||
|
||||
.vf2-root {
|
||||
--purple:#5b21b6;--purple-light:#ede9fe;--purple-xlight:#f5f3ff;
|
||||
--green:#15803d;--green-light:#dcfce7;--red:#dc2626;--red-light:#fee2e2;
|
||||
--border:#e5e7eb;--muted:#6b7280;--text:#111827;--card:#ffffff;
|
||||
font-family:'DM Sans',system-ui,sans-serif;display:flex;flex-direction:column;gap:0;
|
||||
}
|
||||
.vf2-tabs{display:flex;gap:2px;background:#f3f4f6;border-radius:12px;padding:4px;margin-bottom:20px;}
|
||||
.vf2-tab{flex:1;padding:9px 16px;border:none;border-radius:9px;cursor:pointer;font-family:'DM Sans',sans-serif;font-size:13px;font-weight:600;background:transparent;color:var(--muted);display:flex;align-items:center;justify-content:center;gap:7px;transition:all .2s;}
|
||||
.vf2-tab.active{background:#fff;color:var(--purple);box-shadow:0 1px 4px rgba(0,0,0,.1);}
|
||||
.vf2-tab-badge{background:#ef4444;color:#fff;font-size:10px;font-weight:700;padding:1px 6px;border-radius:20px;}
|
||||
.vf2-tab.active .vf2-tab-badge{background:var(--purple);}
|
||||
.vf2-banner{display:flex;align-items:center;gap:14px;padding:14px 18px;background:linear-gradient(135deg,#f5f3ff,#ede9fe);border-radius:14px;border:1px solid #d8b4fe;margin-bottom:16px;}
|
||||
.vf2-banner-icon{width:40px;height:40px;border-radius:12px;background:var(--purple);display:flex;align-items:center;justify-content:center;flex-shrink:0;}
|
||||
.vf2-banner-title{font-size:13px;font-weight:700;color:var(--purple);}
|
||||
.vf2-banner-sub{font-size:12px;color:#6d28d9;margin-top:2px;}
|
||||
.vf2-banner-count{margin-left:auto;background:var(--purple);color:#fff;font-size:13px;font-weight:700;padding:4px 14px;border-radius:20px;white-space:nowrap;}
|
||||
.vf2-card{background:var(--card);border-radius:14px;border:1px solid var(--border);overflow:hidden;box-shadow:0 1px 6px rgba(0,0,0,.06);transition:box-shadow .2s;margin-bottom:12px;}
|
||||
.vf2-card:hover{box-shadow:0 4px 16px rgba(91,33,182,.1);}
|
||||
.vf2-card-trigger{width:100%;display:flex;align-items:center;padding:0;background:none;border:none;cursor:pointer;text-align:left;font-family:inherit;}
|
||||
.vf2-card-header{flex:1;padding:16px 20px;display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;}
|
||||
.vf2-card-chevron{padding:16px 18px;display:flex;align-items:center;border-left:1px solid var(--border);color:var(--muted);}
|
||||
.vf2-ref-badge{font-size:11px;font-weight:700;color:var(--purple);background:var(--purple-light);padding:2px 9px;border-radius:20px;font-family:'DM Mono',monospace;display:inline-block;margin-bottom:5px;}
|
||||
.vf2-card-title{font-size:15px;font-weight:700;color:var(--text);}
|
||||
.vf2-card-meta{font-size:12px;color:var(--muted);margin-top:3px;}
|
||||
.vf2-card-amount{font-size:22px;font-weight:800;color:var(--purple);text-align:right;}
|
||||
.vf2-card-depcount{font-size:11px;color:var(--muted);text-align:right;margin-top:2px;}
|
||||
.vf2-progress-wrap{padding:10px 20px;border-top:1px solid #f3f4f6;background:var(--purple-xlight);}
|
||||
.vf2-progress-bar{height:5px;background:#e5e7eb;border-radius:99px;overflow:hidden;margin-top:6px;}
|
||||
.vf2-progress-fill{height:100%;border-radius:99px;transition:width .4s ease;}
|
||||
.vf2-pills{display:flex;gap:6px;flex-wrap:wrap;}
|
||||
.vf2-pill{font-size:10px;font-weight:700;padding:2px 9px;border-radius:20px;white-space:nowrap;}
|
||||
.vf2-pill-ok{background:var(--green-light);color:var(--green);}
|
||||
.vf2-pill-nok{background:var(--red-light);color:var(--red);}
|
||||
.vf2-pill-wait{background:#f3f4f6;color:var(--muted);}
|
||||
.vf2-body{padding:12px 16px 16px;display:flex;flex-direction:column;gap:12px;}
|
||||
.vf2-section-label{font-size:10px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.8px;padding-bottom:6px;border-bottom:1px solid #f0f0f0;margin-bottom:8px;}
|
||||
|
||||
/* ── Bloc dépense ── */
|
||||
.vfc-bloc{border:1px solid rgba(0,0,0,.08);border-radius:10px;overflow:hidden;margin-bottom:0;}
|
||||
.vfc-bloc.all-ok{border-color:#86efac;}
|
||||
.vfc-bloc.has-nok{border-color:#fca5a5;}
|
||||
.vfc-dep-row{display:flex;align-items:center;gap:8px;padding:9px 12px;background:#f9fafb;}
|
||||
.vfc-num{width:20px;height:20px;border-radius:50%;font-size:9px;font-weight:800;display:flex;align-items:center;justify-content:center;flex-shrink:0;}
|
||||
.vfc-icon{width:28px;height:28px;border-radius:7px;display:flex;align-items:center;justify-content:center;font-size:13px;flex-shrink:0;}
|
||||
.vfc-info{flex:1;min-width:0;}
|
||||
.vfc-label{font-size:12px;font-weight:700;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||
.vfc-sub{font-size:10px;color:var(--muted);margin-top:1px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||
.vfc-price{font-size:13px;font-weight:800;flex-shrink:0;font-family:'DM Mono',monospace;}
|
||||
.vfc-badge{font-size:9px;font-weight:700;padding:2px 7px;border-radius:20px;flex-shrink:0;}
|
||||
.vfc-badge-km{background:#ede9fe;color:#7c3aed;}
|
||||
.vfc-badge-miss{background:#fee2e2;color:#dc2626;}
|
||||
|
||||
/* ── Justifs inline (toujours visibles) ── */
|
||||
.vfc-justifs{border-top:1px solid #f0f0f0;display:flex;flex-direction:column;}
|
||||
.vfc-jrow{display:flex;align-items:center;gap:7px;padding:7px 12px;border-bottom:1px solid #f5f5f5;}
|
||||
.vfc-jrow:last-child{border-bottom:none;}
|
||||
.vfc-jrow.ok{background:#f0fdf4;}
|
||||
.vfc-jrow.nok{background:#fff5f5;}
|
||||
.vfc-jrow.pending{background:#fff;}
|
||||
.vfc-jicon{font-size:14px;flex-shrink:0;}
|
||||
.vfc-jinfo{flex:1;min-width:0;}
|
||||
.vfc-jname{font-size:11px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||
.vfc-jname.ok{color:var(--green);}
|
||||
.vfc-jname.nok{color:var(--red);}
|
||||
.vfc-jname.pending{color:var(--text);}
|
||||
.vfc-jreason{font-size:10px;color:var(--red);font-style:italic;margin-top:1px;}
|
||||
.vfc-jstatus{font-size:9px;font-weight:700;padding:2px 7px;border-radius:20px;flex-shrink:0;}
|
||||
.vfc-jstatus.ok{background:var(--green-light);color:var(--green);}
|
||||
.vfc-jstatus.nok{background:var(--red-light);color:var(--red);}
|
||||
.vfc-jstatus.pending{background:#f3f4f6;color:var(--muted);}
|
||||
.vfc-no-km{font-size:11px;color:var(--green);background:var(--green-light);padding:6px 12px;font-weight:600;}
|
||||
|
||||
/* Boutons */
|
||||
.vf2-btn-voir{display:flex;align-items:center;gap:3px;padding:4px 8px;background:#f9fafb;border:1px solid #e5e7eb;border-radius:5px;cursor:pointer;font-family:inherit;font-size:10px;font-weight:600;color:#374151;flex-shrink:0;white-space:nowrap;}
|
||||
.vf2-btn-voir:hover{background:#f0f0f0;}
|
||||
.vf2-btn-check{width:26px;height:26px;border-radius:6px;flex-shrink:0;border:1.5px solid;cursor:pointer;font-size:12px;font-weight:700;display:flex;align-items:center;justify-content:center;transition:all .12s;background:none;}
|
||||
.vf2-btn-ok-on{border-color:#16a34a;background:#f0fdf4 !important;color:#16a34a;}
|
||||
.vf2-btn-ok-off{border-color:#d1d5db;color:#d1d5db;}
|
||||
.vf2-btn-nok-on{border-color:var(--red);background:#fff5f5 !important;color:var(--red);}
|
||||
.vf2-btn-nok-off{border-color:#d1d5db;color:#d1d5db;}
|
||||
|
||||
/* Approbation */
|
||||
.vf2-approval-box{border:1px solid #d8b4fe;border-radius:10px;overflow:hidden;}
|
||||
.vf2-approval-head{display:flex;align-items:center;gap:10px;padding:9px 14px;background:linear-gradient(90deg,#f5f3ff,#ede9fe);border-bottom:1px solid #e9d5ff;}
|
||||
.vf2-approval-label{font-size:12px;font-weight:700;color:var(--purple);}
|
||||
.vf2-approval-files{padding:6px 10px;display:flex;flex-direction:column;gap:4px;}
|
||||
|
||||
/* Footer */
|
||||
.vf2-footer{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px;padding:12px 16px;border-top:1.5px solid #f3f4f6;background:var(--purple-xlight);}
|
||||
.vf2-footer-msg{font-size:12px;color:var(--muted);}
|
||||
.vf2-footer-msg.ok{color:var(--green);font-weight:700;}
|
||||
.vf2-footer-msg.nok{color:var(--red);font-weight:700;}
|
||||
.vf2-btn-submit{padding:10px 20px;background:var(--purple);color:#fff;border:none;border-radius:9px;font-family:inherit;font-size:13px;font-weight:700;white-space:nowrap;box-shadow:0 4px 14px rgba(91,33,182,.3);cursor:pointer;transition:all .15s;}
|
||||
.vf2-btn-submit:hover:not(:disabled){background:#4c1d95;transform:translateY(-1px);}
|
||||
.vf2-btn-submit:disabled{opacity:.35;cursor:not-allowed;}
|
||||
|
||||
.vf2-modal-backdrop{position:fixed;inset:0;z-index:10001;background:rgba(0,0,0,.4);display:flex;align-items:center;justify-content:center;backdrop-filter:blur(4px);padding:1rem;}
|
||||
.vf2-modal{background:#fff;border-radius:16px;width:100%;max-width:460px;border:1px solid #e5e7eb;overflow:hidden;box-shadow:0 24px 64px rgba(0,0,0,.18);}
|
||||
.vf2-empty{background:#fafafa;border:1px solid var(--border);border-radius:14px;padding:64px 24px;text-align:center;}
|
||||
.vf2-hist-card{background:#fff;border:1px solid var(--border);border-radius:12px;overflow:hidden;margin-bottom:10px;box-shadow:0 1px 4px rgba(0,0,0,.05);}
|
||||
.vf2-hist-head{padding:14px 18px;display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:10px;background:#fafafa;border-bottom:1px solid #f0f0f0;}
|
||||
.vf2-hist-body{padding:12px 18px;display:flex;gap:10px;flex-wrap:wrap;align-items:center;}
|
||||
.vf2-hist-stat{display:flex;align-items:center;gap:5px;font-size:12px;font-weight:600;}
|
||||
.vf2-hist-comment{font-size:12px;color:var(--muted);font-style:italic;padding:8px 11px;background:#f9fafb;border-radius:8px;border:1px solid #f0f0f0;margin-top:6px;}
|
||||
`;
|
||||
|
||||
export default function VerificateurFinanceLight({
|
||||
notesAVerifier: initialNotes, onVerified, API, hdrs,
|
||||
proxyUrl = u => u, setPreviewUrl,
|
||||
}: Props) {
|
||||
const [notes, setNotes] = useState<NoteAVerifier[]>(initialNotes);
|
||||
const [justifStates, setJustifStates] = useState<Record<string, JustifState>>({});
|
||||
const [modalNok, setModalNok] = useState<ModalNok | null>(null);
|
||||
const [nokReason, setNokReason] = useState('');
|
||||
const [nokSending, setNokSending] = useState(false);
|
||||
const [notifyOnNok, setNotifyOnNok] = useState(true);
|
||||
const [expandedNotes, setExpandedNotes] = useState<Set<number>>(new Set());
|
||||
const [activeTab, setActiveTab] = useState<'pending' | 'history'>('pending');
|
||||
const [history, setHistory] = useState<VerifHistorique[]>([]);
|
||||
const [historyLoaded, setHistoryLoaded] = useState(false);
|
||||
const [expandedHistory, setExpandedHistory] = useState<Set<number>>(new Set());
|
||||
const toggleHistory = (id: number) => setExpandedHistory(prev => {
|
||||
const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n;
|
||||
});
|
||||
// ✅ Restaurer les états non-conformes depuis la BDD au chargement
|
||||
useEffect(() => {
|
||||
setNotes(initialNotes);
|
||||
|
||||
if (!initialNotes.length) return;
|
||||
|
||||
const newStates: Record<string, JustifState> = {};
|
||||
|
||||
for (const note of initialNotes) {
|
||||
const ncs = note.nonConformes;
|
||||
if (!ncs || ncs.length === 0) continue;
|
||||
|
||||
let tousLesFichiers: Fichier[] = [];
|
||||
try { tousLesFichiers = note.fichiers ? JSON.parse(note.fichiers as string) : []; } catch { }
|
||||
|
||||
const justificatifsReels = tousLesFichiers.filter(f =>
|
||||
!SYSTEME_KEYWORDS.some(kw => (f.fileName ?? '').toLowerCase().includes(kw))
|
||||
);
|
||||
|
||||
let lignesData: LigneDepense[] = [];
|
||||
try { if (note.lignesJson) lignesData = JSON.parse(note.lignesJson); } catch { }
|
||||
|
||||
const allQrRefs = lignesData.map(l => l.qrNoteRef).filter(Boolean) as string[];
|
||||
const fichiersGlobaux = allQrRefs.length > 0
|
||||
? justificatifsReels.filter(f => !allQrRefs.some(ref => f.fileName?.includes(ref) || f.uploadUrl?.includes(ref)))
|
||||
: justificatifsReels;
|
||||
|
||||
for (const nc of ncs) {
|
||||
// Chercher dans les fichiers globaux (distribués par ligne)
|
||||
const idxGlobal = fichiersGlobaux.findIndex(f => f.fileName === nc.fileName);
|
||||
if (idxGlobal !== -1) {
|
||||
for (let li = 0; li < lignesData.length; li++) {
|
||||
const start = Math.floor(li * fichiersGlobaux.length / lignesData.length);
|
||||
const end = Math.floor((li + 1) * fichiersGlobaux.length / lignesData.length);
|
||||
if (idxGlobal >= start && idxGlobal < end) {
|
||||
newStates[buildKey(note.id, `global${li}`, idxGlobal - start)] = { status: 'nok', comment: nc.motif };
|
||||
break;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Chercher dans les fichiers par ligne (scope ligne{li})
|
||||
for (let li = 0; li < lignesData.length; li++) {
|
||||
const qr = lignesData[li].qrNoteRef || '';
|
||||
if (!qr) continue;
|
||||
const filesLigne = justificatifsReels.filter(f =>
|
||||
f.fileName?.includes(qr) || f.uploadUrl?.includes(qr)
|
||||
);
|
||||
const idxLigne = filesLigne.findIndex(f => f.fileName === nc.fileName);
|
||||
if (idxLigne !== -1) {
|
||||
newStates[buildKey(note.id, `ligne${li}`, idxLigne)] = { status: 'nok', comment: nc.motif };
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Chercher dans les fichiers d'approbation
|
||||
const fichierApprobation = tousLesFichiers.filter(f => isApprovalFile(f));
|
||||
const idxApprob = fichierApprobation.findIndex(f => f.fileName === nc.fileName);
|
||||
if (idxApprob !== -1) {
|
||||
newStates[buildKey(note.id, 'approbation', idxApprob)] = { status: 'nok', comment: nc.motif };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(newStates).length > 0) {
|
||||
setJustifStates(prev => ({ ...prev, ...newStates }));
|
||||
}
|
||||
}, [initialNotes]);
|
||||
|
||||
const toggleNote = (id: number) => setExpandedNotes(prev => {
|
||||
const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n;
|
||||
});
|
||||
const setJustif = (key: string, status: JustifState['status'], comment?: string) =>
|
||||
setJustifStates(prev => ({ ...prev, [key]: { status, comment } }));
|
||||
const openNok = (noteId: number, fileKey: string, fileName: string) => {
|
||||
setModalNok({ noteId, fileKey, fileName });
|
||||
setNokReason(justifStates[fileKey]?.comment || '');
|
||||
};
|
||||
const loadHistory = async () => {
|
||||
if (historyLoaded) return;
|
||||
try {
|
||||
const res = await fetch(`${API}/api/verificateur/historique`, { headers: hdrs });
|
||||
if (res.ok) { const d = await res.json(); if (Array.isArray(d)) setHistory(d); }
|
||||
} catch { }
|
||||
setHistoryLoaded(true);
|
||||
};
|
||||
const handleTabChange = (tab: 'pending' | 'history') => {
|
||||
setActiveTab(tab);
|
||||
if (tab === 'history') loadHistory();
|
||||
};
|
||||
|
||||
// ── Justificatif inline toujours visible ─────────────────────────────────
|
||||
const renderJRow = (f: Fichier, stateKey: string, noteId: number) => {
|
||||
const state = justifStates[stateKey] ?? { status: 'pending' };
|
||||
const cls = state.status;
|
||||
const isImg = /\.(jpg|jpeg|png|gif|webp)$/i.test(f.fileName);
|
||||
const short = f.fileName.length > 44 ? f.fileName.slice(0, 42) + '…' : f.fileName;
|
||||
const label = cls === 'ok' ? 'Conforme' : cls === 'nok' ? 'Non conforme' : 'En attente';
|
||||
return (
|
||||
<div key={stateKey} className={`vfc-jrow ${cls}`}>
|
||||
<span className="vfc-jicon">{isImg ? '🖼️' : '📄'}</span>
|
||||
<div className="vfc-jinfo">
|
||||
<div className={`vfc-jname ${cls}`}>{short}</div>
|
||||
{cls === 'nok' && state.comment && <div className="vfc-jreason">↳ {state.comment}</div>}
|
||||
</div>
|
||||
<span className={`vfc-jstatus ${cls}`}>{label}</span>
|
||||
{setPreviewUrl && (
|
||||
<button className="vf2-btn-voir"
|
||||
onClick={() => setPreviewUrl({ url: proxyUrl(f.uploadUrl), name: f.fileName })}>
|
||||
<Eye size={11} /> Voir
|
||||
</button>
|
||||
)}
|
||||
<button className={`vf2-btn-check ${cls === 'ok' ? 'vf2-btn-ok-on' : 'vf2-btn-ok-off'}`}
|
||||
title="Conforme" onClick={() => setJustif(stateKey, 'ok')}>✓</button>
|
||||
<button className={`vf2-btn-check ${cls === 'nok' ? 'vf2-btn-nok-on' : 'vf2-btn-nok-off'}`}
|
||||
title="Non conforme" onClick={() => openNok(noteId, stateKey, f.fileName)}>✗</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Bloc dépense ─────────────────────────────────────────────────────────
|
||||
// ✅ FIX : lignesData est maintenant passé en paramètre
|
||||
const renderBloc = (
|
||||
l: LigneDepense, li: number, note: NoteAVerifier,
|
||||
justificatifsReels: Fichier[], fichiersGlobaux: Fichier[], allQrRefs: string[],
|
||||
lignesData: LigneDepense[] // ← paramètre ajouté
|
||||
) => {
|
||||
const isKm = (l.categorie || '').toLowerCase().includes('kilom');
|
||||
const km = parseFloat(l.km || '0') || 0;
|
||||
const cv = parseInt(l.chevaux || '7') || 7;
|
||||
const price = isKm ? getIndemniteKm(km, cv) : parseFloat(l.montant || '0') || 0;
|
||||
const taux = parseFloat(l.tauxTVA || '0') || 0;
|
||||
const ht = (!isKm && taux > 0) ? price / (1 + taux / 100) : null;
|
||||
const cat = getCatMeta(l.categorie);
|
||||
|
||||
const qrRef = l.qrNoteRef || '';
|
||||
|
||||
// ✅ FIX : distribution équitable des fichiers globaux entre les dépenses
|
||||
const files: Fichier[] = qrRef
|
||||
? justificatifsReels.filter(f => f.fileName?.includes(qrRef) || f.uploadUrl?.includes(qrRef))
|
||||
: (allQrRefs.length === 0
|
||||
? fichiersGlobaux.slice(
|
||||
Math.floor(li * fichiersGlobaux.length / lignesData.length),
|
||||
Math.floor((li + 1) * fichiersGlobaux.length / lignesData.length)
|
||||
)
|
||||
: []);
|
||||
|
||||
// ✅ FIX : scope différent par ligne même en mode global
|
||||
const scope = qrRef ? `ligne${li}` : `global${li}`;
|
||||
|
||||
const depOk = files.filter((_, fi) => justifStates[buildKey(note.id, scope, fi)]?.status === 'ok').length;
|
||||
const depNok = files.filter((_, fi) => justifStates[buildKey(note.id, scope, fi)]?.status === 'nok').length;
|
||||
const blockCls = depNok > 0 ? 'has-nok' : (depOk === files.length && files.length > 0 ? 'all-ok' : '');
|
||||
|
||||
const sub: string[] = [];
|
||||
if (l.categorie) sub.push(l.categorie);
|
||||
if (l.date) sub.push(fmtDate(l.date));
|
||||
if (isKm && km > 0) sub.push(`${km} km · ${cv} CV`);
|
||||
if (!isKm && taux > 0 && ht !== null) sub.push(`HT ${fmt(ht)} · TVA ${taux}%`);
|
||||
if (l.nombreParticipants) sub.push(`${l.nombreParticipants} pers.`);
|
||||
if (l.description) sub.push(l.description);
|
||||
|
||||
return (
|
||||
<div key={li} className={`vfc-bloc ${blockCls}`}>
|
||||
<div className="vfc-dep-row">
|
||||
<div className="vfc-num" style={{ background: cat.bg, color: cat.color }}>{li + 1}</div>
|
||||
<div className="vfc-icon" style={{ background: cat.bg }}>{cat.emoji}</div>
|
||||
<div className="vfc-info">
|
||||
<div className="vfc-label">{l.libelle || l.categorie || '—'}</div>
|
||||
<div className="vfc-sub">{sub.join(' · ')}</div>
|
||||
</div>
|
||||
<span className="vfc-price" style={{ color: cat.color }}>{fmt(price)}</span>
|
||||
{files.length === 0 && isKm && <span className="vfc-badge vfc-badge-km">km</span>}
|
||||
{files.length === 0 && !isKm && <span className="vfc-badge vfc-badge-miss">⚠ manquant</span>}
|
||||
</div>
|
||||
{files.length > 0 && (
|
||||
<div className="vfc-justifs">
|
||||
{files.map((f, fi) => renderJRow(f, buildKey(note.id, scope, fi), note.id))}
|
||||
</div>
|
||||
)}
|
||||
{files.length === 0 && isKm && (
|
||||
<div className="vfc-no-km">✓ Indemnité kilométrique — aucun justificatif requis</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{CSS}</style>
|
||||
<div className="vf2-root">
|
||||
{/* Tabs */}
|
||||
<div className="vf2-tabs">
|
||||
<button className={`vf2-tab ${activeTab === 'pending' ? 'active' : ''}`} onClick={() => handleTabChange('pending')}>
|
||||
<Eye size={14} /> À vérifier
|
||||
{notes.length > 0 && <span className="vf2-tab-badge">{notes.length}</span>}
|
||||
</button>
|
||||
<button className={`vf2-tab ${activeTab === 'history' ? 'active' : ''}`} onClick={() => handleTabChange('history')}>
|
||||
<History size={14} /> Historique
|
||||
{history.length > 0 && <span className="vf2-tab-badge" style={{ background: activeTab === 'history' ? '#5b21b6' : '#6b7280' }}>{history.length}</span>}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── PENDING ── */}
|
||||
{activeTab === 'pending' && (
|
||||
<>
|
||||
|
||||
|
||||
{notes.length === 0 ? (
|
||||
<div className="vf2-empty">
|
||||
<Eye size={44} color="#a78bfa" style={{ display: 'block', margin: '0 auto 14px' }} />
|
||||
<p style={{ fontSize: 14, fontWeight: 700, color: '#5b21b6', marginBottom: 6 }}>Aucune note en attente</p>
|
||||
<span style={{ fontSize: 12, color: '#6b7280' }}>Les notes approuvées apparaîtront ici</span>
|
||||
</div>
|
||||
) : notes.map(note => {
|
||||
const isOpen = expandedNotes.has(note.id);
|
||||
|
||||
let lignesData: LigneDepense[] = [];
|
||||
try { if (note.lignesJson) lignesData = JSON.parse(note.lignesJson); } catch { }
|
||||
|
||||
let tousLesFichiers: Fichier[] = [];
|
||||
try { tousLesFichiers = note.fichiers ? JSON.parse(note.fichiers as string) : []; } catch { }
|
||||
|
||||
const fichierApprobation = tousLesFichiers.filter(f => isApprovalFile(f));
|
||||
const justificatifsReels = tousLesFichiers.filter(
|
||||
f => !isSystemFile(f) && !isApprovalFile(f)
|
||||
);
|
||||
const allQrRefs = lignesData.map(l => l.qrNoteRef).filter(Boolean) as string[];
|
||||
const fichiersGlobaux = allQrRefs.length > 0
|
||||
? justificatifsReels.filter(f => !allQrRefs.some(ref => f.fileName?.includes(ref) || f.uploadUrl?.includes(ref)))
|
||||
: justificatifsReels;
|
||||
|
||||
// ✅ FIX : allKeys cohérent avec la distribution par ligne
|
||||
const allKeys: string[] = [];
|
||||
if (allQrRefs.length === 0) {
|
||||
lignesData.forEach((_, li) => {
|
||||
const start = Math.floor(li * fichiersGlobaux.length / lignesData.length);
|
||||
const end = Math.floor((li + 1) * fichiersGlobaux.length / lignesData.length);
|
||||
fichiersGlobaux.slice(start, end).forEach((_, fi) =>
|
||||
allKeys.push(buildKey(note.id, `global${li}`, fi))
|
||||
);
|
||||
});
|
||||
} else {
|
||||
lignesData.forEach((l, li) => {
|
||||
const qr = l.qrNoteRef || '';
|
||||
if (qr) {
|
||||
justificatifsReels
|
||||
.filter(f => f.fileName?.includes(qr) || f.uploadUrl?.includes(qr))
|
||||
.forEach((_, fi) => allKeys.push(buildKey(note.id, `ligne${li}`, fi)));
|
||||
}
|
||||
});
|
||||
fichiersGlobaux.forEach((_, fi) => allKeys.push(buildKey(note.id, 'global0', fi)));
|
||||
}
|
||||
fichierApprobation.forEach((_, fi) => allKeys.push(buildKey(note.id, 'approbation', fi)));
|
||||
|
||||
const okAll = allKeys.filter(k => justifStates[k]?.status === 'ok').length;
|
||||
const nokAll = allKeys.filter(k => justifStates[k]?.status === 'nok').length;
|
||||
const pendingAll = allKeys.length - okAll - nokAll;
|
||||
const pct = allKeys.length > 0 ? Math.round(((okAll + nokAll) / allKeys.length) * 100) : 0;
|
||||
const allChecked = allKeys.length > 0 && pendingAll === 0;
|
||||
|
||||
return (
|
||||
<div key={note.id} className="vf2-card">
|
||||
<button className="vf2-card-trigger" onClick={() => toggleNote(note.id)}>
|
||||
<div className="vf2-card-header">
|
||||
<div>
|
||||
<div className="vf2-ref-badge">{note.reference}</div>
|
||||
<div className="vf2-card-title">{note.libelle}</div>
|
||||
<div className="vf2-card-meta">
|
||||
{[note.collaborateur, note.campus, note.departement, fmtDate(note.date)].filter(Boolean).join(' · ')}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="vf2-card-amount">{fmt(note.montant || 0)}</div>
|
||||
<div className="vf2-card-depcount">{lignesData.length} dépense{lignesData.length > 1 ? 's' : ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="vf2-card-chevron">
|
||||
{isOpen ? <ChevronUp size={18} /> : <ChevronDown size={18} />}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{allKeys.length > 0 && (
|
||||
<div className="vf2-progress-wrap">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
|
||||
<span style={{ fontSize: 10, fontWeight: 700, color: '#6b7280', textTransform: 'uppercase', letterSpacing: '.5px' }}>
|
||||
Conformité — {pct}%
|
||||
</span>
|
||||
<div className="vf2-pills">
|
||||
{okAll > 0 && <span className="vf2-pill vf2-pill-ok">{okAll} conforme{okAll > 1 ? 's' : ''}</span>}
|
||||
{nokAll > 0 && <span className="vf2-pill vf2-pill-nok">{nokAll} non conforme{nokAll > 1 ? 's' : ''}</span>}
|
||||
{pendingAll > 0 && <span className="vf2-pill vf2-pill-wait">{pendingAll} en attente</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="vf2-progress-bar">
|
||||
<div className="vf2-progress-fill" style={{ width: `${pct}%`, background: nokAll > 0 ? '#ef4444' : '#5b21b6' }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isOpen && (
|
||||
<div className="vf2-body">
|
||||
<div>
|
||||
<div className="vf2-section-label">Dépenses & justificatifs</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{/* ✅ FIX : lignesData passé en dernier argument */}
|
||||
{lignesData.map((l, li) =>
|
||||
renderBloc(l, li, note, justificatifsReels, fichiersGlobaux, allQrRefs, lignesData)
|
||||
)}
|
||||
</div>
|
||||
{lignesData.length > 1 && (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '7px 12px', marginTop: 8, background: 'linear-gradient(90deg,#eef2ff,#f0fdf4)', border: '1px solid #c7d2fe', borderRadius: 8 }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 700, color: '#6b7280' }}>TOTAL — {lignesData.length} dépenses</span>
|
||||
<span style={{ fontSize: 15, fontWeight: 900, color: '#111827', fontFamily: 'DM Mono, monospace' }}>{fmt(note.montant || 0)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{fichierApprobation.length > 0 && (
|
||||
<div>
|
||||
<div className="vf2-section-label">Document d'approbation</div>
|
||||
<div className="vf2-approval-box">
|
||||
<div className="vf2-approval-head">
|
||||
<FileText size={15} color="#7c3aed" />
|
||||
<span className="vf2-approval-label">PDF d'approbation signé</span>
|
||||
<span style={{ marginLeft: 'auto', fontSize: 11, color: '#6b7280' }}>{fichierApprobation.length} fichier{fichierApprobation.length > 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
<div className="vf2-approval-files">
|
||||
{fichierApprobation.map((f, fi) => renderJRow(f, buildKey(note.id, 'approbation', fi), note.id))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="vf2-footer">
|
||||
<div className={`vf2-footer-msg ${allChecked ? 'ok' : nokAll > 0 ? 'nok' : ''}`}>
|
||||
{allChecked && nokAll === 0 ? '✓ Tous les justificatifs sont conformes'
|
||||
: allChecked && nokAll > 0 ? `⚠️ ${nokAll} non conforme${nokAll > 1 ? 's' : ''} — vérification possible`
|
||||
: nokAll > 0 && pendingAll > 0 ? `${nokAll} non conforme${nokAll > 1 ? 's' : ''} · ${pendingAll} en attente`
|
||||
: pendingAll > 0 ? `${pendingAll} justificatif${pendingAll > 1 ? 's' : ''} encore en attente`
|
||||
: allKeys.length === 0 ? 'Aucun justificatif à vérifier' : ''}
|
||||
</div>
|
||||
<button className="vf2-btn-submit" disabled={!allChecked && allKeys.length > 0}
|
||||
onClick={async () => {
|
||||
const commentaire = window.prompt('Commentaire de vérification (optionnel)', '') ?? '';
|
||||
if (commentaire === null) return;
|
||||
try {
|
||||
const res = await fetch(`${API}/api/verificateur/notes/${note.id}/verifier`, {
|
||||
method: 'PUT', headers: hdrs, body: JSON.stringify({ commentaire }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error);
|
||||
onVerified(note.id);
|
||||
setNotes(prev => prev.filter(n => n.id !== note.id));
|
||||
setJustifStates(prev => {
|
||||
const next = { ...prev };
|
||||
allKeys.forEach(k => delete next[k]);
|
||||
return next;
|
||||
});
|
||||
setHistoryLoaded(false);
|
||||
setHistory([]);
|
||||
setActiveTab('history');
|
||||
handleTabChange('history');
|
||||
} catch (e: any) { alert(e.message); }
|
||||
}}>
|
||||
✓ Marquer comme vérifié — Notifier Validateur Finance
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── HISTORY ── */}
|
||||
{activeTab === 'history' && (
|
||||
<div>
|
||||
<div className="vf2-banner" style={{ background: 'linear-gradient(135deg,#f0fdf4,#dcfce7)', borderColor: '#86efac' }}>
|
||||
<div className="vf2-banner-icon" style={{ background: '#15803d' }}><History size={20} color="#fff" /></div>
|
||||
<div>
|
||||
<div className="vf2-banner-title" style={{ color: '#15803d' }}>Historique des vérifications</div>
|
||||
<div className="vf2-banner-sub" style={{ color: '#166534' }}>Traçabilité complète de vos contrôles Finance</div>
|
||||
</div>
|
||||
{history.length > 0 && <div className="vf2-banner-count" style={{ background: '#15803d' }}>{history.length} vérifiée{history.length > 1 ? 's' : ''}</div>}
|
||||
</div>
|
||||
{!historyLoaded ? (
|
||||
<div style={{ textAlign: 'center', padding: '60px 24px', color: '#6b7280' }}>
|
||||
<Clock size={36} style={{ display: 'block', margin: '0 auto 12px', opacity: .4 }} />
|
||||
<p style={{ fontSize: 13 }}>Chargement de l'historique…</p>
|
||||
</div>
|
||||
) : history.length === 0 ? (
|
||||
<div className="vf2-empty">
|
||||
<History size={44} color="#a78bfa" style={{ display: 'block', margin: '0 auto 14px' }} />
|
||||
<p style={{ fontSize: 14, fontWeight: 700, color: '#5b21b6', marginBottom: 6 }}>Aucune vérification effectuée</p>
|
||||
<span style={{ fontSize: 12, color: '#6b7280' }}>Les notes vérifiées apparaîtront ici</span>
|
||||
</div>
|
||||
) : history.map((h, i) => {
|
||||
const isOpen = expandedHistory.has(h.id);
|
||||
|
||||
let lignesData: LigneDepense[] = [];
|
||||
try { if (h.lignesJson) lignesData = JSON.parse(h.lignesJson); } catch { }
|
||||
|
||||
let tousLesFichiers: Fichier[] = [];
|
||||
try { tousLesFichiers = h.fichiers ? JSON.parse(h.fichiers as string) : []; } catch { }
|
||||
|
||||
const fichierApprobation = tousLesFichiers.filter(f => isApprovalFile(f));
|
||||
const justificatifsReels = tousLesFichiers.filter(f => !isSystemFile(f) && !isApprovalFile(f));
|
||||
const allQrRefs = lignesData.map(l => l.qrNoteRef).filter(Boolean) as string[];
|
||||
const fichiersGlobaux = allQrRefs.length > 0
|
||||
? justificatifsReels.filter(f => !allQrRefs.some(ref => f.fileName?.includes(ref) || f.uploadUrl?.includes(ref)))
|
||||
: justificatifsReels;
|
||||
|
||||
return (
|
||||
<div key={i} className="vf2-hist-card">
|
||||
{/* ── Header cliquable ── */}
|
||||
<button
|
||||
onClick={() => toggleHistory(h.id)}
|
||||
style={{ width: '100%', background: 'none', border: 'none', cursor: 'pointer', textAlign: 'left', padding: 0, fontFamily: 'inherit' }}>
|
||||
<div className="vf2-hist-head">
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||
<span style={{ fontFamily: 'DM Mono,monospace', fontSize: 11, fontWeight: 700, color: '#5b21b6', background: '#ede9fe', padding: '2px 8px', borderRadius: 20 }}>{h.reference}</span>
|
||||
<CheckCircle size={13} color="#15803d" />
|
||||
<span style={{ fontSize: 11, fontWeight: 700, color: '#15803d' }}>Vérifiée</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: '#111827' }}>{h.libelle}</div>
|
||||
<div style={{ fontSize: 12, color: '#6b7280', marginTop: 2 }}>{[h.collaborateur, h.campus, h.departement].filter(Boolean).join(' · ')}</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right', display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 4 }}>
|
||||
<div style={{ fontSize: 20, fontWeight: 800, color: '#15803d' }}>{fmt(h.montant || 0)}</div>
|
||||
<div style={{ fontSize: 11, color: '#6b7280' }}>
|
||||
<Clock size={10} style={{ display: 'inline', marginRight: 4 }} />
|
||||
{new Date(h.dateVerification).toLocaleDateString('fr-FR', { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' })}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, fontSize: 11, color: '#6b7280' }}>
|
||||
{isOpen ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||
<span>{isOpen ? 'Réduire' : 'Voir les justificatifs'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* ── Stats toujours visibles ── */}
|
||||
<div className="vf2-hist-body">
|
||||
<div className="vf2-hist-stat"><FileText size={13} color="#6b7280" /><span style={{ color: '#6b7280' }}>{h.nbJustifs} justificatif{h.nbJustifs > 1 ? 's' : ''}</span></div>
|
||||
{h.nbConformes > 0 && <div className="vf2-hist-stat"><CheckCircle size={13} color="#15803d" /><span style={{ color: '#15803d' }}>{h.nbConformes} conforme{h.nbConformes > 1 ? 's' : ''}</span></div>}
|
||||
{h.nbNonConformes > 0 && <div className="vf2-hist-stat"><XCircle size={13} color="#dc2626" /><span style={{ color: '#dc2626' }}>{h.nbNonConformes} non conforme{h.nbNonConformes > 1 ? 's' : ''}</span></div>}
|
||||
{h.commentaire && <div style={{ width: '100%' }}><div className="vf2-hist-comment">💬 {h.commentaire}</div></div>}
|
||||
</div>
|
||||
|
||||
{/* ── Détail dépenses + justificatifs (accordéon) ── */}
|
||||
{isOpen && (
|
||||
<div style={{ padding: '0 16px 16px', borderTop: '1px solid #f0f0f0', display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{lignesData.length > 0 && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<div className="vf2-section-label">Dépenses & justificatifs</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{lignesData.map((l, li) => {
|
||||
const isKm = (l.categorie || '').toLowerCase().includes('kilom');
|
||||
const km = parseFloat(l.km || '0') || 0;
|
||||
const cv = parseInt(l.chevaux || '7') || 7;
|
||||
const price = isKm ? getIndemniteKm(km, cv) : parseFloat(l.montant || '0') || 0;
|
||||
const cat = getCatMeta(l.categorie);
|
||||
const qrRef = l.qrNoteRef || '';
|
||||
|
||||
const files: Fichier[] = qrRef
|
||||
? justificatifsReels.filter(f => f.fileName?.includes(qrRef) || f.uploadUrl?.includes(qrRef))
|
||||
: (allQrRefs.length === 0
|
||||
? fichiersGlobaux.slice(
|
||||
Math.floor(li * fichiersGlobaux.length / lignesData.length),
|
||||
Math.floor((li + 1) * fichiersGlobaux.length / lignesData.length)
|
||||
)
|
||||
: []);
|
||||
|
||||
const nonConformesLigne = (h.nonConformes || []).filter(nc =>
|
||||
files.some(f => f.fileName === nc.fileName)
|
||||
);
|
||||
|
||||
const sub: string[] = [];
|
||||
if (l.categorie) sub.push(l.categorie);
|
||||
if (l.date) sub.push(fmtDate(l.date));
|
||||
if (isKm && km > 0) sub.push(`${km} km · ${cv} CV`);
|
||||
if (l.nombreParticipants) sub.push(`${l.nombreParticipants} pers.`);
|
||||
if (l.description) sub.push(l.description);
|
||||
|
||||
return (
|
||||
<div key={li} className={`vfc-bloc ${nonConformesLigne.length > 0 ? 'has-nok' : files.length > 0 ? 'all-ok' : ''}`}>
|
||||
<div className="vfc-dep-row">
|
||||
<div className="vfc-num" style={{ background: cat.bg, color: cat.color }}>{li + 1}</div>
|
||||
<div className="vfc-icon" style={{ background: cat.bg }}>{cat.emoji}</div>
|
||||
<div className="vfc-info">
|
||||
<div className="vfc-label">{l.libelle || l.categorie || '—'}</div>
|
||||
<div className="vfc-sub">{sub.join(' · ')}</div>
|
||||
</div>
|
||||
<span className="vfc-price" style={{ color: cat.color }}>{fmt(price)}</span>
|
||||
{files.length === 0 && isKm && <span className="vfc-badge vfc-badge-km">km</span>}
|
||||
{files.length === 0 && !isKm && <span className="vfc-badge vfc-badge-miss">⚠ manquant</span>}
|
||||
</div>
|
||||
{files.length > 0 && (
|
||||
<div className="vfc-justifs">
|
||||
{files.map((f, fi) => {
|
||||
const nc = nonConformesLigne.find(nc => nc.fileName === f.fileName);
|
||||
const status = nc ? 'nok' : 'ok';
|
||||
const isImg = /\.(jpg|jpeg|png|gif|webp)$/i.test(f.fileName);
|
||||
const short = f.fileName.length > 44 ? f.fileName.slice(0, 42) + '…' : f.fileName;
|
||||
return (
|
||||
<div key={fi} className={`vfc-jrow ${status}`}>
|
||||
<span className="vfc-jicon">{isImg ? '🖼️' : '📄'}</span>
|
||||
<div className="vfc-jinfo">
|
||||
<div className={`vfc-jname ${status}`}>{short}</div>
|
||||
{nc && <div className="vfc-jreason">↳ {nc.motif}</div>}
|
||||
</div>
|
||||
<span className={`vfc-jstatus ${status}`}>{status === 'ok' ? 'Conforme' : 'Non conforme'}</span>
|
||||
{setPreviewUrl && (
|
||||
<button className="vf2-btn-voir"
|
||||
onClick={() => setPreviewUrl({ url: proxyUrl(f.uploadUrl), name: f.fileName })}>
|
||||
<Eye size={11} /> Voir
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{files.length === 0 && isKm && (
|
||||
<div className="vfc-no-km">✓ Indemnité kilométrique — aucun justificatif requis</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{lignesData.length > 1 && (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '7px 12px', marginTop: 8, background: 'linear-gradient(90deg,#eef2ff,#f0fdf4)', border: '1px solid #c7d2fe', borderRadius: 8 }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 700, color: '#6b7280' }}>TOTAL — {lignesData.length} dépenses</span>
|
||||
<span style={{ fontSize: 15, fontWeight: 900, color: '#111827', fontFamily: 'DM Mono, monospace' }}>{fmt(h.montant || 0)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fichierApprobation.length > 0 && (
|
||||
<div>
|
||||
<div className="vf2-section-label">Document d'approbation</div>
|
||||
<div className="vf2-approval-box">
|
||||
<div className="vf2-approval-head">
|
||||
<FileText size={15} color="#7c3aed" />
|
||||
<span className="vf2-approval-label">PDF d'approbation signé</span>
|
||||
</div>
|
||||
<div className="vf2-approval-files">
|
||||
{fichierApprobation.map((f, fi) => {
|
||||
const isImg = /\.(jpg|jpeg|png|gif|webp)$/i.test(f.fileName);
|
||||
const short = f.fileName.length > 44 ? f.fileName.slice(0, 42) + '…' : f.fileName;
|
||||
return (
|
||||
<div key={fi} className="vfc-jrow ok">
|
||||
<span className="vfc-jicon">{isImg ? '🖼️' : '📄'}</span>
|
||||
<div className="vfc-jinfo">
|
||||
<div className="vfc-jname ok">{short}</div>
|
||||
</div>
|
||||
<span className="vfc-jstatus ok">Signé</span>
|
||||
{setPreviewUrl && (
|
||||
<button className="vf2-btn-voir"
|
||||
onClick={() => setPreviewUrl({ url: proxyUrl(f.uploadUrl), name: f.fileName })}>
|
||||
<Eye size={11} /> Voir
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Modal NOK ── */}
|
||||
{modalNok && (
|
||||
<div className="vf2-modal-backdrop" onClick={() => { setModalNok(null); setNokReason(''); }}>
|
||||
<div className="vf2-modal" onClick={e => e.stopPropagation()}>
|
||||
<div style={{ padding: '16px 20px', display: 'flex', alignItems: 'center', gap: 12, borderBottom: '1px solid #fee2e2', background: '#fff5f5' }}>
|
||||
<div style={{ width: 36, height: 36, borderRadius: '50%', background: '#fee2e2', border: '1px solid #fca5a5', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 18, flexShrink: 0 }}>⚠️</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 700, fontSize: 14, color: '#991b1b' }}>Justificatif non conforme</div>
|
||||
<div style={{ fontSize: 11, color: '#6b7280', marginTop: 2 }}>Précisez le motif pour notifier le collaborateur</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: '18px 20px', display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 12px', background: '#f9fafb', border: '1px solid #e5e7eb', borderRadius: 8 }}>
|
||||
<span style={{ fontSize: 18 }}>📄</span>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, color: '#6b7280' }}>Fichier concerné</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: '#111827', marginTop: 1 }}>{modalNok.fileName}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, color: '#374151', fontWeight: 600, display: 'block', marginBottom: 6 }}>
|
||||
Motif de non-conformité <span style={{ color: '#dc2626' }}>*</span>
|
||||
</label>
|
||||
<textarea value={nokReason} onChange={e => setNokReason(e.target.value)} autoFocus
|
||||
placeholder="Ex : Justificatif illisible, montant différent, facture non conforme TVA…"
|
||||
style={{ width: '100%', minHeight: 80, resize: 'vertical', padding: '8px 10px', fontSize: 13, borderRadius: 8, border: `1.5px solid ${nokReason.trim() ? '#d1d5db' : '#fca5a5'}`, fontFamily: 'DM Sans,sans-serif', color: '#111827', background: '#fff', boxSizing: 'border-box' as const }} />
|
||||
{!nokReason.trim() && <div style={{ fontSize: 11, color: '#dc2626', marginTop: 4 }}>Le motif est obligatoire</div>}
|
||||
</div>
|
||||
<label style={{ display: 'flex', alignItems: 'flex-start', gap: 10, padding: '12px 14px', background: '#f9fafb', border: '1px solid #e5e7eb', borderRadius: 8, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={notifyOnNok} onChange={e => setNotifyOnNok(e.target.checked)} style={{ marginTop: 2, accentColor: '#dc2626', cursor: 'pointer', flexShrink: 0 }} />
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, color: '#111827', fontSize: 13 }}>Notifier par email</div>
|
||||
<div style={{ fontSize: 11, color: '#6b7280', marginTop: 2 }}>Le collaborateur et son validateur N1 recevront un email avec le motif</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ padding: '14px 20px', borderTop: '1px solid #e5e7eb', display: 'flex', justifyContent: 'flex-end', gap: 10, background: '#f9fafb' }}>
|
||||
<button onClick={() => { setModalNok(null); setNokReason(''); }}
|
||||
style={{ padding: '8px 16px', background: '#fff', border: '1px solid #d1d5db', borderRadius: 8, cursor: 'pointer', fontFamily: 'inherit', fontSize: 13, color: '#374151', fontWeight: 500 }}>
|
||||
Annuler
|
||||
</button>
|
||||
<button disabled={!nokReason.trim() || nokSending}
|
||||
onClick={async () => {
|
||||
if (!nokReason.trim() || !modalNok) return;
|
||||
setNokSending(true);
|
||||
try {
|
||||
const res = await fetch(`${API}/api/verificateur/notes/${modalNok.noteId}/non-conforme`, {
|
||||
method: 'POST',
|
||||
headers: hdrs,
|
||||
body: JSON.stringify({
|
||||
fileName: modalNok.fileName,
|
||||
motif: nokReason.trim(),
|
||||
notifier: notifyOnNok,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Erreur');
|
||||
setJustifStates(prev => ({ ...prev, [modalNok.fileKey]: { status: 'nok', comment: nokReason.trim() } }));
|
||||
setModalNok(null);
|
||||
setNokReason('');
|
||||
} catch (e: any) { alert(e.message); }
|
||||
finally { setNokSending(false); }
|
||||
}}
|
||||
style={{ padding: '8px 18px', background: nokReason.trim() && !nokSending ? '#dc2626' : '#fca5a5', color: '#fff', border: 'none', borderRadius: 8, cursor: nokReason.trim() && !nokSending ? 'pointer' : 'not-allowed', fontFamily: 'inherit', fontSize: 13, fontWeight: 700, transition: 'background .15s' }}>
|
||||
{nokSending ? 'Envoi…' : 'Confirmer non conforme'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Vendored
+14
@@ -0,0 +1,14 @@
|
||||
import * as React from 'react';
|
||||
|
||||
declare module 'react-qr-code' {
|
||||
export interface QRCodeProps extends React.SVGProps<SVGSVGElement> {
|
||||
value: string;
|
||||
size?: number;
|
||||
level?: 'L' | 'M' | 'Q' | 'H';
|
||||
bgColor?: string;
|
||||
fgColor?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
const QRCode: React.FC<QRCodeProps>;
|
||||
export default QRCode;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: { '@': path.resolve(__dirname, './src') }
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 81,
|
||||
strictPort: true,
|
||||
allowedHosts: ['myndf.ensup-adm.net', 'localhost'],
|
||||
historyApiFallback: true, // ✅ routes SPA
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://backend:3024',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
ws: true
|
||||
},
|
||||
'/proxy-file': {
|
||||
target: 'http://backend:3024',
|
||||
changeOrigin: true,
|
||||
secure: false
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": [ "ES2020", "DOM", "DOM.Iterable" ],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": [ "src" ]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": [ "vite.config.ts" ]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user