4840 lines
230 KiB
JavaScript
4840 lines
230 KiB
JavaScript
console.log('🚀 1. Démarrage du serveur...');
|
|
|
|
import express from 'express';
|
|
import cors from 'cors';
|
|
import sql from 'mssql';
|
|
import jwt from 'jsonwebtoken';
|
|
import { ConfidentialClientApplication } from '@azure/msal-node';
|
|
import axios from 'axios';
|
|
import dotenv from 'dotenv';
|
|
import crypto from 'crypto';
|
|
import multer from 'multer';
|
|
import { Client } from '@microsoft/microsoft-graph-client';
|
|
import 'isomorphic-fetch';
|
|
import PDFDocument from 'pdfkit';
|
|
import { PDFDocument as PDFLib } from 'pdf-lib';
|
|
|
|
import { generateFicheSignee, preparerLignesPDF }
|
|
from './ndfPdfGenerator.js';
|
|
|
|
console.log('✅ 2. Modules de base chargés');
|
|
|
|
dotenv.config();
|
|
console.log('✅ 3. Dotenv chargé');
|
|
|
|
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } });
|
|
|
|
const proxyCache = new Map();
|
|
const PROXY_TTL = 10 * 60 * 1000;
|
|
function getCached(url) {
|
|
const entry = proxyCache.get(url);
|
|
if (!entry) return null;
|
|
if (Date.now() - entry.at > PROXY_TTL) { proxyCache.delete(url); return null; }
|
|
return entry;
|
|
}
|
|
function setCache(url, buffer, contentType) {
|
|
if (proxyCache.size >= 50) {
|
|
const oldest = [...proxyCache.entries()].sort((a, b) => a[1].at - b[1].at)[0];
|
|
proxyCache.delete(oldest[0]);
|
|
}
|
|
proxyCache.set(url, { buffer, contentType, at: Date.now() });
|
|
}
|
|
|
|
const SHAREPOINT_CONFIG = {
|
|
siteId: process.env.SHAREPOINT_SITE_ID,
|
|
driveId: process.env.SHAREPOINT_DRIVE_ID,
|
|
basePath: 'Notes de Frais',
|
|
};
|
|
|
|
process.on('uncaughtException', (error) => {
|
|
console.error('\n❌❌❌ ERREUR NON CAPTURÉE ❌❌❌');
|
|
console.error(error);
|
|
console.error(error.stack);
|
|
});
|
|
|
|
process.on('unhandledRejection', (reason, promise) => {
|
|
console.error('\n❌❌❌ PROMESSE REJETÉE ❌❌❌');
|
|
console.error('Raison:', reason);
|
|
});
|
|
|
|
process.on('exit', (code) => {
|
|
console.log(`\n⚠️ PROCESSUS EN COURS DE TERMINAISON - CODE: ${code}\n`);
|
|
});
|
|
|
|
console.log('✅ 4. Handlers d\'erreurs installés');
|
|
|
|
const app = express();
|
|
console.log('✅ 5. Express initialisé');
|
|
|
|
const PORT = process.env.PORT || 3024;
|
|
console.log(`✅ 6. Port configuré: ${PORT}`);
|
|
|
|
app.use(cors({
|
|
origin: [
|
|
'http://myndf.ensup-adm.net',
|
|
'https://myndf.ensup-adm.net',
|
|
'http://localhost:3025',
|
|
'http://localhost:81'
|
|
],
|
|
credentials: true,
|
|
}));
|
|
app.use(express.json());
|
|
app.use(express.urlencoded({ extended: true }));
|
|
console.log('✅ 7. Middlewares installés');
|
|
|
|
const dbConfig = {
|
|
server: process.env.DB_SERVER || '192.168.0.3',
|
|
user: process.env.DB_USER || 'ndf_app',
|
|
password: process.env.DB_PASSWORD || 'P@ssw0rd2026!',
|
|
database: process.env.DB_NAME || 'NDF',
|
|
port: parseInt(process.env.DB_PORT) || 1433,
|
|
options: {
|
|
encrypt: true,
|
|
trustServerCertificate: true,
|
|
enableArithAbort: true,
|
|
connectTimeout: 60000,
|
|
requestTimeout: 60000,
|
|
useUTC: false
|
|
},
|
|
pool: { max: 10, min: 0, idleTimeoutMillis: 30000 }
|
|
};
|
|
|
|
console.log('🔄 8. Test connexion SQL Server...');
|
|
let pool;
|
|
|
|
const AZURE_CONFIG = {
|
|
tenantId: process.env.AZURE_TENANT_ID,
|
|
clientId: process.env.AZURE_CLIENT_ID,
|
|
clientSecret: process.env.AZURE_CLIENT_SECRET,
|
|
groupId: process.env.AZURE_GROUP_ID || 'c1ea877c-6bca-4f47-bfad-f223640813a0'
|
|
};
|
|
|
|
async function initializeDatabase() {
|
|
try {
|
|
pool = await sql.connect(dbConfig);
|
|
console.log('✅ 9. Connexion SQL Server réussie');
|
|
console.log(' Server:', dbConfig.server);
|
|
console.log(' User:', dbConfig.user);
|
|
console.log(' Database:', dbConfig.database);
|
|
console.log(' Port:', dbConfig.port);
|
|
} catch (err) {
|
|
console.error('❌ 9. ERREUR CONNEXION SQL SERVER:', err.message);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
initializeDatabase().catch(err => {
|
|
console.error('❌ Impossible de démarrer le serveur:', err);
|
|
process.exit(1);
|
|
});
|
|
|
|
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) {
|
|
if (process.env.NODE_ENV === 'development') console.log('[MSAL]', message);
|
|
},
|
|
piiLoggingEnabled: false,
|
|
logLevel: 'Info',
|
|
},
|
|
}
|
|
};
|
|
|
|
const BAREME_KM_SERVER = {
|
|
3: { t1: 0.529, t2_a: 0.316, t2_b: 1061, t3: 0.369 },
|
|
4: { t1: 0.606, t2_a: 0.340, t2_b: 1330, t3: 0.408 },
|
|
5: { t1: 0.636, t2_a: 0.356, t2_b: 1391, t3: 0.427 },
|
|
6: { t1: 0.665, t2_a: 0.374, t2_b: 1457, t3: 0.448 },
|
|
7: { t1: 0.697, t2_a: 0.394, t2_b: 1512, t3: 0.470 },
|
|
};
|
|
|
|
function getIndemniteKmServer(kmTotal, chevaux) {
|
|
const cv = Math.min(Math.max(parseInt(chevaux) || 7, 3), 7);
|
|
const b = BAREME_KM_SERVER[cv];
|
|
if (!b || 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));
|
|
}
|
|
|
|
function normalizeCampus(campus) {
|
|
if (!campus) return null;
|
|
const c = campus.toUpperCase();
|
|
if (c.includes('SQY') || c.includes('SAINT') || c.includes('SQUY')) return 'SQY';
|
|
if (c.includes('CGY') || c.includes('CERGY')) return 'CGY';
|
|
if (c.includes('MRS') || c.includes('MARSEILLE')) return 'MRS';
|
|
if (c.includes('NTE') || c.includes('NANTES')) return 'NTE';
|
|
return null;
|
|
}
|
|
|
|
// ── Helper getTarifKm ────────────────────────────────────────────
|
|
async function getTarifKm() {
|
|
try {
|
|
const annee = new Date().getFullYear();
|
|
const result = await pool.request()
|
|
.input('annee', sql.Int, annee)
|
|
.query(`
|
|
SELECT TOP 1 tarifParKm
|
|
FROM ParametresKm
|
|
WHERE annee = @annee AND actif = 1
|
|
ORDER BY DateCreation DESC
|
|
`);
|
|
if (result.recordset[0]?.tarifParKm) {
|
|
return parseFloat(result.recordset[0].tarifParKm);
|
|
}
|
|
return 0.697; // Fallback 7 CV+
|
|
} catch (e) {
|
|
console.warn('⚠️ getTarifKm fallback 0.697:', e.message);
|
|
return 0.697;
|
|
}
|
|
}
|
|
|
|
async function getConfigDebiteur() {
|
|
try {
|
|
const result = await pool.request().query(`
|
|
SELECT TOP 1 companyName, companyIban, companyBic,
|
|
companyAddress, companyCp, companyVille, companyPays
|
|
FROM ConfigDebiteurXML
|
|
WHERE actif = 1
|
|
ORDER BY DateModification DESC
|
|
`);
|
|
if (result.recordset.length) return result.recordset[0];
|
|
} catch (e) {
|
|
console.warn('⚠️ getConfigDebiteur fallback .env:', e.message);
|
|
}
|
|
// Fallback .env si table inaccessible
|
|
return {
|
|
companyName: process.env.COMPANY_NAME || 'ENSUP GROUP',
|
|
companyIban: process.env.COMPANY_IBAN || 'FR0000000000000000000000000',
|
|
companyBic: process.env.COMPANY_BIC || 'BNPAFRPP',
|
|
companyAddress: process.env.COMPANY_ADDRESS || '',
|
|
companyCp: process.env.COMPANY_CP || '',
|
|
companyVille: process.env.COMPANY_VILLE || '',
|
|
companyPays: process.env.COMPANY_PAYS || 'FR',
|
|
};
|
|
}
|
|
// ── Helpers IBAN ─────────────────────────────────────────────────
|
|
// ── Helpers IBAN ─────────────────────────────────────────────────
|
|
function getIbanKey() {
|
|
const key = process.env.IBAN_ENCRYPTION_KEY;
|
|
if (!key) throw new Error('IBAN_ENCRYPTION_KEY manquante');
|
|
const buf = Buffer.from(key, 'hex');
|
|
if (buf.length !== 32) throw new Error(`IBAN_ENCRYPTION_KEY invalide: ${buf.length} octets (attendu: 32)`);
|
|
return buf;
|
|
}
|
|
|
|
function encryptIban(iban) {
|
|
const iv = crypto.randomBytes(12);
|
|
const cipher = crypto.createCipheriv('aes-256-gcm', getIbanKey(), iv);
|
|
const encrypted = Buffer.concat([cipher.update(iban, 'utf8'), cipher.final()]);
|
|
const tag = cipher.getAuthTag();
|
|
return `${iv.toString('hex')}:${tag.toString('hex')}:${encrypted.toString('hex')}`;
|
|
}
|
|
|
|
function decryptIban(stored) {
|
|
const [ivHex, tagHex, encHex] = stored.split(':');
|
|
const decipher = crypto.createDecipheriv('aes-256-gcm', getIbanKey(), Buffer.from(ivHex, 'hex'));
|
|
decipher.setAuthTag(Buffer.from(tagHex, 'hex'));
|
|
return decipher.update(Buffer.from(encHex, 'hex')) + decipher.final('utf8');
|
|
}
|
|
|
|
function validateIban(iban) {
|
|
const lengths = { FR: 27, BE: 16, DE: 22, ES: 24, IT: 27 };
|
|
const country = iban.slice(0, 2);
|
|
if (lengths[country] && iban.length !== lengths[country]) return false;
|
|
const rearranged = iban.slice(4) + iban.slice(0, 4);
|
|
const numeric = rearranged.split('').map(c => isNaN(c) ? (c.charCodeAt(0) - 55).toString() : c).join('');
|
|
let remainder = 0;
|
|
for (const chunk of numeric.match(/.{1,9}/g)) {
|
|
remainder = parseInt(remainder + chunk) % 97;
|
|
}
|
|
return remainder === 1;
|
|
}
|
|
|
|
function maskIban(iban) {
|
|
if (!iban || iban.length < 8) return iban;
|
|
return iban.slice(0, 4) + ' ' +
|
|
iban.slice(4, -4).replace(/./g, '*').match(/.{1,4}/g).join(' ') +
|
|
' ' + iban.slice(-4);
|
|
}
|
|
|
|
function hashIban(iban) {
|
|
return crypto.createHash('sha256').update(iban + process.env.IBAN_HASH_SALT).digest('hex');
|
|
}
|
|
// ✅ NOUVEAU — Charge les rôles depuis UtilisateurRoles (remplace CollaborateurAD.role)
|
|
async function getRolesForUser(collaborateurId) {
|
|
try {
|
|
const result = await pool.request()
|
|
.input('id', sql.Int, collaborateurId)
|
|
.query(`
|
|
SELECT role
|
|
FROM UtilisateurRoles
|
|
WHERE collaborateur_id = @id AND actif = 1
|
|
`);
|
|
return result.recordset.map(r => r.role);
|
|
// Retourne ex: ['Collaboratrice', 'Finance'] ou ['superUtilisateur']
|
|
} catch (e) {
|
|
console.warn('⚠️ getRolesForUser erreur:', e.message);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function formatDateParis(date) {
|
|
if (!date) return '—';
|
|
const d = new Date(date);
|
|
return d.toLocaleString('fr-FR', {
|
|
timeZone: 'Europe/Paris',
|
|
day: '2-digit', month: '2-digit', year: 'numeric',
|
|
hour: '2-digit', minute: '2-digit'
|
|
});
|
|
}
|
|
|
|
const cca = new ConfidentialClientApplication(msalConfig);
|
|
console.log('✅ 10. MSAL configuré');
|
|
|
|
// ================================================
|
|
// 🔑 TOKEN MICROSOFT GRAPH
|
|
// ================================================
|
|
async function getGraphToken() {
|
|
try {
|
|
console.log('🔑 Tentative d\'obtention du token...');
|
|
console.log(' Tenant ID:', AZURE_CONFIG.tenantId ? '✅' : '❌ MANQUANT');
|
|
console.log(' Client ID:', AZURE_CONFIG.clientId ? '✅' : '❌ MANQUANT');
|
|
console.log(' Client Secret:', AZURE_CONFIG.clientSecret ? '✅' : '❌ MANQUANT');
|
|
|
|
if (!AZURE_CONFIG.tenantId || !AZURE_CONFIG.clientId || !AZURE_CONFIG.clientSecret) {
|
|
throw new Error('Configuration Azure incomplète - vérifiez votre fichier .env');
|
|
}
|
|
|
|
const params = new URLSearchParams({
|
|
grant_type: 'client_credentials',
|
|
client_id: AZURE_CONFIG.clientId,
|
|
client_secret: AZURE_CONFIG.clientSecret,
|
|
scope: 'https://graph.microsoft.com/.default'
|
|
});
|
|
|
|
const response = await axios.post(
|
|
`https://login.microsoftonline.com/${AZURE_CONFIG.tenantId}/oauth2/v2.0/token`,
|
|
params.toString(),
|
|
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
|
|
);
|
|
|
|
console.log('✅ Token obtenu avec succès');
|
|
return response.data.access_token;
|
|
} catch (error) {
|
|
console.error('❌ Erreur obtention token:', error.message);
|
|
if (error.response) {
|
|
console.error(' Status HTTP:', error.response.status);
|
|
console.error(' Erreur détaillée:', JSON.stringify(error.response.data, null, 2));
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ================================================
|
|
// 🔄 SYNCHRONISATION ENTRA ID
|
|
// ================================================
|
|
async function syncEntraIdUsers() {
|
|
const syncResults = { processed: 0, inserted: 0, updated: 0, deactivated: 0, errors: [] };
|
|
|
|
try {
|
|
console.log('\n🔄 === DÉBUT SYNCHRONISATION ENTRA ID ===');
|
|
|
|
const accessToken = await getGraphToken();
|
|
if (!accessToken) { console.error('❌ Impossible d\'obtenir le token'); return syncResults; }
|
|
console.log('✅ Token obtenu');
|
|
|
|
const groupResponse = await axios.get(
|
|
`https://graph.microsoft.com/v1.0/groups/${AZURE_CONFIG.groupId}?$select=id,displayName`,
|
|
{ headers: { Authorization: `Bearer ${accessToken}` } }
|
|
);
|
|
const groupName = groupResponse.data.displayName;
|
|
console.log(`📋 Groupe : ${groupName}`);
|
|
|
|
let allAzureMembers = [];
|
|
let nextLink = `https://graph.microsoft.com/v1.0/groups/${AZURE_CONFIG.groupId}/members?$select=id,givenName,surname,mail,department,jobTitle,officeLocation,accountEnabled&$top=999`;
|
|
|
|
console.log('📥 Récupération des membres...');
|
|
while (nextLink) {
|
|
const membersResponse = await axios.get(nextLink, { headers: { Authorization: `Bearer ${accessToken}` } });
|
|
allAzureMembers = allAzureMembers.concat(membersResponse.data.value);
|
|
nextLink = membersResponse.data['@odata.nextLink'];
|
|
if (nextLink) console.log(` 📄 ${allAzureMembers.length} membres récupérés...`);
|
|
}
|
|
|
|
console.log(`✅ ${allAzureMembers.length} membres trouvés`);
|
|
|
|
const validMembers = allAzureMembers.filter(m => {
|
|
if (!m.mail || m.mail.trim() === '') return false;
|
|
if (m.accountEnabled === false) return false;
|
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(m.mail);
|
|
});
|
|
|
|
console.log(`✅ ${validMembers.length} membres valides`);
|
|
|
|
const transaction = new sql.Transaction(pool);
|
|
await transaction.begin();
|
|
|
|
try {
|
|
const azureEmails = new Set();
|
|
validMembers.forEach(m => azureEmails.add(m.mail.toLowerCase().trim()));
|
|
|
|
console.log('\n📝 Traitement des utilisateurs...');
|
|
|
|
for (const m of validMembers) {
|
|
try {
|
|
const emailClean = m.mail.toLowerCase().trim();
|
|
syncResults.processed++;
|
|
|
|
const request = new sql.Request(transaction);
|
|
request.input('email', sql.NVarChar, emailClean);
|
|
const result = await request.query(`
|
|
SELECT id, email, entraUserId, Actif FROM CollaborateurAD WHERE LOWER(email) = LOWER(@email)
|
|
`);
|
|
|
|
if (result.recordset.length > 0) {
|
|
const updateRequest = new sql.Request(transaction);
|
|
updateRequest.input('entraUserId', sql.NVarChar, m.id);
|
|
updateRequest.input('prenom', sql.NVarChar, m.givenName || '');
|
|
updateRequest.input('nom', sql.NVarChar, m.surname || '');
|
|
updateRequest.input('departement', sql.NVarChar, m.department || '');
|
|
updateRequest.input('fonction', sql.NVarChar, m.jobTitle || '');
|
|
updateRequest.input('campus', sql.NVarChar, m.officeLocation || '');
|
|
updateRequest.input('email', sql.NVarChar, emailClean);
|
|
await updateRequest.query(`
|
|
UPDATE CollaborateurAD SET
|
|
entraUserId = @entraUserId, prenom = @prenom, nom = @nom,
|
|
departement = @departement, fonction = @fonction, campus = @campus,
|
|
Actif = 1, dateMiseAJour = GETDATE(), DateModification = GETDATE()
|
|
WHERE LOWER(email) = LOWER(@email)
|
|
`);
|
|
syncResults.updated++;
|
|
console.log(` ✓ Mis à jour : ${emailClean}`);
|
|
} else {
|
|
const insertRequest = new sql.Request(transaction);
|
|
insertRequest.input('entraUserId', sql.NVarChar, m.id);
|
|
insertRequest.input('prenom', sql.NVarChar, m.givenName || '');
|
|
insertRequest.input('nom', sql.NVarChar, m.surname || '');
|
|
insertRequest.input('email', sql.NVarChar, emailClean);
|
|
insertRequest.input('departement', sql.NVarChar, m.department || '');
|
|
insertRequest.input('fonction', sql.NVarChar, m.jobTitle || '');
|
|
insertRequest.input('campus', sql.NVarChar, m.officeLocation || '');
|
|
await insertRequest.query(`
|
|
INSERT INTO CollaborateurAD
|
|
(entraUserId, prenom, nom, email, departement, fonction, campus, role, service, Actif, DateCreation, DateModification, dateMiseAJour)
|
|
VALUES (@entraUserId, @prenom, @nom, @email, @departement, @fonction, @campus, 'Collaborateur', NULL, 1, GETDATE(), GETDATE(), GETDATE())
|
|
`);
|
|
syncResults.inserted++;
|
|
console.log(` ✓ Créé : ${emailClean}`);
|
|
}
|
|
} catch (userError) {
|
|
syncResults.errors.push({ email: m.mail, error: userError.message });
|
|
console.error(` ❌ Erreur ${m.mail}:`, userError.message);
|
|
}
|
|
}
|
|
|
|
console.log('\n🔍 Désactivation des comptes obsolètes...');
|
|
if (azureEmails.size > 0) {
|
|
const activeEmailsList = Array.from(azureEmails).map(e => `'${e}'`).join(',');
|
|
const deactivateRequest = new sql.Request(transaction);
|
|
const deactivateResult = await deactivateRequest.query(`
|
|
UPDATE CollaborateurAD SET Actif = 0, DateModification = GETDATE()
|
|
WHERE email IS NOT NULL AND email != ''
|
|
AND LOWER(email) NOT IN (${activeEmailsList})
|
|
AND (Actif = 1 OR Actif IS NULL)
|
|
`);
|
|
syncResults.deactivated = deactivateResult.rowsAffected[0];
|
|
console.log(` ✓ ${syncResults.deactivated} compte(s) désactivé(s)`);
|
|
}
|
|
|
|
await transaction.commit();
|
|
|
|
console.log('\n📊 === RÉSUMÉ ===');
|
|
console.log(` Groupe: ${groupName}`);
|
|
console.log(` Total Entra: ${allAzureMembers.length}`);
|
|
console.log(` Valides: ${validMembers.length}`);
|
|
console.log(` Traités: ${syncResults.processed}`);
|
|
console.log(` Créés: ${syncResults.inserted}`);
|
|
console.log(` Mis à jour: ${syncResults.updated}`);
|
|
console.log(` Désactivés: ${syncResults.deactivated}`);
|
|
console.log(` Erreurs: ${syncResults.errors.length}`);
|
|
|
|
} catch (error) {
|
|
await transaction.rollback();
|
|
throw error;
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('\n❌ ERREUR SYNCHRONISATION:', error.message);
|
|
}
|
|
|
|
return syncResults;
|
|
}
|
|
|
|
// ================================================
|
|
// MIDDLEWARE JWT
|
|
// ================================================
|
|
const authenticateToken = (req, res, next) => {
|
|
const authHeader = req.headers['authorization'];
|
|
const token = authHeader && authHeader.split(' ')[1];
|
|
if (!token) return res.status(401).json({ error: 'Token manquant' });
|
|
try {
|
|
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
|
req.user = decoded;
|
|
next();
|
|
} catch (err) {
|
|
return res.status(403).json({ error: 'Token invalide ou expiré' });
|
|
}
|
|
};
|
|
|
|
// requireRole gère les rôles multiples (tableau req.user.roles)
|
|
const requireRole = (...rolesRequis) => (req, res, next) => {
|
|
if (!req.user) return res.status(401).json({ error: 'Non authentifié' });
|
|
const hasRole = req.user.roles && req.user.roles.some(r => rolesRequis.includes(r));
|
|
if (!hasRole) {
|
|
return res.status(403).json({ error: `Accès refusé — rôle(s) requis : ${rolesRequis.join(' ou ')}` });
|
|
}
|
|
next();
|
|
};
|
|
|
|
// Helper : vérifie si l'utilisateur possède un rôle parmi une liste
|
|
const hasAnyRole = (user, ...roles) => user.roles && user.roles.some(r => roles.includes(r));
|
|
|
|
// ================================================
|
|
// ROUTES DE BASE
|
|
// ================================================
|
|
app.get('/', (req, res) => {
|
|
res.json({ message: 'API Gestion des Notes de Frais', version: '1.0.0', status: 'OK' });
|
|
});
|
|
|
|
app.get('/users-dev', async (req, res) => {
|
|
try {
|
|
const result = await pool.request().query(`
|
|
SELECT id, email, nom, prenom, role, Actif FROM CollaborateurAD
|
|
WHERE Actif = 1 OR Actif IS NULL ORDER BY nom, prenom
|
|
`);
|
|
res.json(result.recordset);
|
|
} catch (error) {
|
|
console.error('❌ Erreur /users-dev:', error);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// ✅ MODIFIÉ — login-dev avec rôles depuis UtilisateurRoles
|
|
app.post('/login-dev', async (req, res) => {
|
|
try {
|
|
const { accessToken } = req.body;
|
|
if (!accessToken) return res.status(400).json({ error: 'Token d\'accès manquant' });
|
|
|
|
let userInfo;
|
|
try {
|
|
const graphResponse = await fetch('https://graph.microsoft.com/v1.0/me', {
|
|
headers: { 'Authorization': `Bearer ${accessToken}` }
|
|
});
|
|
if (!graphResponse.ok) throw new Error('Token invalide');
|
|
userInfo = await graphResponse.json();
|
|
} catch (graphError) {
|
|
return res.status(401).json({ error: 'Token d\'accès invalide', details: graphError.message });
|
|
}
|
|
|
|
const userEmail = userInfo.mail || userInfo.userPrincipalName;
|
|
const result = await pool.request()
|
|
.input('email', sql.VarChar, userEmail)
|
|
.query(`SELECT * FROM CollaborateurAD WHERE email = @email AND (Actif = 1 OR Actif IS NULL)`);
|
|
|
|
if (!result.recordset.length)
|
|
return res.status(404).json({ error: 'Utilisateur non trouvé ou compte désactivé' });
|
|
|
|
const user = result.recordset[0];
|
|
|
|
// ✅ Rôles depuis UtilisateurRoles au lieu de CollaborateurAD.role
|
|
const userRoles = await getRolesForUser(user.id);
|
|
if (!userRoles.length)
|
|
return res.status(403).json({ error: 'Aucun rôle assigné dans UtilisateurRoles' });
|
|
|
|
const rolesAutorises = [
|
|
'Collaborateur', 'Collaboratrice',
|
|
'Validateur', 'Validatrice',
|
|
'Finance', 'VerificateurFinance', 'ValidateurFinance',
|
|
'superUtilisateur'
|
|
];
|
|
const hasAccess = userRoles.some(r => rolesAutorises.includes(r));
|
|
if (!hasAccess)
|
|
return res.status(403).json({ error: `Vos rôles n'ont pas accès à cette application` });
|
|
|
|
const token = jwt.sign(
|
|
{
|
|
id: user.id,
|
|
email: user.email,
|
|
roles: userRoles,
|
|
nom: user.nom,
|
|
prenom: user.prenom,
|
|
societe: user.societe,
|
|
campus: normalizeCampus(user.campus),
|
|
},
|
|
process.env.JWT_SECRET,
|
|
{ expiresIn: '8h' }
|
|
);
|
|
|
|
res.json({
|
|
token,
|
|
user: {
|
|
id: user.id,
|
|
nom: user.nom,
|
|
prenom: user.prenom,
|
|
email: user.email,
|
|
roles: userRoles,
|
|
societe: user.societe,
|
|
campus: user.campus,
|
|
}
|
|
});
|
|
} catch (error) {
|
|
res.status(500).json({ error: 'Erreur serveur inattendue', details: error.message });
|
|
}
|
|
});
|
|
|
|
app.get('/auth/verify', authenticateToken, (req, res) => {
|
|
res.json({ valid: true, user: req.user });
|
|
});
|
|
|
|
app.post('/auth/logout', authenticateToken, (req, res) => {
|
|
res.json({ success: true, message: 'Déconnexion réussie' });
|
|
});
|
|
|
|
const pkceStore = new Map();
|
|
|
|
app.get('/api/auth/microsoft', (req, res) => {
|
|
const tenantId = process.env.AZURE_TENANT_ID;
|
|
const clientId = process.env.AZURE_CLIENT_ID;
|
|
const state = Math.random().toString(36).substring(2, 15);
|
|
const codeVerifier = crypto.randomBytes(32).toString('base64url');
|
|
const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url');
|
|
pkceStore.set(state, codeVerifier);
|
|
setTimeout(() => pkceStore.delete(state), 10 * 60 * 1000);
|
|
|
|
// ✅ Détecte automatiquement l'URL d'origine
|
|
const redirectUri = process.env.OAUTH_REDIRECT_URI || 'https://myndf.ensup-adm.net/api/auth/callback';
|
|
|
|
console.log('🔗 Redirect URI:', redirectUri);
|
|
|
|
const params = new URLSearchParams({
|
|
client_id: clientId,
|
|
response_type: 'code',
|
|
redirect_uri: redirectUri,
|
|
response_mode: 'query',
|
|
scope: 'openid profile email User.Read',
|
|
state,
|
|
prompt: 'select_account',
|
|
code_challenge: codeChallenge,
|
|
code_challenge_method: 'S256'
|
|
});
|
|
|
|
res.redirect(`https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize?${params.toString()}`);
|
|
});
|
|
|
|
|
|
// ✅ MODIFIÉ — auth/callback avec rôles depuis UtilisateurRoles
|
|
app.get('/api/auth/callback', async (req, res) => {
|
|
const { code, error, state } = req.query;
|
|
if (error) return res.redirect(`/login?error=${error}&desc=${encodeURIComponent(req.query.error_description || '')}`);
|
|
if (!code) return res.redirect(`/login?error=no_code`);
|
|
|
|
const codeVerifier = pkceStore.get(state);
|
|
if (!codeVerifier) return res.redirect(`/login?error=invalid_state`);
|
|
pkceStore.delete(state);
|
|
|
|
try {
|
|
// ✅ Détecte automatiquement l'URL d'origine (même logique que /api/auth/microsoft)
|
|
const redirectUri = process.env.OAUTH_REDIRECT_URI || 'https://myndf.ensup-adm.net/api/auth/callback';
|
|
|
|
console.log('🔗 Callback Redirect URI:', redirectUri);
|
|
|
|
const tokenResponse = await axios.post(
|
|
`https://login.microsoftonline.com/${AZURE_CONFIG.tenantId}/oauth2/v2.0/token`,
|
|
new URLSearchParams({
|
|
client_id: AZURE_CONFIG.clientId,
|
|
client_secret: AZURE_CONFIG.clientSecret,
|
|
code,
|
|
redirect_uri: redirectUri, // ✅ URI détectée automatiquement
|
|
grant_type: 'authorization_code',
|
|
code_verifier: codeVerifier
|
|
}),
|
|
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
|
|
);
|
|
|
|
const accessToken = tokenResponse.data.access_token;
|
|
const userResponse = await axios.get('https://graph.microsoft.com/v1.0/me', {
|
|
headers: { Authorization: `Bearer ${accessToken}` }
|
|
});
|
|
|
|
const userEmail = userResponse.data.mail || userResponse.data.userPrincipalName;
|
|
const result = await pool.request()
|
|
.input('email', sql.VarChar, userEmail)
|
|
.query(`SELECT * FROM CollaborateurAD WHERE email = @email AND (Actif = 1 OR Actif IS NULL)`);
|
|
|
|
if (!result.recordset.length) return res.redirect(`/login?error=user_not_found`);
|
|
|
|
const user = result.recordset[0];
|
|
|
|
// ✅ Rôles depuis UtilisateurRoles
|
|
const userRoles = await getRolesForUser(user.id);
|
|
if (!userRoles.length)
|
|
return res.redirect(`/login?error=no_role_assigned`);
|
|
|
|
const rolesAutorises = ['Collaborateur', 'Collaboratrice', 'Validateur', 'Validatrice', 'Finance', 'VerificateurFinance', 'ValidateurFinance', , 'superUtilisateur'];
|
|
const hasAccess = userRoles.some(r => rolesAutorises.includes(r));
|
|
if (!hasAccess) return res.redirect(`${process.env.FRONTEND_URL || 'http://localhost:3025'}/login?error=unauthorized_role`);
|
|
|
|
|
|
const jwtToken = jwt.sign(
|
|
{
|
|
id: user.id,
|
|
email: user.email,
|
|
roles: userRoles,
|
|
nom: user.nom,
|
|
prenom: user.prenom,
|
|
societe: user.societe,
|
|
campus: normalizeCampus(user.campus),
|
|
},
|
|
process.env.JWT_SECRET,
|
|
{ expiresIn: '8h' }
|
|
);
|
|
|
|
// ✅ Redirection vers le frontend avec le token
|
|
res.redirect(`${process.env.FRONTEND_URL || 'http://localhost:3025'}/auth/callback?token=${jwtToken}`);
|
|
|
|
} catch (error) {
|
|
console.error('❌ Erreur callback OAuth:', error.message);
|
|
if (error.response?.data) {
|
|
console.error('Détails:', JSON.stringify(error.response.data, null, 2));
|
|
}
|
|
res.redirect(`${process.env.FRONTEND_URL || 'http://localhost:3025'}/login?error=auth_failed&details=${encodeURIComponent(error.message)}`);
|
|
|
|
}
|
|
});
|
|
|
|
// GET /api/verificateur/notes — notes approuvées à vérifier
|
|
app.get('/api/verificateur/notes', authenticateToken, async (req, res) => {
|
|
if (!hasAnyRole(req.user, 'VerificateurFinance', 'Finance', 'superUtilisateur'))
|
|
return res.status(403).json({ error: 'Accès réservé VerificateurFinance' });
|
|
|
|
try {
|
|
const request = pool.request();
|
|
let campusWhere = '';
|
|
if (req.user.campus && !hasAnyRole(req.user, 'superUtilisateur')) {
|
|
const campusCode = normalizeCampus(req.user.campus);
|
|
if (campusCode) {
|
|
request.input('campus', sql.NVarChar, `%${campusCode}%`);
|
|
campusWhere = `AND c.campus LIKE @campus`;
|
|
}
|
|
}
|
|
|
|
const result = await request.query(`
|
|
SELECT n.*,
|
|
c.nom + ' ' + c.prenom AS collaborateur,
|
|
c.email AS collaborateurEmail,
|
|
c.departement, c.campus, c.societe,
|
|
v1.nom + ' ' + v1.prenom AS nomN1,
|
|
v2.nom + ' ' + v2.prenom AS nomN2,
|
|
vf.nom + ' ' + vf.prenom AS nomVerificateur,
|
|
n.dateVerification, n.commentaireVerification
|
|
FROM NoteDeFrais n
|
|
JOIN CollaborateurAD c ON c.id = n.collaborateurId
|
|
LEFT JOIN CollaborateurAD v1 ON v1.id = n.validateurN1Id
|
|
LEFT JOIN CollaborateurAD v2 ON v2.id = n.validateurN2Id
|
|
LEFT JOIN CollaborateurAD vf ON vf.id = n.verificateurFinanceId
|
|
WHERE n.statut = 'approuve'
|
|
${campusWhere}
|
|
ORDER BY n.DateCreation DESC
|
|
`);
|
|
|
|
const notes = result.recordset;
|
|
for (const note of notes) {
|
|
const ncResult = await pool.request()
|
|
.input('noteId', sql.Int, note.id)
|
|
.query(`
|
|
SELECT fileName, motif, statut, dateSignalement
|
|
FROM JustificatifsNonConformes
|
|
WHERE noteDeFraisId = @noteId
|
|
ORDER BY dateSignalement DESC
|
|
`);
|
|
note.nonConformes = ncResult.recordset;
|
|
}
|
|
|
|
res.json(notes);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
// PUT /api/verificateur/notes/:id/verifier
|
|
app.put('/api/verificateur/notes/:id/verifier', authenticateToken, async (req, res) => {
|
|
if (!hasAnyRole(req.user, 'VerificateurFinance', 'Finance', 'superUtilisateur'))
|
|
return res.status(403).json({ error: 'Accès réservé VerificateurFinance' });
|
|
|
|
const { commentaire } = req.body;
|
|
const noteId = parseInt(req.params.id);
|
|
|
|
try {
|
|
// Récupérer la note
|
|
const noteResult = await pool.request()
|
|
.input('id', sql.Int, noteId)
|
|
.query(`
|
|
SELECT n.*, c.prenom, c.nom, c.email, c.campus
|
|
FROM NoteDeFrais n
|
|
JOIN CollaborateurAD c ON c.id = n.collaborateurId
|
|
WHERE n.id = @id AND n.statut = 'approuve'
|
|
`);
|
|
|
|
if (!noteResult.recordset.length)
|
|
return res.status(404).json({ error: 'Note introuvable ou statut incompatible' });
|
|
|
|
const note = noteResult.recordset[0];
|
|
|
|
// Marquer comme vérifiée
|
|
await pool.request()
|
|
.input('id', sql.Int, noteId)
|
|
.input('verificateurId', sql.Int, req.user.id)
|
|
.input('commentaire', sql.NVarChar, commentaire || null)
|
|
.query(`
|
|
UPDATE NoteDeFrais SET
|
|
statut = 'verifie',
|
|
verificateurFinanceId = @verificateurId,
|
|
dateVerification = GETDATE(),
|
|
commentaireVerification = @commentaire,
|
|
DateModification = GETDATE()
|
|
WHERE id = @id
|
|
`);
|
|
|
|
// Historique
|
|
await pool.request()
|
|
.input('noteId', sql.Int, noteId)
|
|
.input('validateurId', sql.Int, req.user.id)
|
|
.input('action', sql.NVarChar, 'verifier')
|
|
.input('commentaire', sql.NVarChar, commentaire || null)
|
|
.input('statut', sql.NVarChar, 'verifie')
|
|
.query(`
|
|
INSERT INTO HistoriqueValidation
|
|
(NoteDeFraisId, ValidateurId, Niveau, Action, Commentaire, NouveauStatut, DateAction)
|
|
VALUES
|
|
(@noteId, @validateurId, 'VERIF', @action, @commentaire, @statut, GETDATE())
|
|
`);
|
|
|
|
// Trouver les ValidateurFinance du même campus pour les notifier
|
|
const campusNorm = normalizeCampus(note.campus);
|
|
const validRequest = pool.request()
|
|
.input('campus', sql.NVarChar, campusNorm ? `%${campusNorm}%` : '%');
|
|
const validateurs = await validRequest.query(`
|
|
SELECT c.id, c.email, c.prenom, c.nom
|
|
FROM CollaborateurAD c
|
|
JOIN UtilisateurRoles r ON r.collaborateur_id = c.id
|
|
WHERE r.role = 'ValidateurFinance' AND r.actif = 1
|
|
AND c.campus LIKE @campus AND c.Actif = 1
|
|
`);
|
|
|
|
const verificateurNom = `${req.user.prenom} ${req.user.nom}`;
|
|
const montantFormate = parseFloat(note.montant).toFixed(2);
|
|
const frontendUrl = process.env.FRONTEND_URL || 'https://myndf.ensup-adm.net';
|
|
|
|
for (const val of validateurs.recordset) {
|
|
// Notification BDD
|
|
try {
|
|
await creerNotification({
|
|
destinataireId: val.id,
|
|
destinataireEmail: val.email,
|
|
type: 'paiement',
|
|
titre: `✅ Note vérifiée à valider — ${note.reference}`,
|
|
message: `${verificateurNom} a vérifié la note ${note.reference} (${montantFormate} €) de ${note.prenom} ${note.nom}. En attente de votre validation de paiement.`,
|
|
noteId: noteId
|
|
});
|
|
} catch (e) { console.error('Notif ValidateurFinance:', e.message); }
|
|
|
|
// Email
|
|
try {
|
|
await sendMailGraph(
|
|
val.email,
|
|
`✅ Note vérifiée — validation paiement requise : ${note.reference}`,
|
|
`<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto">
|
|
<div style="background:linear-gradient(135deg,#7c3aed,#6d28d9);color:white;padding:24px;border-radius:12px 12px 0 0">
|
|
<h2 style="margin:0">✅ Note vérifiée — paiement à valider</h2>
|
|
</div>
|
|
<div style="padding:24px;background:#f8fafc;border:1px solid #e2e8f0;border-top:none;border-radius:0 0 12px 12px">
|
|
<p>Bonjour <strong>${val.prenom} ${val.nom}</strong>,</p>
|
|
<p>La note <strong>${note.reference}</strong> de <strong>${note.prenom} ${note.nom}</strong> (${montantFormate} €)
|
|
a été vérifiée par <strong>${verificateurNom}</strong> et est prête pour le paiement.</p>
|
|
${commentaire ? `<p style="background:#f1f5f9;padding:12px;border-radius:8px;border-left:4px solid #7c3aed">
|
|
💬 Commentaire vérificateur : ${commentaire}</p>` : ''}
|
|
<div style="background:#f0fdf4;border:1px solid #86efac;border-radius:8px;padding:16px;margin:16px 0">
|
|
<table style="width:100%;border-collapse:collapse">
|
|
<tr><td style="color:#64748b;font-size:13px">Référence</td><td style="font-weight:700">${note.reference}</td></tr>
|
|
<tr><td style="color:#64748b;font-size:13px">Collaborateur</td><td>${note.prenom} ${note.nom}</td></tr>
|
|
<tr><td style="color:#64748b;font-size:13px">Montant</td><td style="font-weight:800;color:#15803d">${montantFormate} €</td></tr>
|
|
<tr><td style="color:#64748b;font-size:13px">Campus</td><td>${note.campus || '—'}</td></tr>
|
|
</table>
|
|
</div>
|
|
<div style="text-align:center;margin-top:24px">
|
|
<a href="${frontendUrl}" style="background:linear-gradient(135deg,#7c3aed,#6d28d9);color:white;padding:14px 32px;text-decoration:none;border-radius:8px;font-weight:700;display:inline-block">
|
|
Valider le paiement →
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>`
|
|
);
|
|
} catch (e) { console.error('Email ValidateurFinance:', e.message); }
|
|
}
|
|
|
|
// Notifier aussi le collaborateur
|
|
try {
|
|
await creerNotification({
|
|
destinataireId: note.collaborateurId,
|
|
destinataireEmail: note.email,
|
|
type: 'paiement',
|
|
titre: `Note ${note.reference} en cours de traitement`,
|
|
message: `Votre note ${note.reference} (${montantFormate} €) a été vérifiée et est en attente de validation du paiement.`,
|
|
noteId: noteId
|
|
});
|
|
} catch (e) { console.error('Notif collab vérification:', e.message); }
|
|
|
|
res.json({ success: true, statut: 'verifie', notifiesCount: validateurs.recordset.length });
|
|
|
|
} catch (error) {
|
|
console.error('Erreur PUT verificateur/notes/:id/verifier:', error.message);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// GET /api/verificateur/historique
|
|
app.get('/api/verificateur/historique', authenticateToken, async (req, res) => {
|
|
if (!hasAnyRole(req.user, 'VerificateurFinance', 'Finance', 'superUtilisateur'))
|
|
return res.status(403).json({ error: 'Accès refusé' });
|
|
|
|
try {
|
|
const request = pool.request().input('verificateurId', sql.Int, req.user.id);
|
|
|
|
let campusWhere = '';
|
|
if (req.user.campus && !hasAnyRole(req.user, 'superUtilisateur')) {
|
|
const campusCode = normalizeCampus(req.user.campus);
|
|
if (campusCode) {
|
|
request.input('campus', sql.NVarChar, `%${campusCode}%`);
|
|
campusWhere = 'AND c.campus LIKE @campus';
|
|
}
|
|
}
|
|
|
|
const result = await request.query(`
|
|
SELECT
|
|
n.id, n.reference, n.libelle, n.montant,
|
|
n.dateVerification, n.commentaireVerification,
|
|
n.lignesJson, n.fichiers,
|
|
c.nom + ' ' + c.prenom AS collaborateur,
|
|
c.campus, c.departement,
|
|
(SELECT COUNT(*) FROM JustificatifsNonConformes j
|
|
WHERE j.noteDeFraisId = n.id) AS nbNonConformes
|
|
FROM NoteDeFrais n
|
|
JOIN CollaborateurAD c ON c.id = n.collaborateurId
|
|
WHERE n.verificateurFinanceId = @verificateurId
|
|
AND n.statut IN ('verifie', 'paiementenattente', 'payee')
|
|
${campusWhere}
|
|
ORDER BY n.dateVerification DESC
|
|
`);
|
|
|
|
const notesAvecNC = await Promise.all(result.recordset.map(async row => {
|
|
const ncResult = await pool.request()
|
|
.input('noteId', sql.Int, row.id)
|
|
.query(`
|
|
SELECT fileName, motif, statut, dateSignalement
|
|
FROM JustificatifsNonConformes
|
|
WHERE noteDeFraisId = @noteId
|
|
ORDER BY dateSignalement DESC
|
|
`);
|
|
|
|
const nonConformes = ncResult.recordset;
|
|
|
|
let nbJustifs = 0;
|
|
try {
|
|
const fichiers = JSON.parse(row.fichiers || '[]');
|
|
const SYSTEME = ['soumission', 'resoumission', 'recap-paiement', 'recap', 'signe-approuve', 'signeapprouve', 'signe_approuve'];
|
|
nbJustifs = fichiers.filter(f =>
|
|
!SYSTEME.some(kw => (f.fileName || '').toLowerCase().includes(kw))
|
|
).length;
|
|
} catch { }
|
|
|
|
const nbNonConformes = nonConformes.length;
|
|
const nbConformes = Math.max(0, nbJustifs - nbNonConformes);
|
|
|
|
return {
|
|
...row,
|
|
nbJustifs,
|
|
nbConformes,
|
|
nbNonConformes,
|
|
nonConformes,
|
|
dateVerification: row.dateVerification,
|
|
commentaire: row.commentaireVerification,
|
|
};
|
|
}));
|
|
|
|
res.json(notesAvecNC);
|
|
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
// ================================================
|
|
// SYNC ENTRA — réservé Finance
|
|
// ================================================
|
|
app.post('/api/sync-entra', authenticateToken, async (req, res) => {
|
|
if (!hasAnyRole(req.user, 'Finance')) return res.status(403).json({ success: false, message: 'Accès refusé - réservé à la Finance' });
|
|
const results = await syncEntraIdUsers();
|
|
res.json({ success: true, message: 'Synchronisation terminée', stats: results });
|
|
});
|
|
|
|
app.get('/api/sync-status', authenticateToken, async (req, res) => {
|
|
try {
|
|
const totalDB = await pool.request().query(`
|
|
SELECT COUNT(*) as total,
|
|
SUM(CASE WHEN Actif = 1 THEN 1 ELSE 0 END) as actifs,
|
|
SUM(CASE WHEN Actif = 0 THEN 1 ELSE 0 END) as inactifs
|
|
FROM CollaborateurAD
|
|
`);
|
|
const derniers = await pool.request().query(`
|
|
SELECT TOP 10 id, prenom, nom, email, role, Actif, dateCreation, dateMiseAJour
|
|
FROM CollaborateurAD ORDER BY dateMiseAJour DESC
|
|
`);
|
|
let entraStatus = { connected: false };
|
|
try {
|
|
const token = await getGraphToken();
|
|
if (token) {
|
|
const groupResponse = await axios.get(
|
|
`https://graph.microsoft.com/v1.0/groups/${AZURE_CONFIG.groupId}?$select=id,displayName`,
|
|
{ headers: { Authorization: `Bearer ${token}` } }
|
|
);
|
|
entraStatus = { connected: true, groupName: groupResponse.data.displayName, groupId: AZURE_CONFIG.groupId };
|
|
}
|
|
} catch (err) { entraStatus.error = err.message; }
|
|
|
|
res.json({
|
|
success: true,
|
|
database: totalDB.recordset[0],
|
|
entraId: entraStatus,
|
|
derniers_utilisateurs: derniers.recordset
|
|
});
|
|
} catch (error) {
|
|
res.status(500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
// ── GET tarif km actif ─────────────────────────────────────────────
|
|
app.get('/api/parametres/km', authenticateToken, async (req, res) => {
|
|
try {
|
|
const result = await pool.request().query(`
|
|
SELECT TOP 1 tarifParKm
|
|
FROM ParametresKm
|
|
WHERE actif = 1
|
|
ORDER BY annee DESC
|
|
`);
|
|
const tarif = result.recordset[0]?.tarifParKm ?? 0.697;
|
|
res.json({ tarifKm: tarif });
|
|
} catch (e) {
|
|
res.status(500).json({ error: e.message });
|
|
}
|
|
});
|
|
|
|
// ── GET taux TVA actifs ────────────────────────────────────────────
|
|
app.get('/api/parametres/tva', authenticateToken, async (req, res) => {
|
|
try {
|
|
const result = await pool.request().query(`
|
|
SELECT taux, libelle, categorie
|
|
FROM ParametresTVA
|
|
WHERE actif = 1
|
|
AND dateDebut <= GETDATE()
|
|
AND (dateFin IS NULL OR dateFin >= GETDATE())
|
|
ORDER BY taux ASC
|
|
`);
|
|
res.json(result.recordset);
|
|
} catch (e) {
|
|
res.status(500).json({ error: e.message });
|
|
}
|
|
});
|
|
|
|
// ── PUT tarif km — réservé Finance ────────────────────────────────
|
|
app.put('/api/parametres/km', authenticateToken, requireRole('Finance'), async (req, res) => {
|
|
const { tarifParKm, annee } = req.body;
|
|
if (!tarifParKm || isNaN(tarifParKm))
|
|
return res.status(400).json({ error: 'Tarif invalide' });
|
|
try {
|
|
await pool.request().query(`UPDATE ParametresKm SET actif = 0 WHERE actif = 1`);
|
|
await pool.request()
|
|
.input('tarif', sql.Decimal(10, 4), parseFloat(tarifParKm))
|
|
.input('annee', sql.Int, annee || new Date().getFullYear())
|
|
.query(`INSERT INTO ParametresKm (tarifParKm, annee, actif, DateCreation) VALUES (@tarif, @annee, 1, GETDATE())`);
|
|
res.json({ success: true, tarifParKm: parseFloat(tarifParKm) });
|
|
} catch (e) {
|
|
res.status(500).json({ error: e.message });
|
|
}
|
|
});
|
|
|
|
app.get('/api/parametres/bareme-km', authenticateToken, (req, res) => {
|
|
res.json({
|
|
baremes: [
|
|
{ chevaux: 3, label: '3 CV et moins', t1: 0.529, t2_a: 0.316, t2_b: 1061, t3: 0.369 },
|
|
{ chevaux: 4, label: '4 CV', t1: 0.606, t2_a: 0.340, t2_b: 1330, t3: 0.408 },
|
|
{ chevaux: 5, label: '5 CV', t1: 0.636, t2_a: 0.356, t2_b: 1391, t3: 0.427 },
|
|
{ chevaux: 6, label: '6 CV', t1: 0.665, t2_a: 0.374, t2_b: 1457, t3: 0.448 },
|
|
{ chevaux: 7, label: '7 CV et plus', t1: 0.697, t2_a: 0.394, t2_b: 1512, t3: 0.470 },
|
|
]
|
|
});
|
|
});
|
|
|
|
// ✅ MODIFIÉ — profil expose les rôles depuis UtilisateurRoles
|
|
app.get('/api/profile', authenticateToken, async (req, res) => {
|
|
try {
|
|
const result = await pool.request()
|
|
.input('id', sql.Int, req.user.id)
|
|
.query(`
|
|
SELECT id, nom, prenom, email, role, fonction as poste,
|
|
TypeContrat, DateEntree, campus, departement, societe,
|
|
adresse_rue, adresse_cp, adresse_ville, adresse_pays
|
|
FROM CollaborateurAD WHERE id = @id
|
|
`);
|
|
if (!result.recordset.length) return res.status(404).json({ error: 'Profil non trouvé' });
|
|
const userProfile = result.recordset[0];
|
|
|
|
// ✅ Rôles depuis UtilisateurRoles au lieu de CollaborateurAD.role
|
|
const rolesDB = await getRolesForUser(req.user.id);
|
|
userProfile.roles = rolesDB.length > 0 ? rolesDB : ['Collaborateur'];
|
|
|
|
res.json(userProfile);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
app.put('/api/profile/adresse', authenticateToken, async (req, res) => {
|
|
try {
|
|
const { adresse_rue, adresse_cp, adresse_ville, adresse_pays, societe } = req.body;
|
|
|
|
if (!adresse_rue || !adresse_cp || !adresse_ville || !adresse_pays)
|
|
return res.status(400).json({ error: 'Tous les champs adresse sont obligatoires' });
|
|
|
|
await pool.request()
|
|
.input('id', sql.Int, req.user.id)
|
|
.input('adresse_rue', sql.NVarChar, adresse_rue.trim())
|
|
.input('adresse_cp', sql.NVarChar, adresse_cp.trim())
|
|
.input('adresse_ville', sql.NVarChar, adresse_ville.trim())
|
|
.input('adresse_pays', sql.NVarChar, adresse_pays.trim())
|
|
.input('societe', sql.NVarChar, societe ? societe.trim() : null)
|
|
.query(`
|
|
UPDATE CollaborateurAD SET
|
|
adresse_rue = @adresse_rue,
|
|
adresse_cp = @adresse_cp,
|
|
adresse_ville = @adresse_ville,
|
|
adresse_pays = @adresse_pays,
|
|
societe = COALESCE(@societe, societe),
|
|
DateModification = GETDATE()
|
|
WHERE id = @id
|
|
`);
|
|
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error('PUT /api/profile/adresse :', error.message);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// ================================================
|
|
// HELPERS
|
|
// ================================================
|
|
async function genererReference(campus, nom, prenom) {
|
|
const now = new Date();
|
|
const annee = now.getFullYear();
|
|
const mois = String(now.getMonth() + 1).padStart(2, '0');
|
|
|
|
// Normaliser le campus
|
|
const campusCode = normalizeCampus(campus) || 'XXX';
|
|
|
|
// Construire la partie nom : NOM.P (première lettre du prénom)
|
|
const nomClean = (nom || '').toUpperCase()
|
|
.normalize('NFD').replace(/[\u0300-\u036f]/g, '') // supprimer accents
|
|
.replace(/[^A-Z]/g, ''); // garder uniquement lettres
|
|
const prenomInitiale = (prenom || '').charAt(0).toUpperCase()
|
|
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
|
|
.replace(/[^A-Z]/g, '');
|
|
|
|
const nomPart = `${nomClean}.${prenomInitiale}`;
|
|
|
|
const tx = new sql.Transaction(pool);
|
|
await tx.begin();
|
|
try {
|
|
await new sql.Request(tx).query(`
|
|
IF NOT EXISTS (SELECT 1 FROM NDFSequence WHERE annee = ${annee})
|
|
INSERT INTO NDFSequence (annee, compteur) VALUES (${annee}, 0)
|
|
`);
|
|
const result = await new sql.Request(tx).query(`
|
|
UPDATE NDFSequence SET compteur = compteur + 1 OUTPUT INSERTED.compteur WHERE annee = ${annee}
|
|
`);
|
|
await tx.commit();
|
|
const num = String(result.recordset[0].compteur).padStart(3, '0');
|
|
return `NDF-${annee}-${mois}-${campusCode}-${nomPart}`;
|
|
} catch (e) { await tx.rollback(); throw e; }
|
|
}
|
|
|
|
async function uploadToSharePoint(file, ndfReference, collaborateurNomPrenom) {
|
|
const accessToken = await getGraphToken();
|
|
if (!accessToken) throw new Error('Token Graph indisponible');
|
|
const safeName = file.originalname.replace(/[^a-zA-Z0-9._\-]/g, '_');
|
|
const fileName = `${ndfReference}_${safeName}`;
|
|
const folderPath = `${SHAREPOINT_CONFIG.basePath}/${collaborateurNomPrenom}`;
|
|
const uploadPath = `${folderPath}/${fileName}`;
|
|
|
|
const res = await axios.put(
|
|
`https://graph.microsoft.com/v1.0/sites/${SHAREPOINT_CONFIG.siteId}/drives/${SHAREPOINT_CONFIG.driveId}/root:/${uploadPath}:/content`,
|
|
file.buffer,
|
|
{ headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': file.mimetype } }
|
|
);
|
|
return { fileName, uploadUrl: res.data.webUrl };
|
|
}
|
|
|
|
async function downloadFromSharePoint(webUrl) {
|
|
const accessToken = await getGraphToken();
|
|
const urlObj = new URL(webUrl);
|
|
const fullPath = decodeURIComponent(urlObj.pathname);
|
|
const marker = '/Shared Documents/';
|
|
const markerAlt = '/Documents/';
|
|
let relativePath = '';
|
|
if (fullPath.includes(marker)) relativePath = fullPath.split(marker)[1];
|
|
else if (fullPath.includes(markerAlt)) relativePath = fullPath.split(markerAlt)[1];
|
|
else {
|
|
const parts = fullPath.split('/sites/')[1]?.split('/');
|
|
relativePath = parts?.slice(2).join('/') || '';
|
|
}
|
|
const res = await axios.get(
|
|
`https://graph.microsoft.com/v1.0/sites/${SHAREPOINT_CONFIG.siteId}/drives/${SHAREPOINT_CONFIG.driveId}/root:/${relativePath}:/content`,
|
|
{ headers: { Authorization: `Bearer ${accessToken}` }, responseType: 'arraybuffer' }
|
|
);
|
|
return Buffer.from(res.data);
|
|
}
|
|
|
|
async function uploadToSharePointHierarchique(file, noteRef, nomDossier, moisDossier) {
|
|
const accessToken = await getGraphToken();
|
|
if (!accessToken) throw new Error('Token Graph indisponible');
|
|
const safeName = (file.originalname || 'fichier').replace(/[^a-zA-Z0-9._\-]/g, '_');
|
|
const fileName = safeName.startsWith(noteRef) ? safeName : `${noteRef}_${safeName}`;
|
|
const folderPath = `${SHAREPOINT_CONFIG.basePath}/${nomDossier}/${moisDossier}/${noteRef}`;
|
|
const uploadPath = `${folderPath}/${fileName}`;
|
|
|
|
const res = await axios.put(
|
|
`https://graph.microsoft.com/v1.0/sites/${SHAREPOINT_CONFIG.siteId}/drives/${SHAREPOINT_CONFIG.driveId}/root:/${uploadPath}:/content`,
|
|
file.buffer,
|
|
{
|
|
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': file.mimetype || 'application/octet-stream' },
|
|
maxBodyLength: Infinity, maxContentLength: Infinity
|
|
}
|
|
);
|
|
return { fileName, uploadUrl: res.data.webUrl, folderPath };
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════
|
|
// 📎 RÉCAP COMPLET (fiche PDF/A + justificatifs fusionnés)
|
|
// ══════════════════════════════════════════════════════
|
|
async function generateRecapWithJustifs(noteData, justifFiles, signaturesOpt) {
|
|
const signatures = signaturesOpt || [
|
|
{ niveau: 'COLLAB', nomPrenom: noteData.nomPrenom || '', date: new Date(), action: 'soumettre', commentaire: null }
|
|
];
|
|
const ficheBuffer = await generateFicheSignee(noteData, signatures);
|
|
|
|
const finalPdf = await PDFLib.create();
|
|
const fichePdf = await PDFLib.load(ficheBuffer);
|
|
const fichePages = await finalPdf.copyPages(fichePdf, fichePdf.getPageIndices());
|
|
fichePages.forEach(p => finalPdf.addPage(p));
|
|
|
|
for (const file of justifFiles) {
|
|
const mimetype = file.mimetype || '';
|
|
if (mimetype === 'application/pdf') {
|
|
try {
|
|
const justifPdf = await PDFLib.load(file.buffer);
|
|
const pages = await finalPdf.copyPages(justifPdf, justifPdf.getPageIndices());
|
|
pages.forEach(p => finalPdf.addPage(p));
|
|
} catch (e) { console.warn(`⚠️ PDF non intégrable: ${file.originalname} — ${e.message}`); }
|
|
} else if (mimetype.startsWith('image/')) {
|
|
try {
|
|
const page = finalPdf.addPage([595, 842]);
|
|
const img = mimetype === 'image/png' ? await finalPdf.embedPng(file.buffer) : await finalPdf.embedJpg(file.buffer);
|
|
const maxW = 495, maxH = 742;
|
|
const ratio = Math.min(maxW / img.width, maxH / img.height);
|
|
const w = img.width * ratio, h = img.height * ratio;
|
|
page.drawImage(img, { x: (595 - w) / 2, y: (842 - h) / 2, width: w, height: h });
|
|
} catch (e) { console.warn(`⚠️ Image non intégrable: ${file.originalname} — ${e.message}`); }
|
|
}
|
|
}
|
|
|
|
return Buffer.from(await finalPdf.save());
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════
|
|
// POST /api/notes — Créer une note de frais (multi-lignes)
|
|
// ══════════════════════════════════════════════════════
|
|
app.post('/api/notes', authenticateToken, upload.any(), async (req, res) => {
|
|
try {
|
|
const { libelle, date, description, participants, nombreParticipants, lignes, qrNoteRef } = req.body;
|
|
|
|
if (!libelle || !date || !lignes)
|
|
return res.status(400).json({ error: 'Libellé, date et lignes sont obligatoires' });
|
|
|
|
let lignesParsed;
|
|
try {
|
|
lignesParsed = typeof lignes === 'string' ? JSON.parse(lignes) : lignes;
|
|
} catch {
|
|
return res.status(400).json({ error: 'Format des lignes invalide' });
|
|
}
|
|
if (!Array.isArray(lignesParsed) || lignesParsed.length === 0)
|
|
return res.status(400).json({ error: 'Au moins une ligne est obligatoire' });
|
|
|
|
let tarifKm = await getTarifKm();
|
|
try {
|
|
const annee = new Date().getFullYear();
|
|
const kmParam = await pool.request()
|
|
.input('annee', sql.Int, annee)
|
|
.query(`SELECT TOP 1 tarifParKm FROM ParametresKm WHERE annee = @annee AND actif = 1 ORDER BY DateCreation DESC`);
|
|
if (kmParam.recordset[0]?.tarifParKm) tarifKm = parseFloat(kmParam.recordset[0].tarifParKm);
|
|
} catch { }
|
|
|
|
const lignesPDF = preparerLignesPDF(lignesParsed, tarifKm);
|
|
const montantTTC = lignesPDF.reduce((s, l) => s + l.montantTTC, 0);
|
|
const indemKm = lignesPDF.reduce((s, l) => s + l.indemniteKm, 0);
|
|
const kmTotal = lignesPDF.reduce((s, l) => s + (l.km || 0), 0);
|
|
const montantFinal = parseFloat((montantTTC + indemKm).toFixed(2));
|
|
const montantFormate = montantFinal.toFixed(2);
|
|
const isKmOnly = lignesPDF.every(l => l.montantTTC === 0 && l.indemniteKm > 0);
|
|
console.log('🔍 isKmOnly:', isKmOnly, 'kmTotal:', kmTotal, 'montantTTC:', montantTTC);
|
|
|
|
const categorieNote = isKmOnly ? 'Kilométrique pur' : 'Multiple';
|
|
|
|
// POST /api/notes — ligne ~420
|
|
const collabResult = await pool.request()
|
|
.input('id', sql.Int, req.user.id)
|
|
.query(`SELECT id, email, prenom, nom, departement, campus FROM CollaborateurAD WHERE id = @id`);
|
|
if (!collabResult.recordset.length) return res.status(404).json({ error: 'Collaborateur non trouvé' });
|
|
const collaborateur = collabResult.recordset[0];
|
|
const nomPrenom = `${collaborateur.nom.toUpperCase()} ${collaborateur.prenom}`;
|
|
|
|
const dateObj = new Date(date);
|
|
const moisStr = dateObj.toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' });
|
|
const moisCapitalized = moisStr.charAt(0).toUpperCase() + moisStr.slice(1);
|
|
|
|
const hierarchie = await pool.request()
|
|
.input('collabId', sql.Int, req.user.id)
|
|
.query(`
|
|
SELECT h.SuperieurId, h.[SuperieurIdn+2],
|
|
s1.email AS emailN1, s1.prenom AS prenomN1, s1.nom AS nomN1,
|
|
s2.email AS emailN2
|
|
FROM HierarchieValidationNDF h
|
|
LEFT JOIN CollaborateurAD s1 ON s1.id = h.SuperieurId
|
|
LEFT JOIN CollaborateurAD s2 ON s2.id = h.[SuperieurIdn+2]
|
|
WHERE h.CollaborateurId = @collabId
|
|
`);
|
|
const n1Id = hierarchie.recordset[0]?.SuperieurId ?? null;
|
|
const n2Id = hierarchie.recordset[0]?.['SuperieurIdn+2'] ?? null;
|
|
const emailN1 = hierarchie.recordset[0]?.emailN1 ?? null;
|
|
const prenomN1 = hierarchie.recordset[0]?.prenomN1 ?? null;
|
|
const nomN1 = hierarchie.recordset[0]?.nomN1 ?? null;
|
|
|
|
const reference = await genererReference(collaborateur.campus, collaborateur.nom, collaborateur.prenom);
|
|
|
|
const nomDossier = `${collaborateur.prenom}_${collaborateur.nom}`.replace(/\s+/g, '_');
|
|
const now = new Date();
|
|
const moisDossier = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
|
|
|
|
const allFiles = [...(req.files || [])];
|
|
if (qrNoteRef) {
|
|
const qrToken = await pool.request()
|
|
.input('noteRef', sql.NVarChar, qrNoteRef)
|
|
.query(`SELECT TOP 1 fichiers FROM UploadTokens WHERE noteRef = @noteRef AND used = 1 ORDER BY expiresAt DESC`);
|
|
if (qrToken.recordset.length && qrToken.recordset[0].fichiers) {
|
|
const qrFichiers = JSON.parse(qrToken.recordset[0].fichiers);
|
|
for (const f of qrFichiers) {
|
|
try {
|
|
const buf = await downloadFromSharePoint(f.uploadUrl);
|
|
allFiles.push({ buffer: buf, originalname: f.fileName, mimetype: f.fileName?.endsWith('.pdf') ? 'application/pdf' : 'image/jpeg', size: buf.length });
|
|
} catch (e) { console.warn('⚠️ QR global download fail:', e.message); }
|
|
}
|
|
}
|
|
}
|
|
|
|
// ✅ QR par ligne — récupère et STOCKE les fichiers dans qrFiles de chaque ligne
|
|
for (let i = 0; i < lignesParsed.length; i++) {
|
|
const ligneQrRef = lignesParsed[i].qrNoteRef;
|
|
if (!ligneQrRef) continue;
|
|
try {
|
|
const qrLigne = await pool.request()
|
|
.input('noteRef', sql.NVarChar, ligneQrRef)
|
|
.query(`SELECT TOP 1 fichiers FROM UploadTokens WHERE noteRef = @noteRef AND used = 1 ORDER BY expiresAt DESC`);
|
|
if (qrLigne.recordset.length && qrLigne.recordset[0].fichiers) {
|
|
const qrFichiers = JSON.parse(qrLigne.recordset[0].fichiers);
|
|
|
|
// ✅ Initialiser qrFiles pour cette ligne
|
|
if (!lignesParsed[i].qrFiles) lignesParsed[i].qrFiles = [];
|
|
|
|
for (const f of qrFichiers) {
|
|
try {
|
|
const buf = await downloadFromSharePoint(f.uploadUrl);
|
|
const fileObj = {
|
|
buffer: buf,
|
|
originalname: f.fileName,
|
|
mimetype: f.fileName?.endsWith('.pdf') ? 'application/pdf' : 'image/jpeg',
|
|
size: buf.length
|
|
};
|
|
|
|
// Ajouter à allFiles pour le récap PDF global
|
|
allFiles.push(fileObj);
|
|
|
|
// ✅ Uploader vers SP avec la référence finale et stocker dans qrFiles
|
|
try {
|
|
const uploaded = await uploadToSharePointHierarchique(
|
|
fileObj, reference, nomDossier, moisDossier
|
|
);
|
|
// Éviter les doublons
|
|
const dejaSauve = lignesParsed[i].qrFiles.some(x => x.fileName === uploaded.fileName);
|
|
if (!dejaSauve) {
|
|
lignesParsed[i].qrFiles.push({
|
|
fileName: uploaded.fileName,
|
|
uploadUrl: uploaded.uploadUrl
|
|
});
|
|
}
|
|
console.log(`✅ QR ligne ${i} stocké dans qrFiles: ${uploaded.fileName}`);
|
|
} catch (uploadErr) {
|
|
console.warn(`⚠️ Upload SP ligne ${i}:`, uploadErr.message);
|
|
}
|
|
} catch (e) { console.warn(`⚠️ QR ligne ${i} download fail:`, e.message); }
|
|
}
|
|
} else {
|
|
console.warn(`⚠️ QR ligne ${i} (${ligneQrRef}) : token introuvable ou non utilisé`);
|
|
}
|
|
} catch (e) {
|
|
console.warn(`⚠️ QR ligne ${i} erreur DB:`, e.message);
|
|
}
|
|
}
|
|
|
|
// ✅ Recalculer lignesJson APRÈS enrichissement des qrFiles
|
|
const lignesJsonFinal = JSON.stringify(lignesParsed);
|
|
|
|
const fichiersUploades = [];
|
|
for (const file of allFiles) {
|
|
try {
|
|
const r = await uploadToSharePointHierarchique(file, reference, nomDossier, moisDossier);
|
|
fichiersUploades.push(r);
|
|
} catch (e) { console.error(`❌ Upload justif ${file.originalname}:`, e.message); }
|
|
}
|
|
|
|
const noteDataPDF = {
|
|
reference,
|
|
nomPrenom,
|
|
mois: moisCapitalized,
|
|
date,
|
|
categorie: categorieNote,
|
|
libelle,
|
|
montant: montantFinal,
|
|
lignes: lignesParsed,
|
|
lignesJson: JSON.stringify(lignesParsed),
|
|
tarifKm: await getTarifKm(),
|
|
statut: 'enattente',
|
|
departement: collaborateur.departement,
|
|
participants: participants || null,
|
|
};
|
|
|
|
let ficheResult = null;
|
|
try {
|
|
const fichePDF = await generateFicheSignee(
|
|
noteDataPDF,
|
|
[{ niveau: 'COLLAB', nomPrenom, date: new Date(), action: 'soumettre', commentaire: null }]
|
|
);
|
|
ficheResult = await uploadToSharePointHierarchique(
|
|
{ buffer: fichePDF, originalname: `${reference}_soumission.pdf`, mimetype: 'application/pdf', size: fichePDF.length },
|
|
reference, nomDossier, moisDossier
|
|
);
|
|
fichiersUploades.push(ficheResult);
|
|
console.log('✅ Fiche soumission uploadée:', ficheResult.fileName);
|
|
} catch (e) { console.error('❌ Génération fiche PDF:', e.message, e.stack); }
|
|
|
|
let recapUrl = null;
|
|
try {
|
|
const recapBuffer = await generateRecapWithJustifs(noteDataPDF, allFiles);
|
|
const recapResult = await uploadToSharePointHierarchique(
|
|
{ buffer: recapBuffer, originalname: `${reference}_recap.pdf`, mimetype: 'application/pdf', size: recapBuffer.length },
|
|
reference, nomDossier, moisDossier
|
|
);
|
|
fichiersUploades.push(recapResult);
|
|
recapUrl = recapResult.uploadUrl;
|
|
console.log('✅ Récap PDF uploadé:', recapResult.fileName);
|
|
} catch (e) { console.error('❌ Génération récap PDF:', e.message); }
|
|
|
|
const insertResult = await pool.request()
|
|
.input('reference', sql.NVarChar, reference)
|
|
.input('collaborateurId', sql.Int, req.user.id)
|
|
.input('libelle', sql.NVarChar, libelle)
|
|
.input('montant', sql.Decimal, montantFinal)
|
|
.input('date', sql.Date, new Date(date))
|
|
.input('categorie', sql.NVarChar, categorieNote)
|
|
.input('description', sql.NVarChar, description || null)
|
|
.input('participants', sql.NVarChar, participants ? String(participants) : null)
|
|
.input('nombreParticipants', sql.Int, nombreParticipants ? parseInt(nombreParticipants) : null)
|
|
.input('sharepointUrl', sql.NVarChar, recapUrl || ficheResult?.uploadUrl || fichiersUploades[0]?.uploadUrl || null)
|
|
.input('fichiers', sql.NVarChar, JSON.stringify(fichiersUploades))
|
|
.input('statut', sql.NVarChar, 'enattente')
|
|
.input('validateurN1Id', sql.Int, n1Id)
|
|
.input('validateurN2Id', sql.Int, n2Id)
|
|
.input('montantHT', sql.Decimal, null)
|
|
.input('tauxTVA', sql.Decimal, null)
|
|
.input('montantTVA21', sql.Decimal, null)
|
|
.input('montantTVA55', sql.Decimal, null)
|
|
.input('montantTVA10', sql.Decimal, null)
|
|
.input('montantTVA20', sql.Decimal, null)
|
|
.input('km', sql.Decimal, kmTotal || null)
|
|
.input('indemniteKm', sql.Decimal, indemKm || null)
|
|
.input('lignesJson', sql.NVarChar, lignesJsonFinal)
|
|
.query(`
|
|
INSERT INTO NoteDeFrais
|
|
(reference, collaborateurId, libelle, montant, date, categorie,
|
|
description, participants, nombreParticipants, sharepointUrl, fichiers,
|
|
statut, validateurN1Id, validateurN2Id,
|
|
montantHT, tauxTVA, montantTVA21, montantTVA55, montantTVA10, montantTVA20,
|
|
km, indemniteKm, lignesJson)
|
|
OUTPUT INSERTED.id, INSERTED.reference
|
|
VALUES
|
|
(@reference, @collaborateurId, @libelle, @montant, @date, @categorie,
|
|
@description, @participants, @nombreParticipants, @sharepointUrl, @fichiers,
|
|
@statut, @validateurN1Id, @validateurN2Id,
|
|
@montantHT, @tauxTVA, @montantTVA21, @montantTVA55, @montantTVA10, @montantTVA20,
|
|
@km, @indemniteKm, @lignesJson)
|
|
`);
|
|
|
|
const noteCreee = insertResult.recordset[0];
|
|
|
|
for (let i = 0; i < lignesParsed.length; i++) {
|
|
const l = lignesParsed[i];
|
|
const pdf = lignesPDF[i];
|
|
try {
|
|
await pool.request()
|
|
.input('noteId', sql.Int, noteCreee.id)
|
|
.input('numPiece', sql.Int, i + 1)
|
|
.input('date', sql.Date, new Date(l.date))
|
|
.input('nature', sql.NVarChar, l.categorie || '')
|
|
.input('libelle', sql.NVarChar, l.libelle || '')
|
|
.input('km', sql.Decimal, pdf.km || null)
|
|
.input('montantTTC', sql.Decimal, pdf.montantTTC || null)
|
|
.input('tva21', sql.Decimal, pdf.tva21 || null)
|
|
.input('tva55', sql.Decimal, pdf.tva55 || null)
|
|
.input('tva10', sql.Decimal, pdf.tva10 || null)
|
|
.input('tva20', sql.Decimal, pdf.tva20 || null)
|
|
.input('montantHT', sql.Decimal, pdf.montantHT || null)
|
|
.input('tauxTVA', sql.Decimal, parseFloat(l.tauxTVA) || null)
|
|
.input('indemniteKm', sql.Decimal, pdf.indemniteKm || null)
|
|
.query(`
|
|
INSERT INTO LigneNoteDeFrais
|
|
(noteDeFraisId, numPiece, date, nature, libelle,
|
|
km, montantTTC, tva21, tva55, tva10, tva20,
|
|
montantHT, tauxTVA, indemniteKm)
|
|
VALUES
|
|
(@noteId, @numPiece, @date, @nature, @libelle,
|
|
@km, @montantTTC, @tva21, @tva55, @tva10, @tva20,
|
|
@montantHT, @tauxTVA, @indemniteKm)
|
|
`);
|
|
} catch (e) { console.error(`❌ Insertion ligne ${i + 1}:`, e.message); }
|
|
}
|
|
|
|
console.log(`✅ Note créée: ${reference} (${lignesParsed.length} lignes) par ${collaborateur.email}`);
|
|
|
|
const dateFormatee = new Date(date).toLocaleDateString('fr-FR');
|
|
const frontendUrl = process.env.FRONTEND_URL || 'myndf.ensup-adm.net';
|
|
|
|
try {
|
|
await creerNotification({
|
|
destinataireId: collaborateur.id, destinataireEmail: null, type: 'soumission',
|
|
titre: `✅ Note ${reference} soumise avec succès`,
|
|
message: `Votre note ${reference} — ${libelle} (${montantFormate} €) a bien été reçue.`,
|
|
noteId: noteCreee.id
|
|
});
|
|
} catch (e) { console.error('❌ Notif BDD collab:', e.message); }
|
|
|
|
try {
|
|
await sendMailGraph(
|
|
collaborateur.email,
|
|
`✅ Accusé de réception — Note ${reference}`,
|
|
`<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto">
|
|
<div style="background:linear-gradient(135deg,#10b981,#059669);color:white;padding:24px;border-radius:12px 12px 0 0">
|
|
<h2 style="margin:0">✅ Note de frais bien reçue</h2>
|
|
</div>
|
|
<div style="padding:24px;background:#f8fafc;border:1px solid #e2e8f0;border-top:none;border-radius:0 0 12px 12px">
|
|
<p>Bonjour <strong>${collaborateur.prenom} ${collaborateur.nom}</strong>,</p>
|
|
<p>Votre note <strong>${reference}</strong> — ${libelle} (${montantFormate} €) a bien été enregistrée.</p>
|
|
${nomN1 ? `<p>Validateur : <strong>${prenomN1} ${nomN1}</strong></p>` : ''}
|
|
${recapUrl ? `<p><a href="${recapUrl}" style="color:#6366f1">📎 Voir le récapitulatif PDF</a></p>` : ''}
|
|
<div style="text-align:center;margin-top:24px">
|
|
<a href="${frontendUrl}" style="background:#6366f1;color:white;padding:13px 28px;text-decoration:none;border-radius:8px;font-weight:700">Suivre mes notes →</a>
|
|
</div>
|
|
</div>
|
|
</div>`
|
|
);
|
|
} catch (e) { console.error('❌ Email accusé collab:', e.message); }
|
|
|
|
if (n1Id && emailN1) {
|
|
try {
|
|
await creerNotification({
|
|
destinataireId: n1Id, destinataireEmail: emailN1, type: 'validation',
|
|
titre: `📋 Note à valider — ${reference}`,
|
|
message: `${collaborateur.prenom} ${collaborateur.nom} a soumis une note de frais de ${montantFormate} € en attente de votre validation.`,
|
|
noteId: noteCreee.id
|
|
});
|
|
} catch (e) { console.error('❌ Notif BDD N1:', e.message); }
|
|
|
|
try {
|
|
await sendMailGraph(
|
|
emailN1,
|
|
`📋 Note de frais à valider — ${reference}`,
|
|
`<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto">
|
|
<div style="background:linear-gradient(135deg,#6366f1,#4f46e5);color:white;padding:24px;border-radius:12px 12px 0 0">
|
|
<h2 style="margin:0">📋 Note de frais à valider</h2>
|
|
</div>
|
|
<div style="padding:24px;background:#f8fafc;border:1px solid #e2e8f0;border-top:none;border-radius:0 0 12px 12px">
|
|
<p>Bonjour <strong>${prenomN1} ${nomN1}</strong>,</p>
|
|
<p><strong>${collaborateur.prenom} ${collaborateur.nom}</strong> a soumis la note <strong>${reference}</strong> — ${libelle} (<strong>${montantFormate} €</strong>).</p>
|
|
${recapUrl ? `<p><a href="${recapUrl}" style="color:#6366f1">📎 Récapitulatif + justificatifs</a></p>` : ''}
|
|
<div style="text-align:center;margin-top:24px">
|
|
<a href="${frontendUrl}" style="background:#6366f1;color:white;padding:14px 32px;text-decoration:none;border-radius:8px;font-weight:700">Valider sur la plateforme →</a>
|
|
</div>
|
|
</div>
|
|
</div>`
|
|
);
|
|
} catch (e) { console.error('❌ Email N1:', e.message); }
|
|
}
|
|
|
|
res.status(201).json({
|
|
success: true, id: noteCreee.id, reference: noteCreee.reference,
|
|
fichiers: fichiersUploades, recapUrl,
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error('❌ Erreur POST /api/notes:', error.message);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
}
|
|
);
|
|
app.put('/api/notes/:id', authenticateToken, upload.any(), async (req, res) => {
|
|
try {
|
|
const noteId = parseInt(req.params.id);
|
|
const userId = req.user.id;
|
|
|
|
const noteCheck = await pool.request()
|
|
.input('id', sql.Int, noteId)
|
|
.input('collabId', sql.Int, userId)
|
|
.query(`
|
|
SELECT * FROM NoteDeFrais
|
|
WHERE id = @id AND collaborateurId = @collabId
|
|
AND statut IN ('enattente', 'refuse', 'non_conforme_verif')
|
|
`);
|
|
|
|
if (!noteCheck.recordset.length)
|
|
return res.status(403).json({ error: 'Note introuvable ou non modifiable (statut incompatible)' });
|
|
|
|
const noteExist = noteCheck.recordset[0];
|
|
|
|
// ✅ Détecter si correction (refusée ou non-conforme) → nouvelle note
|
|
const estCorrection = noteExist.statut === 'refuse' || noteExist.statut === 'non_conforme_verif';
|
|
|
|
const { libelle, date, description, participants, nombreParticipants, lignes } = req.body;
|
|
|
|
if (!libelle || !date || !lignes)
|
|
return res.status(400).json({ error: 'Libellé, date et lignes sont obligatoires' });
|
|
|
|
let lignesParsed;
|
|
try { lignesParsed = typeof lignes === 'string' ? JSON.parse(lignes) : lignes; }
|
|
catch { return res.status(400).json({ error: 'Format des lignes invalide' }); }
|
|
|
|
// ── Calculs communs ───────────────────────────────────────────────────
|
|
const montantFinal = lignesParsed.reduce((total, l) => {
|
|
const isKm = (l.categorie || '').toLowerCase().includes('kilom');
|
|
if (isKm) {
|
|
const km = parseFloat(l.km) || 0;
|
|
const cv = parseInt(l.chevaux) || 7;
|
|
return total + getIndemniteKmServer(km, cv);
|
|
}
|
|
const items = l.tvaItems || [{ montantTTC: l.montant, taux: l.tauxTVA }];
|
|
return total + items.reduce((s, i) => s + (parseFloat(i.montantTTC) || 0), 0);
|
|
}, 0);
|
|
|
|
const indemKm = lignesParsed.reduce((s, l) => {
|
|
if ((l.categorie || '').toLowerCase().includes('kilom'))
|
|
return s + getIndemniteKmServer(parseFloat(l.km) || 0, parseInt(l.chevaux) || 7);
|
|
return s;
|
|
}, 0);
|
|
|
|
const kmTotal = lignesParsed.reduce((s, l) =>
|
|
s + ((l.categorie || '').toLowerCase().includes('kilom') ? (parseFloat(l.km) || 0) : 0), 0
|
|
);
|
|
|
|
const isKmOnly = lignesParsed.every(l => (l.categorie || '').toLowerCase().includes('kilom'));
|
|
const categorieNote = isKmOnly ? 'Kilométrique pur' : 'Multiple';
|
|
|
|
const collabResult = await pool.request()
|
|
.input('id', sql.Int, userId)
|
|
.query(`SELECT id, email, prenom, nom, departement, campus FROM CollaborateurAD WHERE id = @id`);
|
|
const collaborateur = collabResult.recordset[0];
|
|
const nomPrenom = `${collaborateur.nom.toUpperCase()} ${collaborateur.prenom}`;
|
|
|
|
const dateObj = new Date(date);
|
|
const moisStr = dateObj.toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' });
|
|
const moisCapitalized = moisStr.charAt(0).toUpperCase() + moisStr.slice(1);
|
|
const nomDossier = `${collaborateur.prenom}_${collaborateur.nom}`.replace(/\s+/g, '_');
|
|
const now = new Date();
|
|
const moisDossier = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
|
|
const tarifKmVal = await getTarifKm();
|
|
|
|
// ════════════════════════════════════════════════════════════════════
|
|
// CAS 1 — Note REFUSÉE ou NON_CONFORME_VERIF → créer une NOUVELLE note
|
|
// ════════════════════════════════════════════════════════════════════
|
|
if (estCorrection) {
|
|
|
|
// 1. Archiver l'ancienne note
|
|
const statutArchive = noteExist.statut === 'non_conforme_verif'
|
|
? 'non_conforme_archive'
|
|
: 'refuse_archive';
|
|
|
|
await pool.request()
|
|
.input('id', sql.Int, noteId)
|
|
.input('statutArchive', sql.NVarChar, statutArchive)
|
|
.query(`
|
|
UPDATE NoteDeFrais SET
|
|
statut = @statutArchive,
|
|
DateModification = GETDATE()
|
|
WHERE id = @id
|
|
`);
|
|
|
|
// 2. Nouvelle référence
|
|
const nouvelleReference = await genererReference(collaborateur.campus, collaborateur.nom, collaborateur.prenom);
|
|
|
|
// 3. Upload fichiers joints
|
|
const allFiles = [...(req.files || [])];
|
|
const fichiersUploades = [];
|
|
for (const file of allFiles) {
|
|
try {
|
|
const r = await uploadToSharePointHierarchique(file, nouvelleReference, nomDossier, moisDossier);
|
|
fichiersUploades.push(r);
|
|
} catch (e) { console.error(`❌ Upload justif correction ${file.originalname}:`, e.message); }
|
|
}
|
|
|
|
// 4. Récupérer fichiers QR par ligne
|
|
for (let i = 0; i < lignesParsed.length; i++) {
|
|
const ligneQrRef = lignesParsed[i].qrNoteRef;
|
|
if (!ligneQrRef) continue;
|
|
try {
|
|
const qrLigne = await pool.request()
|
|
.input('noteRef', sql.NVarChar, ligneQrRef)
|
|
.query(`SELECT TOP 1 fichiers FROM UploadTokens WHERE noteRef = @noteRef AND used = 1 ORDER BY expiresAt DESC`);
|
|
if (qrLigne.recordset.length && qrLigne.recordset[0].fichiers) {
|
|
const qrFichiers = JSON.parse(qrLigne.recordset[0].fichiers);
|
|
for (const f of qrFichiers) {
|
|
try {
|
|
const buf = await downloadFromSharePoint(f.uploadUrl);
|
|
const r = await uploadToSharePointHierarchique(
|
|
{ buffer: buf, originalname: f.fileName, mimetype: f.fileName?.endsWith('.pdf') ? 'application/pdf' : 'image/jpeg', size: buf.length },
|
|
nouvelleReference, nomDossier, moisDossier
|
|
);
|
|
fichiersUploades.push(r);
|
|
} catch (e) { console.warn(`⚠️ QR ligne ${i} download fail:`, e.message); }
|
|
}
|
|
}
|
|
} catch (e) { console.warn(`⚠️ QR ligne ${i} erreur DB:`, e.message); }
|
|
}
|
|
|
|
// 5. Générer fiche PDF
|
|
const noteDataPDF = {
|
|
reference: nouvelleReference, nomPrenom, mois: moisCapitalized, date,
|
|
categorie: categorieNote, libelle,
|
|
montant: parseFloat(montantFinal.toFixed(2)),
|
|
lignes: lignesParsed, lignesJson: JSON.stringify(lignesParsed),
|
|
tarifKm: tarifKmVal, statut: 'enattente',
|
|
departement: collaborateur.departement,
|
|
};
|
|
|
|
try {
|
|
const commentairePDF = noteExist.statut === 'non_conforme_verif'
|
|
? `Correction suite à non-conformité signalée sur ${noteExist.reference}`
|
|
: `Correction suite au refus de ${noteExist.reference}`;
|
|
const fichePDF = await generateFicheSignee(noteDataPDF, [{
|
|
niveau: 'COLLAB', nomPrenom, date: new Date(),
|
|
action: 'soumettre', commentaire: commentairePDF
|
|
}]);
|
|
const ficheResult = await uploadToSharePointHierarchique(
|
|
{ buffer: fichePDF, originalname: `${nouvelleReference}_soumission.pdf`, mimetype: 'application/pdf', size: fichePDF.length },
|
|
nouvelleReference, nomDossier, moisDossier
|
|
);
|
|
fichiersUploades.push(ficheResult);
|
|
} catch (e) { console.error('❌ Fiche PDF correction:', e.message); }
|
|
|
|
// 6. Hiérarchie
|
|
const hierarchie = await pool.request()
|
|
.input('collabId', sql.Int, userId)
|
|
.query(`
|
|
SELECT h.SuperieurId, h.[SuperieurIdn+2],
|
|
s1.email AS emailN1, s1.prenom AS prenomN1, s1.nom AS nomN1,
|
|
s2.email AS emailN2
|
|
FROM HierarchieValidationNDF h
|
|
LEFT JOIN CollaborateurAD s1 ON s1.id = h.SuperieurId
|
|
LEFT JOIN CollaborateurAD s2 ON s2.id = h.[SuperieurIdn+2]
|
|
WHERE h.CollaborateurId = @collabId
|
|
`);
|
|
const n1Id = hierarchie.recordset[0]?.SuperieurId ?? null;
|
|
const n2Id = hierarchie.recordset[0]?.['SuperieurIdn+2'] ?? null;
|
|
const emailN1 = hierarchie.recordset[0]?.emailN1 ?? null;
|
|
const prenomN1 = hierarchie.recordset[0]?.prenomN1 ?? null;
|
|
const nomN1 = hierarchie.recordset[0]?.nomN1 ?? null;
|
|
|
|
// 7. Insérer nouvelle note
|
|
const insertResult = await pool.request()
|
|
.input('reference', sql.NVarChar, nouvelleReference)
|
|
.input('collaborateurId', sql.Int, userId)
|
|
.input('libelle', sql.NVarChar, libelle)
|
|
.input('montant', sql.Decimal, parseFloat(montantFinal.toFixed(2)))
|
|
.input('date', sql.Date, new Date(date))
|
|
.input('categorie', sql.NVarChar, categorieNote)
|
|
.input('description', sql.NVarChar, description || null)
|
|
.input('participants', sql.NVarChar, participants ? String(participants) : null)
|
|
.input('nombreParticipants', sql.Int, nombreParticipants ? parseInt(nombreParticipants) : null)
|
|
.input('fichiers', sql.NVarChar, JSON.stringify(fichiersUploades))
|
|
.input('statut', sql.NVarChar, 'enattente')
|
|
.input('validateurN1Id', sql.Int, n1Id)
|
|
.input('validateurN2Id', sql.Int, n2Id)
|
|
.input('km', sql.Decimal, kmTotal || null)
|
|
.input('indemniteKm', sql.Decimal, indemKm || null)
|
|
.input('lignesJson', sql.NVarChar, JSON.stringify(lignesParsed))
|
|
.input('noteRefuseeId', sql.Int, noteId)
|
|
.query(`
|
|
INSERT INTO NoteDeFrais
|
|
(reference, collaborateurId, libelle, montant, date, categorie,
|
|
description, participants, nombreParticipants,
|
|
fichiers, statut, validateurN1Id, validateurN2Id,
|
|
km, indemniteKm, lignesJson, noteRefuseeId,
|
|
DateCreation, DateModification)
|
|
OUTPUT INSERTED.id, INSERTED.reference
|
|
VALUES
|
|
(@reference, @collaborateurId, @libelle, @montant, @date, @categorie,
|
|
@description, @participants, @nombreParticipants,
|
|
@fichiers, @statut, @validateurN1Id, @validateurN2Id,
|
|
@km, @indemniteKm, @lignesJson, @noteRefuseeId,
|
|
GETDATE(), GETDATE())
|
|
`);
|
|
|
|
const nouvelleNote = insertResult.recordset[0];
|
|
|
|
// 8. Insérer lignes
|
|
const lignesPDF = preparerLignesPDF(lignesParsed, tarifKmVal);
|
|
for (let i = 0; i < lignesParsed.length; i++) {
|
|
const l = lignesParsed[i];
|
|
const pdf = lignesPDF[i] || {};
|
|
const isKmLine = (l.categorie || '').toLowerCase().includes('kilom');
|
|
const kmLine = parseFloat(l.km) || 0;
|
|
const cvLine = parseInt(l.chevaux) || 7;
|
|
const indemLine = isKmLine ? getIndemniteKmServer(kmLine, cvLine) : 0;
|
|
const items = l.tvaItems || [{ montantTTC: l.montant, taux: l.tauxTVA }];
|
|
const ttcLine = isKmLine ? indemLine : items.reduce((s, item) => s + (parseFloat(item.montantTTC) || 0), 0);
|
|
try {
|
|
await pool.request()
|
|
.input('noteId', sql.Int, nouvelleNote.id)
|
|
.input('numPiece', sql.Int, i + 1)
|
|
.input('date', sql.Date, new Date(l.date))
|
|
.input('nature', sql.NVarChar, l.categorie || '')
|
|
.input('libelle', sql.NVarChar, l.libelle || '')
|
|
.input('km', sql.Decimal, isKmLine ? kmLine : null)
|
|
.input('montantTTC', sql.Decimal, ttcLine || null)
|
|
.input('tva21', sql.Decimal, pdf.tva21 || null)
|
|
.input('tva55', sql.Decimal, pdf.tva55 || null)
|
|
.input('tva10', sql.Decimal, pdf.tva10 || null)
|
|
.input('tva20', sql.Decimal, pdf.tva20 || null)
|
|
.input('montantHT', sql.Decimal, pdf.montantHT || null)
|
|
.input('tauxTVA', sql.Decimal, parseFloat(l.tauxTVA) || null)
|
|
.input('indemniteKm', sql.Decimal, indemLine || null)
|
|
.query(`
|
|
INSERT INTO LigneNoteDeFrais
|
|
(noteDeFraisId, numPiece, date, nature, libelle,
|
|
km, montantTTC, tva21, tva55, tva10, tva20,
|
|
montantHT, tauxTVA, indemniteKm)
|
|
VALUES
|
|
(@noteId, @numPiece, @date, @nature, @libelle,
|
|
@km, @montantTTC, @tva21, @tva55, @tva10, @tva20,
|
|
@montantHT, @tauxTVA, @indemniteKm)
|
|
`);
|
|
} catch (e) { console.error(`❌ Insertion ligne correction ${i + 1}:`, e.message); }
|
|
}
|
|
|
|
// 9. Notifier N1
|
|
if (n1Id && emailN1) {
|
|
try {
|
|
const titreNotif = noteExist.statut === 'non_conforme_verif'
|
|
? `📋 Note corrigée à valider — ${nouvelleReference}`
|
|
: `📋 Note corrigée à valider — ${nouvelleReference}`;
|
|
// Dans le PUT /api/notes/:id — CAS 1 correction, section "Notifier N1"
|
|
const msgNotif = `${collaborateur.prenom} ${collaborateur.nom} a resoumis une note corrigée.
|
|
Ancienne référence : ${noteExist.reference} (${statutArchive})
|
|
Nouvelle référence : ${nouvelleReference}
|
|
Montant : ${parseFloat(montantFinal.toFixed(2))} €`;
|
|
|
|
await creerNotification({
|
|
destinataireId: n1Id, destinataireEmail: emailN1,
|
|
type: 'validation', titre: titreNotif, message: msgNotif,
|
|
noteId: nouvelleNote.id
|
|
});
|
|
|
|
const frontendUrl = process.env.FRONTEND_URL || 'https://myndf.ensup-adm.net';
|
|
const couleurHeader = noteExist.statut === 'non_conforme_verif'
|
|
? 'linear-gradient(135deg,#f97316,#ea580c)'
|
|
: 'linear-gradient(135deg,#f59e0b,#d97706)';
|
|
const contexte = noteExist.statut === 'non_conforme_verif'
|
|
? `suite à la non-conformité signalée sur <strong>${noteExist.reference}</strong>`
|
|
: `suite au refus de <strong>${noteExist.reference}</strong>`;
|
|
|
|
await sendMailGraph(emailN1, `📋 Note corrigée à valider — ${nouvelleReference}`,
|
|
`<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto">
|
|
<div style="background:${couleurHeader};color:white;padding:20px;border-radius:12px 12px 0 0">
|
|
<h2 style="margin:0">📋 Note corrigée — à valider</h2>
|
|
</div>
|
|
<div style="padding:20px;background:#f8fafc;border:1px solid #e2e8f0;border-top:none;border-radius:0 0 12px 12px">
|
|
<p>Bonjour <strong>${prenomN1} ${nomN1}</strong>,</p>
|
|
<p><strong>${collaborateur.prenom} ${collaborateur.nom}</strong> a resoumis une note corrigée ${contexte}.</p>
|
|
<p>Nouvelle référence : <strong>${nouvelleReference}</strong> — <strong>${parseFloat(montantFinal.toFixed(2))} €</strong></p>
|
|
<div style="text-align:center;margin-top:24px">
|
|
<a href="${frontendUrl}" style="background:#6366f1;color:white;padding:14px 32px;text-decoration:none;border-radius:8px;font-weight:700">Valider sur la plateforme →</a>
|
|
</div>
|
|
</div>
|
|
</div>`
|
|
);
|
|
} catch (e) { console.error('❌ Notif N1 correction:', e.message); }
|
|
}
|
|
|
|
console.log(`✅ Note corrigée ${nouvelleReference} créée (remplace ${noteExist.reference} → ${statutArchive}) par ${collaborateur.email}`);
|
|
return res.json({
|
|
success: true,
|
|
id: nouvelleNote.id,
|
|
reference: nouvelleReference,
|
|
statut: 'enattente',
|
|
isCorrection: true,
|
|
ancienneReference: noteExist.reference
|
|
});
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════
|
|
// CAS 2 — Note EN ATTENTE → modifier sur place (comportement original)
|
|
// ════════════════════════════════════════════════════════════════════
|
|
|
|
const reference = noteExist.reference;
|
|
const allFiles = [...(req.files || [])];
|
|
let fichiersExistants = [];
|
|
try { fichiersExistants = JSON.parse(noteExist.fichiers || '[]'); } catch { }
|
|
|
|
for (const file of allFiles) {
|
|
try {
|
|
const r = await uploadToSharePointHierarchique(file, reference, nomDossier, moisDossier);
|
|
fichiersExistants.push(r);
|
|
} catch (e) { console.error(`❌ Upload justif modif ${file.originalname}:`, e.message); }
|
|
}
|
|
|
|
const noteDataPDF = {
|
|
reference, nomPrenom, mois: moisCapitalized, date,
|
|
categorie: categorieNote, libelle,
|
|
montant: parseFloat(montantFinal.toFixed(2)),
|
|
lignes: lignesParsed, lignesJson: JSON.stringify(lignesParsed),
|
|
tarifKm: tarifKmVal, statut: 'enattente',
|
|
departement: collaborateur.departement,
|
|
};
|
|
|
|
try {
|
|
const fichePDF = await generateFicheSignee(noteDataPDF, [
|
|
{ niveau: 'COLLAB', nomPrenom, date: new Date(), action: 'soumettre', commentaire: 'Note modifiée et resoumise' }
|
|
]);
|
|
const ficheResult = await uploadToSharePointHierarchique(
|
|
{ buffer: fichePDF, originalname: `${reference}_resoumission.pdf`, mimetype: 'application/pdf', size: fichePDF.length },
|
|
reference, nomDossier, moisDossier
|
|
);
|
|
fichiersExistants.push(ficheResult);
|
|
} catch (e) { console.error('❌ Fiche PDF re-soumission:', e.message); }
|
|
|
|
await pool.request()
|
|
.input('id', sql.Int, noteId)
|
|
.input('libelle', sql.NVarChar, libelle)
|
|
.input('montant', sql.Decimal, parseFloat(montantFinal.toFixed(2)))
|
|
.input('date', sql.Date, new Date(date))
|
|
.input('categorie', sql.NVarChar, categorieNote)
|
|
.input('description', sql.NVarChar, description || null)
|
|
.input('fichiers', sql.NVarChar, JSON.stringify(fichiersExistants))
|
|
.input('statut', sql.NVarChar, 'enattente')
|
|
.input('km', sql.Decimal, kmTotal || null)
|
|
.input('indemniteKm', sql.Decimal, indemKm || null)
|
|
.input('lignesJson', sql.NVarChar, JSON.stringify(lignesParsed))
|
|
.query(`
|
|
UPDATE NoteDeFrais SET
|
|
libelle = @libelle, montant = @montant, date = @date,
|
|
categorie = @categorie, description = @description,
|
|
fichiers = @fichiers, statut = @statut,
|
|
km = @km, indemniteKm = @indemniteKm,
|
|
lignesJson = @lignesJson,
|
|
motifRefus = NULL, commentaireN1 = NULL, commentaireN2 = NULL,
|
|
dateValidationN1 = NULL, dateValidationN2 = NULL,
|
|
DateModification = GETDATE()
|
|
WHERE id = @id
|
|
`);
|
|
|
|
await pool.request()
|
|
.input('noteId', sql.Int, noteId)
|
|
.query(`DELETE FROM LigneNoteDeFrais WHERE noteDeFraisId = @noteId`);
|
|
|
|
const lignesPDF = preparerLignesPDF(lignesParsed, tarifKmVal);
|
|
for (let i = 0; i < lignesParsed.length; i++) {
|
|
const l = lignesParsed[i];
|
|
const pdf = lignesPDF[i] || {};
|
|
const isKmLine = (l.categorie || '').toLowerCase().includes('kilom');
|
|
const kmLine = parseFloat(l.km) || 0;
|
|
const cvLine = parseInt(l.chevaux) || 7;
|
|
const indemLine = isKmLine ? getIndemniteKmServer(kmLine, cvLine) : 0;
|
|
const items = l.tvaItems || [{ montantTTC: l.montant, taux: l.tauxTVA }];
|
|
const ttcLine = isKmLine ? indemLine : items.reduce((s, item) => s + (parseFloat(item.montantTTC) || 0), 0);
|
|
try {
|
|
await pool.request()
|
|
.input('noteId', sql.Int, noteId)
|
|
.input('numPiece', sql.Int, i + 1)
|
|
.input('date', sql.Date, new Date(l.date))
|
|
.input('nature', sql.NVarChar, l.categorie || '')
|
|
.input('libelle', sql.NVarChar, l.libelle || '')
|
|
.input('km', sql.Decimal, isKmLine ? kmLine : null)
|
|
.input('montantTTC', sql.Decimal, ttcLine || null)
|
|
.input('tva21', sql.Decimal, pdf.tva21 || null)
|
|
.input('tva55', sql.Decimal, pdf.tva55 || null)
|
|
.input('tva10', sql.Decimal, pdf.tva10 || null)
|
|
.input('tva20', sql.Decimal, pdf.tva20 || null)
|
|
.input('montantHT', sql.Decimal, pdf.montantHT || null)
|
|
.input('tauxTVA', sql.Decimal, parseFloat(l.tauxTVA) || null)
|
|
.input('indemniteKm', sql.Decimal, indemLine || null)
|
|
.query(`
|
|
INSERT INTO LigneNoteDeFrais
|
|
(noteDeFraisId, numPiece, date, nature, libelle,
|
|
km, montantTTC, tva21, tva55, tva10, tva20,
|
|
montantHT, tauxTVA, indemniteKm)
|
|
VALUES
|
|
(@noteId, @numPiece, @date, @nature, @libelle,
|
|
@km, @montantTTC, @tva21, @tva55, @tva10, @tva20,
|
|
@montantHT, @tauxTVA, @indemniteKm)
|
|
`);
|
|
} catch (e) { console.error(`❌ Insertion ligne modif ${i + 1}:`, e.message); }
|
|
}
|
|
|
|
const hierarchie = await pool.request()
|
|
.input('collabId', sql.Int, userId)
|
|
.query(`
|
|
SELECT h.SuperieurId, s1.email AS emailN1, s1.prenom AS prenomN1, s1.nom AS nomN1
|
|
FROM HierarchieValidationNDF h
|
|
LEFT JOIN CollaborateurAD s1 ON s1.id = h.SuperieurId
|
|
WHERE h.CollaborateurId = @collabId
|
|
`);
|
|
const n1 = hierarchie.recordset[0];
|
|
if (n1?.SuperieurId && n1?.emailN1) {
|
|
try {
|
|
await creerNotification({
|
|
destinataireId: n1.SuperieurId, destinataireEmail: n1.emailN1,
|
|
type: 'validation',
|
|
titre: `📋 Note modifiée à valider — ${reference}`,
|
|
message: `${collaborateur.prenom} ${collaborateur.nom} a modifié et resoumis la note ${reference} (${parseFloat(montantFinal.toFixed(2))} €).`,
|
|
noteId
|
|
});
|
|
const frontendUrl = process.env.FRONTEND_URL || 'myndf.ensup-adm.net';
|
|
await sendMailGraph(n1.emailN1, `📋 Note modifiée à valider — ${reference}`,
|
|
`<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto">
|
|
<div style="background:linear-gradient(135deg,#f59e0b,#d97706);color:white;padding:20px;border-radius:12px 12px 0 0">
|
|
<h2 style="margin:0">📋 Note modifiée — à valider</h2>
|
|
</div>
|
|
<div style="padding:20px;background:#f8fafc;border:1px solid #e2e8f0;border-top:none;border-radius:0 0 12px 12px">
|
|
<p>Bonjour <strong>${n1.prenomN1} ${n1.nomN1}</strong>,</p>
|
|
<p><strong>${collaborateur.prenom} ${collaborateur.nom}</strong> a modifié et resoumis la note <strong>${reference}</strong> — ${libelle} (<strong>${parseFloat(montantFinal.toFixed(2))} €</strong>).</p>
|
|
<div style="text-align:center;margin-top:24px">
|
|
<a href="${frontendUrl}" style="background:#6366f1;color:white;padding:14px 32px;text-decoration:none;border-radius:8px;font-weight:700">Valider sur la plateforme →</a>
|
|
</div>
|
|
</div>
|
|
</div>`
|
|
);
|
|
} catch (e) { console.error('❌ Notif N1 modif:', e.message); }
|
|
}
|
|
|
|
console.log(`✅ Note ${reference} modifiée par ${collaborateur.email}`);
|
|
res.json({ success: true, id: noteId, reference, statut: 'enattente', isCorrection: false });
|
|
|
|
} catch (error) {
|
|
console.error('❌ Erreur PUT /api/notes/:id:', error.message);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
// ================================================
|
|
// GET /api/notes
|
|
// ================================================
|
|
app.get('/api/notes', authenticateToken, async (req, res) => {
|
|
try {
|
|
const { statut, mois, annee } = req.query;
|
|
const request = pool.request()
|
|
.input('collaborateurId', sql.Int, req.user.id);
|
|
|
|
let where = 'WHERE n.collaborateurId = @collaborateurId';
|
|
if (statut) {
|
|
request.input('statut', sql.NVarChar, statut);
|
|
where += ' AND n.statut = @statut';
|
|
}
|
|
if (mois && annee) {
|
|
request.input('mois', sql.Int, parseInt(mois));
|
|
request.input('annee', sql.Int, parseInt(annee));
|
|
where += ' AND MONTH(n.date) = @mois AND YEAR(n.date) = @annee';
|
|
}
|
|
|
|
const result = await request.query(`
|
|
SELECT n.*,
|
|
v1.nom + ' ' + v1.prenom as nomValidateurN1,
|
|
v2.nom + ' ' + v2.prenom as nomValidateurN2
|
|
FROM NoteDeFrais n
|
|
LEFT JOIN CollaborateurAD v1 ON v1.id = n.validateurN1Id
|
|
LEFT JOIN CollaborateurAD v2 ON v2.id = n.validateurN2Id
|
|
${where}
|
|
ORDER BY n.DateCreation DESC
|
|
`);
|
|
|
|
const notes = result.recordset;
|
|
for (const note of notes) {
|
|
// ✅ Parser fichiers → sharepointFiles pour le frontend
|
|
if (note.fichiers) {
|
|
try { note.sharepointFiles = JSON.parse(note.fichiers); } catch { note.sharepointFiles = []; }
|
|
} else { note.sharepointFiles = []; }
|
|
if (note.statut === 'non_conforme_verif') {
|
|
try {
|
|
const ncResult = await pool.request()
|
|
.input('noteId', sql.Int, note.id)
|
|
.query(`
|
|
SELECT fileName, motif, dateSignalement
|
|
FROM JustificatifsNonConformes
|
|
WHERE noteDeFraisId = @noteId
|
|
ORDER BY dateSignalement DESC
|
|
`);
|
|
note.nonConformes = ncResult.recordset;
|
|
} catch (e) { note.nonConformes = []; }
|
|
}
|
|
// ✅ Toujours re-parser lignesJson depuis la BDD pour avoir les qrFiles à jour
|
|
// Ne reconstruire depuis LigneNoteDeFrais qu'en dernier recours
|
|
// ✅ Enrichir chaque ligne avec ses fichiers QR depuis UploadTokens
|
|
if (note.lignesJson) {
|
|
try {
|
|
const lignes = JSON.parse(note.lignesJson);
|
|
let enrichi = false;
|
|
|
|
const lignesEnrichies = await Promise.all(lignes.map(async (l) => {
|
|
if (l.qrFiles && l.qrFiles.length > 0) return l;
|
|
|
|
const qrRef = l.qrNoteRef || '';
|
|
if (!qrRef) return l;
|
|
|
|
try {
|
|
const qrResult = await pool.request()
|
|
.input('noteRef', sql.NVarChar, qrRef)
|
|
.query(`SELECT TOP 1 fichiers FROM UploadTokens
|
|
WHERE noteRef = @noteRef AND used = 1
|
|
ORDER BY expiresAt DESC`);
|
|
|
|
if (qrResult.recordset.length && qrResult.recordset[0].fichiers) {
|
|
const fichiers = JSON.parse(qrResult.recordset[0].fichiers);
|
|
if (fichiers.length > 0) {
|
|
enrichi = true;
|
|
return { ...l, qrFiles: fichiers };
|
|
}
|
|
}
|
|
} catch (e) { }
|
|
return l;
|
|
}));
|
|
|
|
if (enrichi) {
|
|
note.lignesJson = JSON.stringify(lignesEnrichies);
|
|
}
|
|
} catch (e) {
|
|
console.warn('⚠️ Enrichissement QR fail:', note.id, e.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
res.json(notes);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// ================================================
|
|
// 🔑 TOKEN SHAREPOINT (scope différent de Graph)
|
|
// ================================================
|
|
async function getSharePointToken() {
|
|
try {
|
|
const params = new URLSearchParams({
|
|
grant_type: 'client_credentials',
|
|
client_id: AZURE_CONFIG.clientId,
|
|
client_secret: AZURE_CONFIG.clientSecret,
|
|
scope: 'https://ensup.sharepoint.com/.default' // ← scope SharePoint
|
|
});
|
|
|
|
const response = await axios.post(
|
|
`https://login.microsoftonline.com/${AZURE_CONFIG.tenantId}/oauth2/v2.0/token`,
|
|
params.toString(),
|
|
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
|
|
);
|
|
|
|
return response.data.access_token;
|
|
} catch (error) {
|
|
console.error('❌ Erreur token SharePoint:', error.response?.data || error.message);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// routes/sharepoint.js
|
|
// ✅ route proxy-file — force les bons headers
|
|
app.get('/api/proxy-pdf', async (req, res) => {
|
|
let url = req.query.url;
|
|
if (!url) return res.status(400).send('URL manquante');
|
|
try { url = decodeURIComponent(url); } catch { }
|
|
if (url.includes('/api/proxy-pdf?url=')) {
|
|
url = url.split('/api/proxy-pdf?url=')[1];
|
|
try { url = decodeURIComponent(url); } catch { }
|
|
}
|
|
if (!url.startsWith('http')) return res.status(400).send('URL invalide : ' + url);
|
|
|
|
// ✅ Headers cache navigateur
|
|
res.setHeader('Cache-Control', 'private, max-age=600');
|
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
res.setHeader('Content-Disposition', 'inline');
|
|
|
|
// ✅ Vérifier cache serveur
|
|
const cached = getCached(url);
|
|
if (cached) {
|
|
res.setHeader('Content-Type', cached.contentType);
|
|
res.setHeader('X-Cache', 'HIT');
|
|
return res.send(cached.buffer);
|
|
}
|
|
|
|
try {
|
|
const buffer = await downloadFromSharePoint(url);
|
|
const urlLower = url.toLowerCase();
|
|
let contentType = 'application/octet-stream';
|
|
if (urlLower.includes('.pdf')) contentType = 'application/pdf';
|
|
else if (urlLower.includes('.jpg') || urlLower.includes('.jpeg')) contentType = 'image/jpeg';
|
|
else if (urlLower.includes('.png')) contentType = 'image/png';
|
|
|
|
setCache(url, buffer, contentType);
|
|
res.setHeader('Content-Type', contentType);
|
|
res.setHeader('X-Cache', 'MISS');
|
|
res.send(buffer);
|
|
} catch (err) {
|
|
console.error('❌ proxy-pdf erreur:', err.message);
|
|
res.status(500).json({ error: err.message, url });
|
|
}
|
|
});
|
|
|
|
|
|
// ================================================
|
|
// GET /api/notes/pending
|
|
// ================================================
|
|
app.get('/api/notes/pending', authenticateToken, async (req, res) => {
|
|
try {
|
|
const result = await pool.request()
|
|
.input('userId', sql.Int, req.user.id)
|
|
.query(`
|
|
SELECT n.id, n.reference, n.libelle, n.montant, n.date, n.categorie, n.statut,
|
|
n.description, n.participants, n.nombreParticipants,
|
|
n.montantHT, n.tauxTVA, n.km, n.indemniteKm,
|
|
n.commentaireN1, n.commentaireN2, n.sharepointUrl, n.fichiers,
|
|
n.lignesJson,
|
|
n.noteRefuseeId,
|
|
ancienne.reference AS ancienneReference,
|
|
ancienne.motifRefus AS ancienMotifRefus,
|
|
c.prenom + ' ' + c.nom AS collaborateur, c.departement, c.campus
|
|
FROM NoteDeFrais n
|
|
JOIN CollaborateurAD c ON c.id = n.collaborateurId
|
|
LEFT JOIN NoteDeFrais ancienne ON ancienne.id = n.noteRefuseeId
|
|
WHERE (n.validateurN1Id = @userId AND n.statut = 'enattente')
|
|
OR (n.validateurN2Id = @userId AND n.statut = 'validen1')
|
|
ORDER BY n.date DESC
|
|
`);
|
|
|
|
// Pour chaque note, récupérer les lignes depuis LigneNoteDeFrais
|
|
// si lignesJson est vide
|
|
const notes = result.recordset;
|
|
for (const note of notes) {
|
|
// ✅ Parser fichiers → sharepointFiles pour le frontend
|
|
if (note.fichiers) {
|
|
try { note.sharepointFiles = JSON.parse(note.fichiers); } catch { note.sharepointFiles = []; }
|
|
} else { note.sharepointFiles = []; }
|
|
|
|
// ✅ Toujours re-parser lignesJson depuis la BDD pour avoir les qrFiles à jour
|
|
// Ne reconstruire depuis LigneNoteDeFrais qu'en dernier recours
|
|
// ✅ Enrichir chaque ligne avec ses fichiers QR depuis UploadTokens
|
|
if (note.lignesJson) {
|
|
try {
|
|
const lignes = JSON.parse(note.lignesJson);
|
|
let enrichi = false;
|
|
|
|
const lignesEnrichies = await Promise.all(lignes.map(async (l) => {
|
|
if (l.qrFiles && l.qrFiles.length > 0) return l;
|
|
|
|
const qrRef = l.qrNoteRef || '';
|
|
if (!qrRef) return l;
|
|
|
|
try {
|
|
const qrResult = await pool.request()
|
|
.input('noteRef', sql.NVarChar, qrRef)
|
|
.query(`SELECT TOP 1 fichiers FROM UploadTokens
|
|
WHERE noteRef = @noteRef AND used = 1
|
|
ORDER BY expiresAt DESC`);
|
|
|
|
if (qrResult.recordset.length && qrResult.recordset[0].fichiers) {
|
|
const fichiers = JSON.parse(qrResult.recordset[0].fichiers);
|
|
if (fichiers.length > 0) {
|
|
enrichi = true;
|
|
return { ...l, qrFiles: fichiers };
|
|
}
|
|
}
|
|
} catch (e) { }
|
|
return l;
|
|
}));
|
|
|
|
if (enrichi) {
|
|
note.lignesJson = JSON.stringify(lignesEnrichies);
|
|
}
|
|
} catch (e) {
|
|
console.warn('⚠️ Enrichissement QR fail:', note.id, e.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
res.json(notes);
|
|
} catch (e) {
|
|
console.error('Erreur /api/notes/pending:', e.message);
|
|
res.status(500).json({ error: e.message });
|
|
}
|
|
});
|
|
|
|
// ================================================
|
|
// GET /api/notes/:id/lignes
|
|
// ================================================
|
|
app.get('/api/notes/:id/lignes', authenticateToken, async (req, res) => {
|
|
try {
|
|
const isValidator = hasAnyRole(req.user, 'Finance', 'Validateur', 'Validatrice', 'superUtilisateur') ? 1 : 0;
|
|
const result = await pool.request()
|
|
.input('noteId', sql.Int, req.params.id)
|
|
.input('userId', sql.Int, req.user.id)
|
|
.input('isVal', sql.Int, isValidator)
|
|
.query(`
|
|
SELECT l.*
|
|
FROM LigneNoteDeFrais l
|
|
JOIN NoteDeFrais n ON n.id = l.noteDeFraisId
|
|
WHERE l.noteDeFraisId = @noteId
|
|
AND (n.collaborateurId = @userId OR @isVal = 1)
|
|
ORDER BY l.numPiece ASC
|
|
`);
|
|
res.json(result.recordset);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// GET /api/notes/:id/detail — récupère une note par ID (pour validateur + collaborateur)
|
|
app.get('/api/notes/:id/detail', authenticateToken, async (req, res) => {
|
|
try {
|
|
const noteId = parseInt(req.params.id);
|
|
const userId = req.user.id;
|
|
|
|
const result = await pool.request()
|
|
.input('id', sql.Int, noteId)
|
|
.query(`
|
|
SELECT n.*,
|
|
c.prenom + ' ' + c.nom AS collaborateur,
|
|
c.departement, c.campus,
|
|
ancienne.reference AS ancienneReference,
|
|
ancienne.motifRefus AS ancienMotifRefus
|
|
FROM NoteDeFrais n
|
|
JOIN CollaborateurAD c ON c.id = n.collaborateurId
|
|
LEFT JOIN NoteDeFrais ancienne ON ancienne.id = n.noteRefuseeId
|
|
WHERE n.id = @id
|
|
AND (
|
|
n.collaborateurId = ${userId}
|
|
OR n.validateurN1Id = ${userId}
|
|
OR n.validateurN2Id = ${userId}
|
|
OR EXISTS (
|
|
SELECT 1 FROM UtilisateurRoles r
|
|
WHERE r.collaborateur_id = ${userId}
|
|
AND r.role IN ('Finance','superUtilisateur','VerificateurFinance','ValidateurFinance')
|
|
AND r.actif = 1
|
|
)
|
|
)
|
|
`);
|
|
|
|
if (!result.recordset.length)
|
|
return res.status(404).json({ error: 'Note introuvable ou accès refusé' });
|
|
|
|
const note = result.recordset[0];
|
|
if (note.fichiers) {
|
|
try { note.sharepointFiles = JSON.parse(note.fichiers); } catch { note.sharepointFiles = []; }
|
|
} else { note.sharepointFiles = []; }
|
|
|
|
res.json(note);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// ================================================
|
|
// PUT /api/notes/:id/statut — Valider ou refuser
|
|
// ================================================
|
|
// PUT /api/notes/:id/statut — Valider ou refuser
|
|
app.put('/api/notes/:id/statut', authenticateToken, async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
const { action, commentaire, motifRefus } = req.body;
|
|
const userId = Number(req.user.id);
|
|
console.log('Validation demande', id, action, userId);
|
|
|
|
const noteResult = await pool.request()
|
|
.input('id', sql.Int, id)
|
|
.query('SELECT * FROM NoteDeFrais WHERE id = @id');
|
|
if (!noteResult.recordset.length)
|
|
return res.status(404).json({ error: 'Note non trouvée' });
|
|
|
|
const note = noteResult.recordset[0];
|
|
const n1Id = Number(note.validateurN1Id);
|
|
const n2Id = Number(note.validateurN2Id);
|
|
const statutNote = note.statut?.trim();
|
|
|
|
let nouveauStatut = null, niveauValidation = null;
|
|
|
|
if (n1Id === userId && statutNote === 'enattente') {
|
|
niveauValidation = 'N1';
|
|
nouveauStatut = action === 'valider'
|
|
? (note.validateurN2Id && n2Id !== userId ? 'validen1' : 'approuve')
|
|
: 'refuse';
|
|
} else if (n2Id === userId && statutNote === 'validen1') {
|
|
niveauValidation = 'N2';
|
|
nouveauStatut = action === 'valider' ? 'approuve' : 'refuse';
|
|
} else {
|
|
return res.status(403).json({ error: 'Non autorisé à valider cette note' });
|
|
}
|
|
|
|
const dateField = niveauValidation === 'N1' ? 'dateValidationN1' : 'dateValidationN2';
|
|
const commentaireField = niveauValidation === 'N1' ? 'commentaireN1' : 'commentaireN2';
|
|
|
|
await pool.request()
|
|
.input('id', sql.Int, id)
|
|
.input('statut', sql.NVarChar, nouveauStatut)
|
|
.input('commentaire', sql.NVarChar, commentaire ?? null)
|
|
.input('motifRefus', sql.NVarChar, motifRefus ?? null)
|
|
.query(`
|
|
UPDATE NoteDeFrais
|
|
SET statut = @statut,
|
|
${dateField} = GETDATE(),
|
|
${commentaireField} = @commentaire,
|
|
motifRefus = CASE WHEN @motifRefus IS NOT NULL THEN @motifRefus ELSE motifRefus END,
|
|
DateModification = GETDATE()
|
|
WHERE id = @id
|
|
`);
|
|
|
|
await pool.request()
|
|
.input('noteId', sql.Int, id)
|
|
.input('validateurId', sql.Int, userId)
|
|
.input('niveau', sql.NVarChar, niveauValidation)
|
|
.input('action', sql.NVarChar, action)
|
|
.input('commentaire', sql.NVarChar, commentaire ?? null)
|
|
.input('motifRefus', sql.NVarChar, motifRefus ?? null)
|
|
.input('statut', sql.NVarChar, nouveauStatut)
|
|
.query(`
|
|
INSERT INTO HistoriqueValidation
|
|
(NoteDeFraisId, ValidateurId, Niveau, Action, Commentaire, MotifRefus, NouveauStatut, DateAction)
|
|
VALUES
|
|
(@noteId, @validateurId, @niveau, @action, @commentaire, @motifRefus, @statut, GETDATE())
|
|
`);
|
|
|
|
// Génération PDF signé
|
|
let signedPdfUrl = null;
|
|
try {
|
|
const validateurSelfResult = await pool.request()
|
|
.input('id', sql.Int, userId)
|
|
.query('SELECT prenom, nom FROM CollaborateurAD WHERE id = @id');
|
|
const validateurSelf = validateurSelfResult.recordset[0];
|
|
const nomValidateurActuel = (validateurSelf
|
|
? `${validateurSelf.prenom} ${validateurSelf.nom}`
|
|
: `${req.user.prenom} ${req.user.nom}`).trim();
|
|
|
|
const noteComplete = await pool.request()
|
|
.input('id', sql.Int, id)
|
|
.query(`
|
|
SELECT n.reference, n.libelle, n.montant, n.date, n.categorie,
|
|
n.montantHT, n.tauxTVA, n.km, n.participants, n.description,
|
|
n.fichiers, n.DateCreation, n.collaborateurId, n.lignesJson,
|
|
n.commentaireN1, n.commentaireN2, n.dateValidationN1, n.dateValidationN2,
|
|
c.prenom + ' ' + c.nom AS nomPrenom,
|
|
c.prenom AS collabPrenom, c.nom AS collabNom, c.departement,
|
|
v1.prenom + ' ' + v1.nom AS nomValidateurN1,
|
|
v2.prenom + ' ' + v2.nom AS nomValidateurN2
|
|
FROM NoteDeFrais n
|
|
JOIN CollaborateurAD c ON c.id = n.collaborateurId
|
|
LEFT JOIN CollaborateurAD v1 ON v1.id = n.validateurN1Id
|
|
LEFT JOIN CollaborateurAD v2 ON v2.id = n.validateurN2Id
|
|
WHERE n.id = @id
|
|
`);
|
|
|
|
if (noteComplete.recordset.length) {
|
|
const nd = noteComplete.recordset[0];
|
|
const signatures = [
|
|
{ niveau: 'COLLAB', nomPrenom: nd.nomPrenom, date: nd.DateCreation, action: 'soumettre', commentaire: null }
|
|
];
|
|
if (niveauValidation === 'N1') {
|
|
signatures.push({ niveau: 'N1', nomPrenom: nomValidateurActuel, date: new Date(), action, commentaire: commentaire ?? null });
|
|
} else if (niveauValidation === 'N2') {
|
|
if (nd.nomValidateurN1 && nd.dateValidationN1)
|
|
signatures.push({ niveau: 'N1', nomPrenom: nd.nomValidateurN1, date: nd.dateValidationN1, action: 'valider', commentaire: nd.commentaireN1 ?? null });
|
|
signatures.push({ niveau: 'N2', nomPrenom: nomValidateurActuel, date: new Date(), action, commentaire: commentaire ?? null });
|
|
}
|
|
|
|
const moisStr = (() => {
|
|
if (!nd.date) return '';
|
|
const d = new Date(nd.date);
|
|
const m = d.toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' });
|
|
return m.charAt(0).toUpperCase() + m.slice(1);
|
|
})();
|
|
|
|
const noteDataPDF = {
|
|
reference: nd.reference, nomPrenom: nd.nomPrenom, mois: moisStr,
|
|
departement: nd.departement, lignesJson: nd.lignesJson,
|
|
tarifKm: await getTarifKm(), statut: nouveauStatut
|
|
};
|
|
|
|
const pdfSigne = await generateFicheSignee(noteDataPDF, signatures);
|
|
const suffixe = nouveauStatut === 'approuve' ? 'signe-approuve'
|
|
: nouveauStatut === 'refuse' ? 'signe-refuse' : `signe-${nouveauStatut}`;
|
|
|
|
let fichiersExistants = [];
|
|
try { fichiersExistants = JSON.parse(nd.fichiers); } catch { }
|
|
|
|
const existingFolder = fichiersExistants[0]?.folderPath;
|
|
const nomDossier = existingFolder
|
|
? existingFolder.split('/')[1]
|
|
: `${nd.collabPrenom}${nd.collabNom}`.replace(/[^a-zA-Z0-9]/g, '');
|
|
const moisDossier = existingFolder
|
|
? existingFolder.split('/')[2]
|
|
: `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`;
|
|
|
|
const signedResult = await uploadToSharePointHierarchique(
|
|
{ buffer: pdfSigne, originalname: `${nd.reference}-${suffixe}.pdf`, mimetype: 'application/pdf', size: pdfSigne.length },
|
|
nd.reference, nomDossier, moisDossier
|
|
);
|
|
fichiersExistants.push(signedResult);
|
|
|
|
await pool.request()
|
|
.input('id', sql.Int, id)
|
|
.input('fichiers', sql.NVarChar, JSON.stringify(fichiersExistants))
|
|
.query('UPDATE NoteDeFrais SET fichiers = @fichiers WHERE id = @id');
|
|
|
|
signedPdfUrl = signedResult.uploadUrl;
|
|
console.log('PDF signé uploadé:', signedResult.fileName, suffixe);
|
|
}
|
|
} catch (pdfError) {
|
|
console.error('Erreur génération PDF signé:', pdfError.message);
|
|
}
|
|
|
|
// Notifications collaborateur + validateur suivant
|
|
const frontendUrl = process.env.FRONTEND_URL || 'myndf.ensup-adm.net';
|
|
const montantFormate = parseFloat(note.montant).toFixed(2);
|
|
const collabResult = await pool.request().input('id', sql.Int, note.collaborateurId).query('SELECT id, email, prenom, nom FROM CollaborateurAD WHERE id = @id');
|
|
const validateurResult = await pool.request().input('id', sql.Int, userId).query('SELECT id, email, prenom, nom FROM CollaborateurAD WHERE id = @id');
|
|
|
|
if (collabResult.recordset.length) {
|
|
const c = collabResult.recordset[0];
|
|
const v = validateurResult.recordset[0];
|
|
const isApprouve = nouveauStatut === 'approuve';
|
|
const isValidn1 = nouveauStatut === 'validen1';
|
|
const isRefus = nouveauStatut === 'refuse';
|
|
const titreCollab = isApprouve ? `Note ${note.reference} approuvée` : isValidn1 ? `Note ${note.reference} validée N1` : `Note ${note.reference} refusée`;
|
|
const msgCollab = isApprouve
|
|
? `Votre note ${note.reference} (${montantFormate}€) a été approuvée.`
|
|
: isValidn1
|
|
? `Votre note ${note.reference} a été validée N1 par ${v?.prenom} ${v?.nom}.`
|
|
: `Votre note ${note.reference} a été refusée. Motif : ${motifRefus || commentaire || 'Non précisé'}`;
|
|
|
|
try { await creerNotification({ destinataireId: c.id, destinataireEmail: c.email, type: isRefus ? 'refus' : 'validation', titre: titreCollab, message: msgCollab, noteId: parseInt(id) }); } catch { }
|
|
|
|
// ── Email collaborateur ──────────────────────────────────────────
|
|
try {
|
|
const motifAffiche = motifRefus || commentaire || 'Non précisé';
|
|
const nomValidateur = `${v?.prenom || ''} ${v?.nom || ''}`.trim();
|
|
|
|
await sendMailGraph(
|
|
c.email,
|
|
isRefus
|
|
? `❌ Note refusée — action requise : ${note.reference}`
|
|
: titreCollab,
|
|
isRefus
|
|
? `<div style="font-family:Arial,sans-serif;max-width:620px;margin:0 auto">
|
|
<div style="background:linear-gradient(135deg,#ef4444,#dc2626);color:white;padding:24px;border-radius:12px 12px 0 0">
|
|
<h2 style="margin:0;font-size:18px">❌ Votre note de frais a été refusée</h2>
|
|
<p style="margin:8px 0 0;opacity:.85;font-size:13px">Une action de votre part est nécessaire</p>
|
|
</div>
|
|
<div style="padding:24px;background:#f8fafc;border:1px solid #e2e8f0;border-top:none;border-radius:0 0 12px 12px">
|
|
<p>Bonjour <strong>${c.prenom} ${c.nom}</strong>,</p>
|
|
<p>Votre note <strong>${note.reference}</strong> a été refusée par <strong>${nomValidateur}</strong>.</p>
|
|
|
|
<div style="background:#fef2f2;border:1.5px solid #fecaca;border-left:4px solid #ef4444;border-radius:0 8px 8px 0;padding:16px;margin:20px 0">
|
|
<div style="font-size:12px;font-weight:700;color:#991b1b;text-transform:uppercase;letter-spacing:.5px;margin-bottom:6px">Motif du refus</div>
|
|
<div style="font-size:14px;color:#dc2626;font-weight:600">${motifAffiche}</div>
|
|
</div>
|
|
|
|
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:8px;padding:16px;margin:16px 0">
|
|
<table style="width:100%;border-collapse:collapse;font-size:13px">
|
|
<tr><td style="color:#64748b;padding:4px 0;width:140px">Référence</td><td style="font-weight:700;font-family:monospace;color:#6366f1">${note.reference}</td></tr>
|
|
<tr><td style="color:#64748b;padding:4px 0">Libellé</td><td style="font-weight:600">${note.libelle}</td></tr>
|
|
<tr><td style="color:#64748b;padding:4px 0">Montant</td><td style="font-weight:700">${montantFormate} €</td></tr>
|
|
<tr><td style="color:#64748b;padding:4px 0">Refusé par</td><td>${nomValidateur}</td></tr>
|
|
<tr><td style="color:#64748b;padding:4px 0">Date</td><td>${new Date().toLocaleDateString('fr-FR', { day: '2-digit', month: 'long', year: 'numeric' })}</td></tr>
|
|
</table>
|
|
</div>
|
|
|
|
<div style="background:#eff6ff;border:1px solid #bfdbfe;border-radius:8px;padding:16px;margin:16px 0">
|
|
<div style="font-size:13px;font-weight:700;color:#1e40af;margin-bottom:8px">📝 Que faire maintenant ?</div>
|
|
<ol style="margin:0;padding-left:18px;font-size:13px;color:#1d4ed8;line-height:2">
|
|
<li>Connectez-vous à la plateforme NDF</li>
|
|
<li>Rendez-vous dans <strong>Mes notes</strong></li>
|
|
<li>Cliquez sur la note <strong>${note.reference}</strong></li>
|
|
<li>Corrigez les informations demandées</li>
|
|
<li>Resoumettez la note</li>
|
|
</ol>
|
|
</div>
|
|
|
|
<div style="text-align:center;margin-top:28px">
|
|
<a href="${frontendUrl}"
|
|
style="background:linear-gradient(135deg,#6366f1,#4f46e5);color:white;padding:14px 36px;text-decoration:none;border-radius:8px;font-weight:700;font-size:14px;display:inline-block">
|
|
✏️ Modifier ma note →
|
|
</a>
|
|
</div>
|
|
<p style="font-size:11px;color:#94a3b8;text-align:center;margin-top:16px">
|
|
Vous pouvez modifier votre note tant qu'elle est au statut "Refusée".
|
|
</p>
|
|
</div>
|
|
</div>`
|
|
: `<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto">
|
|
<div style="background:${isApprouve ? '#10b981' : '#6366f1'};color:white;padding:20px;border-radius:12px 12px 0 0">
|
|
<h2 style="margin:0">${titreCollab}</h2>
|
|
</div>
|
|
<div style="padding:20px;background:#f8fafc;border:1px solid #e2e8f0;border-top:none;border-radius:0 0 12px 12px">
|
|
<p>Bonjour <strong>${c.prenom} ${c.nom}</strong>,</p>
|
|
<p>${msgCollab}</p>
|
|
<div style="text-align:center;margin-top:24px">
|
|
<a href="${frontendUrl}" style="background:#6366f1;color:white;padding:13px 28px;text-decoration:none;border-radius:8px;font-weight:700">Voir mes notes</a>
|
|
</div>
|
|
</div>
|
|
</div>`
|
|
);
|
|
} catch { }
|
|
|
|
// Notifier N2 si validation N1
|
|
if (action === 'valider' && niveauValidation === 'N1' && note.validateurN2Id && n2Id !== userId) {
|
|
const n2Result = await pool.request().input('id', sql.Int, note.validateurN2Id).query('SELECT id, email, prenom, nom FROM CollaborateurAD WHERE id = @id');
|
|
if (n2Result.recordset.length) {
|
|
const n2 = n2Result.recordset[0];
|
|
try { await creerNotification({ destinataireId: n2.id, destinataireEmail: n2.email, type: 'validation', titre: `Note à valider N2 : ${note.reference}`, message: `La note ${note.reference} (${montantFormate}€) de ${c.prenom} ${c.nom} attend votre validation finale.`, noteId: parseInt(id) }); } catch { }
|
|
try {
|
|
await sendMailGraph(n2.email, `Note à valider N2 : ${note.reference}`, `
|
|
<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto">
|
|
<div style="background:linear-gradient(135deg,#6366f1,#4f46e5);color:white;padding:20px;border-radius:12px 12px 0 0">
|
|
<h2 style="margin:0">Note à valider — Niveau N2</h2>
|
|
</div>
|
|
<div style="padding:20px;background:#f8fafc;border:1px solid #e2e8f0;border-top:none;border-radius:0 0 12px 12px">
|
|
<p>Bonjour <strong>${n2.prenom} ${n2.nom}</strong>,</p>
|
|
<p>La note <strong>${note.reference}</strong> de ${c.prenom} ${c.nom} (${montantFormate}€) a été validée N1 et attend votre validation finale.</p>
|
|
<div style="text-align:center;margin-top:24px">
|
|
<a href="${frontendUrl}" style="background:#6366f1;color:white;padding:14px 32px;text-decoration:none;border-radius:8px;font-weight:700">Valider sur la plateforme</a>
|
|
</div>
|
|
</div>
|
|
</div>`);
|
|
} catch { }
|
|
}
|
|
}
|
|
}
|
|
|
|
res.json({ success: true, statut: nouveauStatut, niveau: niveauValidation });
|
|
} catch (error) {
|
|
console.error('Erreur validation:', error.message);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// ================================================
|
|
// GET /api/notes/:id/historique
|
|
// ================================================
|
|
app.get('/api/notes/:id/historique', authenticateToken, async (req, res) => {
|
|
try {
|
|
const result = await pool.request()
|
|
.input('noteId', sql.Int, req.params.id)
|
|
.query(`
|
|
SELECT h.id, h.Niveau, h.Action, h.Commentaire, h.MotifRefus,
|
|
h.NouveauStatut, h.DateAction,
|
|
v.prenom + ' ' + v.nom AS validateur, v.role AS roleValidateur
|
|
FROM HistoriqueValidation h
|
|
JOIN CollaborateurAD v ON v.id = h.ValidateurId
|
|
WHERE h.NoteDeFraisId = @noteId ORDER BY h.DateAction ASC
|
|
`);
|
|
res.json(result.recordset);
|
|
} catch (error) { res.status(500).json({ error: error.message }); }
|
|
});
|
|
|
|
// ================================================
|
|
// GET /api/validateur/historique
|
|
// ================================================
|
|
app.get('/api/validateur/historique', authenticateToken, async (req, res) => {
|
|
try {
|
|
const result = await pool.request()
|
|
.input('validateurId', sql.Int, req.user.id)
|
|
.query(`
|
|
SELECT h.id, h.Niveau, h.Action, h.Commentaire, h.MotifRefus,
|
|
h.NouveauStatut, h.DateAction,
|
|
n.reference, n.libelle, n.montant, n.categorie,
|
|
c.prenom + ' ' + c.nom AS collaborateur, c.departement, c.campus
|
|
FROM HistoriqueValidation h
|
|
JOIN NoteDeFrais n ON n.id = h.NoteDeFraisId
|
|
JOIN CollaborateurAD c ON c.id = n.collaborateurId
|
|
WHERE h.ValidateurId = @validateurId ORDER BY h.DateAction DESC
|
|
`);
|
|
res.json(result.recordset);
|
|
} catch (error) { res.status(500).json({ error: error.message }); }
|
|
});
|
|
|
|
// ================================================
|
|
// GET /api/notes/all — réservé Finance
|
|
// ================================================
|
|
app.get('/api/notes/all', authenticateToken, async (req, res) => {
|
|
try {
|
|
if (!hasAnyRole(req.user, 'Finance')) return res.status(403).json({ error: 'Accès réservé Finance' });
|
|
const result = await pool.request().query(`
|
|
SELECT n.*, c.nom + ' ' + c.prenom as collaborateur, c.departement, c.campus,
|
|
v1.nom + ' ' + v1.prenom as nomN1, v2.nom + ' ' + v2.prenom as nomN2
|
|
FROM NoteDeFrais n
|
|
JOIN CollaborateurAD c ON c.id = n.collaborateurId
|
|
LEFT JOIN CollaborateurAD v1 ON v1.id = n.validateurN1Id
|
|
LEFT JOIN CollaborateurAD v2 ON v2.id = n.validateurN2Id
|
|
ORDER BY n.DateCreation DESC
|
|
`);
|
|
res.json(result.recordset);
|
|
} catch (error) { res.status(500).json({ error: error.message }); }
|
|
});
|
|
|
|
// ================================================
|
|
// GET /api/admin/notes — réservé superUtilisateur
|
|
// ================================================
|
|
// GET /api/admin/notes — superUtilisateur, filtré sur ENSUP SOLUTION ET SUPPORT
|
|
app.get('/api/admin/notes', authenticateToken, async (req, res) => {
|
|
if (!hasAnyRole(req.user, 'superUtilisateur'))
|
|
return res.status(403).json({ error: 'Accès réservé superUtilisateur' });
|
|
|
|
try {
|
|
const { mois, annee, statut } = req.query;
|
|
let query = `
|
|
SELECT n.id, n.reference, n.libelle, n.montant, n.date, n.categorie, n.statut,
|
|
n.DateCreation, n.montantHT, n.tauxTVA, n.km, n.indemniteKm,
|
|
n.sharepointUrl, n.fichiers, n.lignesJson,
|
|
c.prenom + ' ' + c.nom AS collaborateur, c.email AS collaborateurEmail,
|
|
c.departement, c.campus, c.societe
|
|
FROM NoteDeFrais n
|
|
JOIN CollaborateurAD c ON c.id = n.collaborateurId
|
|
WHERE c.societe = 'ENSUP SOLUTION ET SUPPORT'
|
|
`;
|
|
const req2 = pool.request();
|
|
if (mois && annee) {
|
|
query += ` AND MONTH(n.date) = @mois AND YEAR(n.date) = @annee`;
|
|
req2.input('mois', sql.Int, parseInt(mois));
|
|
req2.input('annee', sql.Int, parseInt(annee));
|
|
}
|
|
if (statut) {
|
|
query += ` AND n.statut = @statut`;
|
|
req2.input('statut', sql.NVarChar, statut);
|
|
}
|
|
query += ` ORDER BY c.nom, n.date DESC`;
|
|
const result = await req2.query(query);
|
|
res.json(result.recordset);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// ================================================
|
|
// PAIEMENTS
|
|
// ================================================
|
|
app.get('/api/paiements/prochain', authenticateToken, async (req, res) => {
|
|
try {
|
|
const config = await pool.request().query(`SELECT TOP 1 JourPaiement FROM ConfigPaiement WHERE Actif = 1 ORDER BY Id DESC`);
|
|
const jourPaiement = config.recordset[0]?.JourPaiement ?? 20;
|
|
const today = new Date();
|
|
const jour = today.getDate(), mois = today.getMonth(), annee = today.getFullYear();
|
|
const datePaiement = jour <= jourPaiement ? new Date(annee, mois, jourPaiement) : new Date(annee, mois + 1, jourPaiement);
|
|
res.json({ jourPaiement, datePaiement: datePaiement.toISOString().split('T')[0], libelle: `Paiement le ${datePaiement.toLocaleDateString('fr-FR')}` });
|
|
} catch (error) { res.status(500).json({ error: error.message }); }
|
|
});
|
|
|
|
app.post('/api/paiements/marquer-payees', authenticateToken, async (req, res) => {
|
|
if (!hasAnyRole(req.user, 'Finance')) return res.status(403).json({ error: 'Accès réservé Finance' });
|
|
try {
|
|
const config = await pool.request().query(`SELECT TOP 1 JourPaiement FROM ConfigPaiement WHERE Actif = 1 ORDER BY Id DESC`);
|
|
const jourPaiement = config.recordset[0]?.JourPaiement ?? 20;
|
|
const today = new Date();
|
|
const mois = today.getMonth() + 1, annee = today.getFullYear();
|
|
const datePaiementExacte = new Date(annee, today.getMonth(), jourPaiement);
|
|
|
|
const notes = await pool.request().query(`
|
|
SELECT n.id, n.reference, n.montant, c.nom + ' ' + c.prenom AS collaborateur, c.email
|
|
FROM NoteDeFrais n JOIN CollaborateurAD c ON c.id = n.collaborateurId
|
|
WHERE n.statut = 'approuve' AND n.datePaiement IS NULL
|
|
`);
|
|
|
|
if (!notes.recordset.length) return res.json({ success: true, message: 'Aucune note approuvée à payer', count: 0 });
|
|
|
|
await pool.request()
|
|
.input('datePaiement', sql.DateTime, datePaiementExacte)
|
|
.input('moisPaiement', sql.Int, mois)
|
|
.input('anneePaiement', sql.Int, annee)
|
|
.query(`
|
|
UPDATE NoteDeFrais SET statut = 'payee', datePaiement = @datePaiement,
|
|
moisPaiement = @moisPaiement, anneePaiement = @anneePaiement, DateModification = GETDATE()
|
|
WHERE statut = 'approuve' AND datePaiement IS NULL
|
|
`);
|
|
|
|
res.json({ success: true, count: notes.recordset.length, datePaiement: datePaiementExacte.toISOString().split('T')[0], notes: notes.recordset });
|
|
} catch (error) { res.status(500).json({ error: error.message }); }
|
|
});
|
|
|
|
app.get('/api/paiements/historique', authenticateToken, async (req, res) => {
|
|
if (!hasAnyRole(req.user, 'Finance', 'superUtilisateur', 'ValidateurFinance', 'VerificateurFinance'))
|
|
return res.status(403).json({ error: 'Accès réservé Finance' });
|
|
|
|
try {
|
|
const mois = req.query.mois ? parseInt(req.query.mois) : null;
|
|
const annee = req.query.annee ? parseInt(req.query.annee) : null;
|
|
|
|
const request = pool.request();
|
|
request.input('mois', sql.Int, mois);
|
|
request.input('annee', sql.Int, annee);
|
|
|
|
let campusFilter = '';
|
|
if (req.user.campus && !hasAnyRole(req.user, 'superUtilisateur')) {
|
|
const campusCode = normalizeCampus(req.user.campus);
|
|
if (campusCode) {
|
|
request.input('campus', sql.NVarChar, `%${campusCode}%`);
|
|
campusFilter = 'AND c.campus LIKE @campus';
|
|
}
|
|
}
|
|
|
|
const result = await request.query(`
|
|
SELECT
|
|
n.id, n.reference, n.libelle, n.montant,
|
|
n.datePaiement, n.moisPaiement, n.anneePaiement,
|
|
n.categorie, n.lignesJson, n.indemniteKm, n.km,
|
|
n.statut,
|
|
c.nom + ' ' + c.prenom AS collaborateur,
|
|
c.campus,
|
|
c.societe
|
|
FROM NoteDeFrais n
|
|
JOIN CollaborateurAD c ON c.id = n.collaborateurId
|
|
WHERE n.statut = 'payee'
|
|
AND (@mois IS NULL OR n.moisPaiement = @mois)
|
|
AND (@annee IS NULL OR n.anneePaiement = @annee)
|
|
${campusFilter}
|
|
ORDER BY n.datePaiement DESC, c.nom
|
|
`);
|
|
|
|
const data = result.recordset.map(row => {
|
|
let montantRepas = 0, montantHebergement = 0;
|
|
let montantKilometrique = row.indemniteKm || 0;
|
|
let montantTransport = 0, montantAutres = 0;
|
|
|
|
const cat = (row.categorie || '').toLowerCase();
|
|
if (cat !== 'multiple') {
|
|
if (cat.includes('repas') || cat.includes('restaurant')) montantRepas = row.montant;
|
|
else if (cat.includes('hebergement') || cat.includes('hotel')) montantHebergement = row.montant;
|
|
else if (cat.includes('kilom') || cat.includes('km')) montantKilometrique = row.indemniteKm || row.montant;
|
|
else if (cat.includes('transport') || cat.includes('avion') || cat.includes('train') || cat.includes('taxi')) montantTransport = row.montant;
|
|
else montantAutres = row.montant;
|
|
} else {
|
|
try {
|
|
const lignes = JSON.parse(row.lignesJson || '[]');
|
|
lignes.forEach(ligne => {
|
|
const lcat = (ligne.categorie || ligne.nature || '').toLowerCase();
|
|
const montantLigne = parseFloat(ligne.montantTTC || ligne.montant || 0);
|
|
if (lcat.includes('repas') || lcat.includes('restaurant')) montantRepas += montantLigne;
|
|
else if (lcat.includes('hebergement') || lcat.includes('hotel')) montantHebergement += montantLigne;
|
|
else if (lcat.includes('kilom') || lcat.includes('km')) montantKilometrique += parseFloat(ligne.indemniteKm || montantLigne);
|
|
else if (lcat.includes('transport') || lcat.includes('avion') || lcat.includes('train') || lcat.includes('taxi')) montantTransport += montantLigne;
|
|
else montantAutres += montantLigne;
|
|
});
|
|
} catch { montantAutres = row.montant; }
|
|
}
|
|
|
|
return {
|
|
...row,
|
|
montantRepas: Math.round(montantRepas * 100) / 100,
|
|
montantHebergement: Math.round(montantHebergement * 100) / 100,
|
|
montantKilometrique: Math.round(montantKilometrique * 100) / 100,
|
|
montantTransport: Math.round(montantTransport * 100) / 100,
|
|
montantAutres: Math.round(montantAutres * 100) / 100,
|
|
};
|
|
});
|
|
|
|
res.json(data);
|
|
} catch (error) {
|
|
console.error('Erreur historique:', error.message);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
app.get('/api/paiements/config', authenticateToken, async (req, res) => {
|
|
try {
|
|
const result = await pool.request().query(`SELECT TOP 1 Id, JourPaiement, Actif, DateModif FROM ConfigPaiement WHERE Actif = 1 ORDER BY Id DESC`);
|
|
res.json(result.recordset[0] ?? { JourPaiement: 20 });
|
|
} catch (error) { res.status(500).json({ error: error.message }); }
|
|
});
|
|
|
|
app.put('/api/paiements/config', authenticateToken, async (req, res) => {
|
|
if (!hasAnyRole(req.user, 'Finance')) return res.status(403).json({ error: 'Accès réservé Finance' });
|
|
try {
|
|
const { jourPaiement } = req.body;
|
|
if (!jourPaiement || jourPaiement < 1 || jourPaiement > 28) return res.status(400).json({ error: 'Jour de paiement invalide (1-28)' });
|
|
await pool.request().query(`UPDATE ConfigPaiement SET Actif = 0 WHERE Actif = 1`);
|
|
await pool.request().input('jour', sql.Int, jourPaiement).query(`INSERT INTO ConfigPaiement (JourPaiement, Actif, DateModif) VALUES (@jour, 1, GETDATE())`);
|
|
res.json({ success: true, jourPaiement });
|
|
} catch (error) { res.status(500).json({ error: error.message }); }
|
|
});
|
|
|
|
// ================================================
|
|
// HELPERS EMAIL + NOTIFICATION
|
|
// ================================================
|
|
async function sendMailGraph(to, subject, htmlBody) {
|
|
try {
|
|
const accessToken = await getGraphToken();
|
|
if (!accessToken) throw new Error('Token Graph indisponible');
|
|
const senderEmail = process.env.MAIL_SENDER || process.env.MAIL_FROM;
|
|
if (!senderEmail) { console.error('MAIL_SENDER non défini'); return; }
|
|
|
|
await axios.post(
|
|
`https://graph.microsoft.com/v1.0/users/${senderEmail}/sendMail`,
|
|
{ message: { subject, body: { contentType: 'HTML', content: htmlBody }, from: { emailAddress: { address: senderEmail } }, toRecipients: [{ emailAddress: { address: to } }] }, saveToSentItems: false },
|
|
{ headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' } }
|
|
);
|
|
console.log(`✅ Email envoyé à ${to} via ${senderEmail}`);
|
|
} catch (error) {
|
|
console.error('Erreur sendMailGraph:', error.response?.data || error.message);
|
|
}
|
|
}
|
|
|
|
async function creerNotification({ destinataireId, destinataireEmail, type, titre, message, noteId }) {
|
|
if (!destinataireId) {
|
|
console.error('❌ creerNotification annulée : destinataireId manquant', { destinataireEmail, type, titre });
|
|
return;
|
|
}
|
|
try {
|
|
await pool.request()
|
|
.input('destinataireId', sql.Int, destinataireId)
|
|
.input('type', sql.NVarChar, type)
|
|
.input('titre', sql.NVarChar, titre)
|
|
.input('message', sql.NVarChar, message)
|
|
.input('noteId', sql.Int, noteId || null)
|
|
.query(`
|
|
INSERT INTO Notifications (CollaborateurId, Type, Titre, Message, NoteDeFraisId, Lu, DateCreation)
|
|
VALUES (@destinataireId, @type, @titre, @message, @noteId, 0, GETDATE())
|
|
`);
|
|
console.log(`🔔 Notification insérée pour ${destinataireEmail} (id: ${destinataireId})`);
|
|
} catch (err) { console.error('❌ Erreur insertion notification:', err.message); }
|
|
}
|
|
|
|
// ================================================
|
|
// PAIEMENTS — NOTIFIER
|
|
// ================================================
|
|
app.post('/api/paiements/notifier', authenticateToken, async (req, res) => {
|
|
if (!hasAnyRole(req.user, 'Finance')) return res.status(403).json({ error: 'Accès réservé Finance' });
|
|
try {
|
|
const notes = await pool.request().query(`
|
|
SELECT n.id, n.reference, n.montant, n.libelle, c.nom + ' ' + c.prenom AS collaborateur
|
|
FROM NoteDeFrais n JOIN CollaborateurAD c ON c.id = n.collaborateurId
|
|
WHERE n.statut = 'approuve' AND n.datePaiement IS NULL
|
|
`);
|
|
if (!notes.recordset.length) return res.json({ success: true, message: 'Aucune note en attente de paiement' });
|
|
|
|
const total = notes.recordset.reduce((s, n) => s + parseFloat(n.montant), 0);
|
|
const alexandre = await pool.request()
|
|
.input('email', sql.NVarChar, process.env.RESPONSABLE_PAIEMENT_EMAIL)
|
|
.query(`SELECT TOP 1 id, email, prenom, nom FROM CollaborateurAD WHERE email = @email AND Actif = 1`);
|
|
|
|
if (!alexandre.recordset.length) return res.status(404).json({ error: 'Responsable paiement introuvable' });
|
|
const responsable = alexandre.recordset[0];
|
|
|
|
const config = await pool.request().query(`SELECT TOP 1 JourPaiement FROM ConfigPaiement WHERE Actif = 1 ORDER BY Id DESC`);
|
|
const jourPaiement = config.recordset[0]?.JourPaiement ?? 20;
|
|
const titre = `⚠️ ${notes.recordset.length} note(s) à payer — ${total.toFixed(2)} €`;
|
|
|
|
await creerNotification({ destinataireId: responsable.id, destinataireEmail: responsable.email, type: 'paiement', titre, message: `${notes.recordset.length} note(s) en attente avant le ${jourPaiement}. Total : ${total.toFixed(2)} €`, noteId: null });
|
|
await sendMailGraph(responsable.email, titre, `<p>Bonjour ${responsable.prenom},</p><p>${notes.recordset.length} note(s) en attente de paiement avant le <strong>${jourPaiement}</strong>. Total : <strong>${total.toFixed(2)} €</strong></p>`);
|
|
|
|
res.json({ success: true, count: notes.recordset.length, total: parseFloat(total.toFixed(2)) });
|
|
} catch (error) { res.status(500).json({ error: error.message }); }
|
|
});
|
|
|
|
// ================================================
|
|
// NOTIFICATIONS
|
|
// ================================================
|
|
app.get('/api/notifications', authenticateToken, async (req, res) => {
|
|
try {
|
|
const result = await pool.request().input('userId', sql.Int, req.user.id).query(`
|
|
SELECT TOP 50 id, Type, Titre, Message, NoteDeFraisId, Lu, DateCreation
|
|
FROM Notifications WHERE CollaborateurId = @userId ORDER BY DateCreation DESC
|
|
`);
|
|
res.json(result.recordset);
|
|
} catch (error) { res.status(500).json({ error: error.message }); }
|
|
});
|
|
|
|
app.put('/api/notifications/:id/lu', authenticateToken, async (req, res) => {
|
|
try {
|
|
await pool.request().input('id', sql.Int, req.params.id).input('userId', sql.Int, req.user.id)
|
|
.query(`UPDATE Notifications SET Lu = 1 WHERE id = @id AND CollaborateurId = @userId`);
|
|
res.json({ success: true });
|
|
} catch (error) { res.status(500).json({ error: error.message }); }
|
|
});
|
|
|
|
// ================================================
|
|
// CATÉGORIES / PARAMÈTRES TVA / KM
|
|
// ================================================
|
|
app.get('/api/categories', authenticateToken, async (req, res) => {
|
|
try {
|
|
const result = await pool.request().query(`
|
|
SELECT c.*, p.PlafondParPersonne, p.DescriptionPlafond
|
|
FROM CategorieNDF c LEFT JOIN PlafondRepas p ON p.CategorieId = c.id AND p.Actif = 1
|
|
WHERE c.Actif = 1 ORDER BY c.Ordre, c.Nom
|
|
`);
|
|
res.json(result.recordset);
|
|
} catch (error) { res.status(500).json({ error: error.message }); }
|
|
});
|
|
|
|
app.get('/api/parametres/tva', authenticateToken, async (req, res) => {
|
|
try {
|
|
const result = await pool.request().query(`
|
|
SELECT id, taux, libelle, categorie FROM ParametresTVA
|
|
WHERE actif = 1 AND dateDebut <= GETDATE() AND (dateFin IS NULL OR dateFin >= GETDATE())
|
|
ORDER BY taux ASC
|
|
`);
|
|
res.json(result.recordset);
|
|
} catch (err) { res.status(500).json({ error: err.message }); }
|
|
});
|
|
|
|
app.get('/api/parametres/km', authenticateToken, async (req, res) => {
|
|
try {
|
|
const annee = new Date().getFullYear();
|
|
const result = await pool.request().input('annee', sql.Int, annee).query(`
|
|
SELECT TOP 1 tarifParKm FROM ParametresKm WHERE annee = @annee AND actif = 1 ORDER BY DateCreation DESC
|
|
`);
|
|
res.json({ tarifKm: result.recordset[0]?.tarifParKm ?? await getTarifKm() });
|
|
} catch (err) { res.status(500).json({ error: err.message }); }
|
|
});
|
|
|
|
// ================================================
|
|
// QR CODE UPLOAD MOBILE
|
|
// ================================================
|
|
app.post('/api/upload/generate-link', authenticateToken, async (req, res) => {
|
|
try {
|
|
const { noteRef } = req.body;
|
|
if (!noteRef) return res.status(400).json({ error: 'noteRef obligatoire' });
|
|
|
|
const collabResult = await pool.request().input('id', sql.Int, req.user.id).query('SELECT nom, prenom FROM CollaborateurAD WHERE id = @id');
|
|
if (!collabResult.recordset.length) return res.status(404).json({ error: 'Collaborateur introuvable' });
|
|
|
|
const { nom, prenom } = collabResult.recordset[0];
|
|
const token = crypto.randomBytes(32).toString('hex');
|
|
|
|
await pool.request()
|
|
.input('token', sql.VarChar, token)
|
|
.input('nomPrenom', sql.NVarChar, `${nom.toUpperCase()}_${prenom}`)
|
|
.input('noteRef', sql.NVarChar, noteRef)
|
|
.query(`INSERT INTO UploadTokens (token, nomPrenom, noteRef, expiresAt) VALUES (@token, @nomPrenom, @noteRef, DATEADD(MINUTE, 120, GETDATE()))`);
|
|
|
|
res.json({ uploadLink: `${process.env.FRONTEND_URL}/upload/${token}`, expiresAt: new Date(Date.now() + 120 * 60 * 1000), token });
|
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
});
|
|
|
|
app.get('/api/upload/check/:token', async (req, res) => {
|
|
try {
|
|
const valid = await pool.request().input('token', sql.VarChar, req.params.token)
|
|
.query(`SELECT * FROM UploadTokens WHERE token = @token AND used = 0 AND expiresAt > GETDATE()`);
|
|
if (!valid.recordset.length) return res.status(410).json({ error: 'Lien expiré ou déjà utilisé' });
|
|
const t = valid.recordset[0];
|
|
res.json({ valid: true, noteRef: t.noteRef, nomPrenom: t.nomPrenom });
|
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
});
|
|
|
|
app.post('/api/upload/submit/:token', upload.array('files', 10), async (req, res) => {
|
|
try {
|
|
const result = await pool.request().input('token', sql.VarChar, req.params.token)
|
|
.query(`SELECT * FROM UploadTokens WHERE token = @token AND used = 0 AND expiresAt > GETDATE()`);
|
|
if (!result.recordset.length) return res.status(410).json({ error: 'Lien expiré ou déjà utilisé' });
|
|
|
|
const { nomPrenom, noteRef } = result.recordset[0];
|
|
const files = req.files;
|
|
if (!files || !files.length) return res.status(400).json({ error: 'Aucun fichier reçu' });
|
|
|
|
const uploaded = [];
|
|
for (const file of files) {
|
|
const r = await uploadToSharePoint(file, noteRef, nomPrenom);
|
|
uploaded.push(r);
|
|
}
|
|
|
|
await pool.request()
|
|
.input('token', sql.VarChar, req.params.token)
|
|
.input('firstUrl', sql.NVarChar, uploaded[0].uploadUrl)
|
|
.input('allFiles', sql.NVarChar, JSON.stringify(uploaded))
|
|
.query(`UPDATE UploadTokens SET used = 1, sharepointUrl = @firstUrl, fichiers = @allFiles WHERE token = @token`);
|
|
|
|
res.json({ success: true, uploaded });
|
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
});
|
|
|
|
app.get('/api/upload/status/:noteRef', async (req, res) => {
|
|
try {
|
|
const result = await pool.request().input('noteRef', sql.NVarChar, req.params.noteRef)
|
|
.query(`SELECT TOP 1 used, fichiers, sharepointUrl FROM UploadTokens WHERE noteRef = @noteRef ORDER BY expiresAt DESC`);
|
|
if (!result.recordset.length) return res.json({ uploaded: false });
|
|
const row = result.recordset[0];
|
|
const fichiers = row.fichiers ? JSON.parse(row.fichiers) : [];
|
|
res.json({
|
|
uploaded: row.used === true || row.used === 1,
|
|
files: fichiers, // ← ajout pour le frontend
|
|
fichiers: fichiers, // ← conservé pour rétrocompat
|
|
sharepointUrl: row.sharepointUrl
|
|
});
|
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
});
|
|
|
|
|
|
// GET /api/notes-all — réservé Finance (filtré par campus) + superUtilisateur (ENSUP SOLUTION ET SUPPORT)
|
|
|
|
app.get('/api/notes-all', authenticateToken, async (req, res) => {
|
|
try {
|
|
if (!hasAnyRole(req.user, 'Finance', 'ValidateurFinance'))
|
|
return res.status(403).json({ error: 'Accès réservé Finance' });
|
|
|
|
const request = pool.request();
|
|
let campusWhere = '';
|
|
if (req.user.campus) {
|
|
const campusCode = normalizeCampus(req.user.campus);
|
|
if (campusCode) {
|
|
request.input('campus', sql.NVarChar, `%${campusCode}%`);
|
|
campusWhere = `AND c.campus LIKE @campus`;
|
|
}
|
|
}
|
|
|
|
const statutFilter = hasAnyRole(req.user, 'ValidateurFinance') && !hasAnyRole(req.user, 'Finance')
|
|
? `AND LOWER(n.statut) IN ('verifie', 'paiementenattente', 'payee')`
|
|
: `AND LOWER(REPLACE(n.statut COLLATE Latin1_General_CI_AI, ' ', '')) IN (
|
|
'approuve', 'approuv', 'verifie', 'paiementenattente', 'paiement_en_attente', 'payee'
|
|
)`;
|
|
|
|
const result = await request.query(`
|
|
SELECT n.*,
|
|
|
|
c.nom + ' ' + c.prenom AS collaborateur,
|
|
c.departement, c.campus, c.societe,
|
|
v1.nom + ' ' + v1.prenom AS nomN1,
|
|
v2.nom + ' ' + v2.prenom AS nomN2,
|
|
vf.nom + ' ' + vf.prenom AS nomVerificateur,
|
|
n.dateVerification, n.commentaireVerification
|
|
FROM NoteDeFrais n
|
|
JOIN CollaborateurAD c ON c.id = n.collaborateurId
|
|
LEFT JOIN CollaborateurAD v1 ON v1.id = n.validateurN1Id
|
|
LEFT JOIN CollaborateurAD v2 ON v2.id = n.validateurN2Id
|
|
LEFT JOIN CollaborateurAD vf ON vf.id = n.verificateurFinanceId
|
|
WHERE 1=1 ${statutFilter} ${campusWhere}
|
|
ORDER BY n.DateCreation DESC
|
|
`);
|
|
|
|
res.json(result.recordset);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
|
|
// POST /api/paiements/generer-xml
|
|
// Body: { noteIds: number[] }
|
|
// POST /api/paiements/generer-xml — Génère le XML PAIN.001 et passe statut à 'paiementenattente'
|
|
app.post('/api/paiements/generer-xml', authenticateToken, async (req, res) => {
|
|
if (!hasAnyRole(req.user, 'Finance', 'ValidateurFinance', 'superUtilisateur'))
|
|
return res.status(403).json({ error: 'Accès réservé Finance' });
|
|
|
|
const { noteIds } = req.body;
|
|
if (!Array.isArray(noteIds) || noteIds.length === 0)
|
|
return res.status(400).json({ error: 'Aucune note sélectionnée' });
|
|
|
|
try {
|
|
const idList = noteIds.map(id => parseInt(id)).filter(Boolean).join(',');
|
|
|
|
const notes = await pool.request().query(`
|
|
SELECT n.id, n.reference, n.montant, n.libelle, n.date,
|
|
n.fichiers, n.lignesJson, n.categorie, n.DateCreation,
|
|
c.nom, c.prenom, c.iban, c.bic, c.campus, c.societe,
|
|
c.adresse_rue, c.adresse_cp, c.adresse_ville, c.adresse_pays,
|
|
c.id AS collabId, c.email
|
|
FROM NoteDeFrais n
|
|
JOIN CollaborateurAD c ON c.id = n.collaborateurId
|
|
WHERE n.id IN (${idList})
|
|
AND n.statut IN ('approuve', 'approuvé', 'verifie')
|
|
`);
|
|
|
|
if (!notes.recordset.length)
|
|
return res.status(404).json({ error: 'Aucune note approuvée/vérifiée trouvée' });
|
|
|
|
const erreurs = [];
|
|
for (const n of notes.recordset) {
|
|
const checks = await pool.request()
|
|
.input('collabId', sql.Int, n.collabId)
|
|
.query(`
|
|
SELECT IBAN, BIC, adresse_rue, adresse_cp, adresse_ville, adresse_pays
|
|
FROM CollaborateurAD
|
|
WHERE id = @collabId
|
|
`);
|
|
const c = checks.recordset[0];
|
|
if (!c) { erreurs.push(`${n.reference} : collaborateur introuvable`); continue; }
|
|
if (!c.IBAN) erreurs.push(`${n.reference} (${n.prenom} ${n.nom}) : IBAN manquant`);
|
|
if (!c.BIC) erreurs.push(`${n.reference} (${n.prenom} ${n.nom}) : BIC manquant`);
|
|
if (!c.adresse_rue || !c.adresse_cp || !c.adresse_ville || !c.adresse_pays)
|
|
erreurs.push(`${n.reference} (${n.prenom} ${n.nom}) : adresse postale incomplète`);
|
|
}
|
|
if (erreurs.length > 0)
|
|
return res.status(422).json({
|
|
error: 'Données manquantes — XML non généré',
|
|
details: erreurs
|
|
});
|
|
|
|
const now = new Date();
|
|
const annee = now.getFullYear();
|
|
const mois = String(now.getMonth() + 1).padStart(2, '0');
|
|
const todayISO = now.toISOString().split('T')[0];
|
|
const creDtTm = now.toISOString().slice(0, 19);
|
|
const msgId = `NDF-${annee}${mois}-${Date.now().toString().slice(-7)}`;
|
|
const total = notes.recordset.reduce((s, n) => s + parseFloat(n.montant), 0);
|
|
const totalFormate = total.toFixed(2);
|
|
|
|
// ── Config débiteur depuis .env ──────────────────────────────────
|
|
const cfg = await getConfigDebiteur();
|
|
const dbtrNom = cfg.companyName;
|
|
const dbtrIban = cfg.companyIban;
|
|
const dbtrBic = cfg.companyBic;
|
|
const dbtrAdrLine = cfg.companyAddress;
|
|
const dbtrCp = cfg.companyCp;
|
|
const dbtrVille = cfg.companyVille;
|
|
const dbtrPays = cfg.companyPays;
|
|
|
|
// ── Générer les transactions ──────────────────────────────────────
|
|
let transactions = '';
|
|
let numTx = 1;
|
|
|
|
for (const n of notes.recordset) {
|
|
let ibanClair = 'FR0000000000000000000000000';
|
|
try {
|
|
if (n.iban && n.iban.includes(':')) ibanClair = decryptIban(n.iban);
|
|
else if (n.iban) ibanClair = n.iban;
|
|
} catch (e) {
|
|
console.warn(`⚠️ Déchiffrement IBAN impossible pour ${n.reference}:`, e.message);
|
|
}
|
|
|
|
const benefNom = `${n.nom.toUpperCase()} ${n.prenom}`;
|
|
const adrLine = (n.adresse_rue || '').toUpperCase();
|
|
const cp = n.adresse_cp || '';
|
|
const ville = (n.adresse_ville || '').toUpperCase();
|
|
const pays = (n.adresse_pays || 'FR').slice(0, 2).toUpperCase();
|
|
|
|
// BIC bénéficiaire : si présent utiliser, sinon NOTPROVIDED
|
|
const benefBicBlock = n.bic
|
|
? `<FinInstnId><BIC>${n.bic}</BIC></FinInstnId>`
|
|
: `<FinInstnId><Othr><Id>NOTPROVIDED</Id></Othr></FinInstnId>`;
|
|
|
|
transactions += `
|
|
<CdtTrfTxInf>
|
|
<PmtId>
|
|
<InstrId>VIREMENT NUM:${n.reference}</InstrId>
|
|
<EndToEndId>${n.reference}</EndToEndId>
|
|
</PmtId>
|
|
<Amt>
|
|
<InstdAmt Ccy="EUR">${parseFloat(n.montant).toFixed(2)}</InstdAmt>
|
|
</Amt>
|
|
<CdtrAgt>
|
|
${benefBicBlock}
|
|
</CdtrAgt>
|
|
<Cdtr>
|
|
<Nm>${benefNom}</Nm>${adrLine || cp || ville ? `
|
|
<PstlAdr>${cp ? `
|
|
<PstCd>${cp}</PstCd>` : ''}${ville ? `
|
|
<TwnNm>${ville}</TwnNm>` : ''}
|
|
<Ctry>${pays}</Ctry>${adrLine ? `
|
|
<AdrLine>${adrLine}</AdrLine>` : ''}
|
|
</PstlAdr>` : ''}
|
|
<CtryOfRes>${pays}</CtryOfRes>
|
|
</Cdtr>
|
|
<CdtrAcct>
|
|
<Id>
|
|
<IBAN>${ibanClair}</IBAN>
|
|
</Id>
|
|
</CdtrAcct>
|
|
</CdtTrfTxInf>`;
|
|
numTx++;
|
|
}
|
|
|
|
// ── XML final au format PAIN.001.001.03 ──────────────────────────
|
|
const xml = `<?xml version="1.0" encoding="iso-8859-1"?>
|
|
<Document xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="urn:iso:std:iso:20022:tech:xsd:pain.001.001.03">
|
|
<CstmrCdtTrfInitn>
|
|
<GrpHdr>
|
|
<MsgId>${msgId}</MsgId>
|
|
<CreDtTm>${creDtTm}</CreDtTm>
|
|
<NbOfTxs>${notes.recordset.length}</NbOfTxs>
|
|
<CtrlSum>${totalFormate}</CtrlSum>
|
|
<InitgPty>
|
|
<Nm>${dbtrNom}</Nm>
|
|
</InitgPty>
|
|
</GrpHdr>
|
|
<PmtInf>
|
|
<PmtInfId>${msgId}</PmtInfId>
|
|
<PmtMtd>TRF</PmtMtd>
|
|
<BtchBookg>true</BtchBookg>
|
|
<NbOfTxs>${notes.recordset.length}</NbOfTxs>
|
|
<CtrlSum>${totalFormate}</CtrlSum>
|
|
<PmtTpInf>
|
|
<SvcLvl>
|
|
<Cd>SEPA</Cd>
|
|
</SvcLvl>
|
|
</PmtTpInf>
|
|
<ReqdExctnDt>${todayISO}</ReqdExctnDt>
|
|
<Dbtr>
|
|
<Nm>${dbtrNom}</Nm>
|
|
<PstlAdr>
|
|
<PstCd>${dbtrCp}</PstCd>
|
|
<TwnNm>${dbtrVille}</TwnNm>
|
|
<Ctry>${dbtrPays}</Ctry>
|
|
<AdrLine>${dbtrAdrLine}</AdrLine>
|
|
</PstlAdr>
|
|
</Dbtr>
|
|
<DbtrAcct>
|
|
<Id>
|
|
<IBAN>${dbtrIban}</IBAN>
|
|
</Id>
|
|
</DbtrAcct>
|
|
<DbtrAgt>
|
|
<FinInstnId>
|
|
<Othr>
|
|
<Id>NOTPROVIDED</Id>
|
|
</Othr>
|
|
</FinInstnId>
|
|
</DbtrAgt>
|
|
<ChrgBr>SLEV</ChrgBr>${transactions}
|
|
</PmtInf>
|
|
</CstmrCdtTrfInitn>
|
|
</Document>`;
|
|
|
|
// ── Upload XML sur SharePoint dans Virements/{annee}/{mois}/ ─────
|
|
let xmlSharepointUrl = null;
|
|
try {
|
|
const xmlFileName = `virements-ndf-${annee}-${mois}-${Date.now().toString().slice(-5)}.xml`;
|
|
const xmlFolderPath = `Virements/${annee}/${mois}`;
|
|
const xmlUploadPath = `${xmlFolderPath}/${xmlFileName}`;
|
|
|
|
const accessToken = await getGraphToken();
|
|
if (accessToken) {
|
|
const spRes = await axios.put(
|
|
`https://graph.microsoft.com/v1.0/sites/${SHAREPOINT_CONFIG.siteId}/drives/${SHAREPOINT_CONFIG.driveId}/root:/${xmlUploadPath}:/content`,
|
|
Buffer.from(xml, 'utf-8'),
|
|
{
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
'Content-Type': 'application/xml'
|
|
},
|
|
maxBodyLength: Infinity
|
|
}
|
|
);
|
|
xmlSharepointUrl = spRes.data.webUrl;
|
|
console.log(`✅ XML virement uploadé sur SharePoint : ${xmlUploadPath}`);
|
|
}
|
|
} catch (spErr) {
|
|
console.error('⚠️ Upload XML SharePoint échoué (XML quand même téléchargé) :', spErr.message);
|
|
}
|
|
|
|
// ── Générer les PDF récap pour chaque note ────────────────────────
|
|
for (const note of notes.recordset) {
|
|
try {
|
|
let fichiersExistants = [];
|
|
try { fichiersExistants = JSON.parse(note.fichiers || '[]'); } catch { }
|
|
|
|
const justifFiles = [];
|
|
for (const f of fichiersExistants) {
|
|
const name = (f.fileName || '').toLowerCase();
|
|
if (name.includes('soumission') || name.includes('resoumission') || name.includes('recap')) continue;
|
|
try {
|
|
const buf = await downloadFromSharePoint(f.uploadUrl);
|
|
const mimetype = name.endsWith('.pdf') ? 'application/pdf'
|
|
: name.endsWith('.png') ? 'image/png' : 'image/jpeg';
|
|
justifFiles.push({ buffer: buf, originalname: f.fileName, mimetype, size: buf.length });
|
|
} catch (e) { console.warn(`⚠️ Justif non récupérable: ${f.fileName}`, e.message); }
|
|
}
|
|
|
|
const dateObj = new Date(note.date);
|
|
const moisStr = dateObj.toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' });
|
|
const moisCapitalized = moisStr.charAt(0).toUpperCase() + moisStr.slice(1);
|
|
const nomPrenom = `${note.nom.toUpperCase()} ${note.prenom}`;
|
|
|
|
const histResult = await pool.request()
|
|
.input('noteId', sql.Int, note.id)
|
|
.query(`
|
|
SELECT h.Niveau, h.Action, h.Commentaire, h.DateAction,
|
|
c.prenom + ' ' + c.nom AS nomPrenom
|
|
FROM HistoriqueValidation h
|
|
JOIN CollaborateurAD c ON c.id = h.ValidateurId
|
|
WHERE h.NoteDeFraisId = @noteId
|
|
ORDER BY h.DateAction ASC
|
|
`);
|
|
|
|
const signatures = [
|
|
{ niveau: 'COLLAB', nomPrenom, date: note.DateCreation || new Date(), action: 'soumettre', commentaire: null },
|
|
...histResult.recordset.map(h => ({
|
|
niveau: h.Niveau, nomPrenom: h.nomPrenom,
|
|
date: h.DateAction, action: h.Action, commentaire: h.Commentaire || null
|
|
}))
|
|
];
|
|
|
|
const noteDataPDF = {
|
|
reference: note.reference, nomPrenom, mois: moisCapitalized,
|
|
date: note.date, categorie: note.categorie || 'Multiple',
|
|
libelle: note.libelle, montant: parseFloat(note.montant),
|
|
lignesJson: note.lignesJson, tarifKm: await getTarifKm(),
|
|
statut: note.statut, departement: note.departement,
|
|
};
|
|
|
|
const recapBuffer = await generateRecapWithJustifs(noteDataPDF, justifFiles, signatures);
|
|
|
|
const existingFolder = fichiersExistants[0]?.folderPath;
|
|
const nomDossier = existingFolder
|
|
? existingFolder.split('/')[1]
|
|
: `${note.prenom}_${note.nom}`.replace(/[^a-zA-Z0-9]/g, '_');
|
|
const moisDossier = existingFolder
|
|
? existingFolder.split('/')[2]
|
|
: `${annee}-${mois}`;
|
|
|
|
const recapResult = await uploadToSharePointHierarchique(
|
|
{
|
|
buffer: recapBuffer,
|
|
originalname: `${note.reference}_recap-paiement.pdf`,
|
|
mimetype: 'application/pdf',
|
|
size: recapBuffer.length
|
|
},
|
|
note.reference, nomDossier, moisDossier
|
|
);
|
|
|
|
fichiersExistants.push(recapResult);
|
|
|
|
await pool.request()
|
|
.input('id', sql.Int, note.id)
|
|
.input('fichiers', sql.NVarChar, JSON.stringify(fichiersExistants))
|
|
.input('recapUrl', sql.NVarChar, recapResult.uploadUrl)
|
|
.query(`
|
|
UPDATE NoteDeFrais
|
|
SET fichiers = @fichiers,
|
|
sharepointUrl = @recapUrl,
|
|
DateModification = GETDATE()
|
|
WHERE id = @id
|
|
`);
|
|
|
|
console.log(`✅ PDF récap-paiement généré pour ${note.reference}`);
|
|
} catch (pdfErr) {
|
|
console.error(`❌ PDF récap ${note.reference}:`, pdfErr.message);
|
|
}
|
|
}
|
|
|
|
// ── Passer en 'paiementenattente' + enregistrer date XML ─────────
|
|
await pool.request()
|
|
.input('dateXml', sql.DateTime, now)
|
|
.input('xmlUrl', sql.NVarChar, xmlSharepointUrl || null)
|
|
.query(`
|
|
UPDATE NoteDeFrais
|
|
SET statut = 'paiementenattente',
|
|
dateXml = @dateXml,
|
|
DateModification = GETDATE()
|
|
WHERE id IN (${idList})
|
|
AND statut IN ('approuve', 'approuvé', 'verifie')
|
|
`);
|
|
|
|
// ── Notifier chaque collaborateur ─────────────────────────────────
|
|
for (const n of notes.recordset) {
|
|
try {
|
|
await creerNotification({
|
|
destinataireId: n.collabId,
|
|
destinataireEmail: n.email,
|
|
type: 'paiement',
|
|
titre: `Paiement en cours de traitement : ${n.reference}`,
|
|
message: `Votre note ${n.reference} de ${parseFloat(n.montant).toFixed(2)} € est en cours de traitement bancaire.`,
|
|
noteId: n.id
|
|
});
|
|
} catch (e) { console.error('Notif paiementenattente:', e.message); }
|
|
}
|
|
|
|
// ── Téléchargement du XML côté client ────────────────────────────
|
|
const xmlFileName = `virements-ndf-${annee}-${mois}-${todayISO}.xml`;
|
|
res.setHeader('Content-Type', 'application/xml; charset=iso-8859-1');
|
|
res.setHeader('Content-Disposition', `attachment; filename=${xmlFileName}`);
|
|
res.send(xml);
|
|
|
|
} catch (error) {
|
|
console.error('Erreur génération XML:', error.message);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// GET /api/paiements/xml-historique — liste les XML générés
|
|
app.get('/api/paiements/xml-historique', authenticateToken, async (req, res) => {
|
|
if (!hasAnyRole(req.user, 'Finance', 'ValidateurFinance', 'superUtilisateur'))
|
|
return res.status(403).json({ error: 'Accès réservé Finance' });
|
|
|
|
try {
|
|
const { annee, mois } = req.query;
|
|
|
|
const request = pool.request();
|
|
let where = `WHERE n.dateXml IS NOT NULL AND n.statut IN ('paiementenattente', 'payee')`;
|
|
|
|
if (annee) {
|
|
request.input('annee', sql.Int, parseInt(annee));
|
|
where += ` AND YEAR(n.dateXml) = @annee`;
|
|
}
|
|
if (mois) {
|
|
request.input('mois', sql.Int, parseInt(mois));
|
|
where += ` AND MONTH(n.dateXml) = @mois`;
|
|
}
|
|
|
|
let campusWhere = '';
|
|
if (req.user.campus && !hasAnyRole(req.user, 'superUtilisateur')) {
|
|
const campusCode = normalizeCampus(req.user.campus);
|
|
if (campusCode) {
|
|
request.input('campus', sql.NVarChar, `%${campusCode}%`);
|
|
campusWhere = `AND c.campus LIKE @campus`;
|
|
}
|
|
}
|
|
|
|
const result = await request.query(`
|
|
SELECT
|
|
CAST(n.dateXml AS DATE) AS dateXmlJour,
|
|
MIN(n.dateXml) AS dateXmlExacte,
|
|
COUNT(*) AS nbNotes,
|
|
SUM(n.montant) AS totalMontant,
|
|
STRING_AGG(CAST(n.id AS NVARCHAR(20)), ',') AS noteIds,
|
|
STRING_AGG(n.reference, ', ') AS listeReferences
|
|
FROM NoteDeFrais n
|
|
JOIN CollaborateurAD c ON c.id = n.collaborateurId
|
|
${where} ${campusWhere}
|
|
GROUP BY CAST(n.dateXml AS DATE)
|
|
ORDER BY CAST(n.dateXml AS DATE) DESC
|
|
`);
|
|
|
|
res.json(result.recordset);
|
|
} catch (error) {
|
|
console.error('GET /api/paiements/xml-historique:', error.message);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// POST /api/paiements/regenerer-xml — régénère le XML pour un batch
|
|
app.post('/api/paiements/regenerer-xml', authenticateToken, async (req, res) => {
|
|
if (!hasAnyRole(req.user, 'Finance', 'ValidateurFinance', 'superUtilisateur'))
|
|
return res.status(403).json({ error: 'Accès réservé Finance' });
|
|
|
|
const { noteIds } = req.body;
|
|
if (!Array.isArray(noteIds) || noteIds.length === 0)
|
|
return res.status(400).json({ error: 'Aucune note sélectionnée' });
|
|
|
|
try {
|
|
const idList = noteIds.map(id => parseInt(id)).filter(Boolean).join(',');
|
|
|
|
const notes = await pool.request().query(`
|
|
SELECT n.id, n.reference, n.montant, n.libelle, n.dateXml,
|
|
c.nom, c.prenom, c.iban, c.bic
|
|
FROM NoteDeFrais n
|
|
JOIN CollaborateurAD c ON c.id = n.collaborateurId
|
|
WHERE n.id IN (${idList})
|
|
AND n.statut IN ('paiementenattente', 'payee')
|
|
`);
|
|
|
|
if (!notes.recordset.length)
|
|
return res.status(404).json({ error: 'Notes introuvables' });
|
|
|
|
const now = new Date();
|
|
const annee = now.getFullYear();
|
|
const mois = String(now.getMonth() + 1).padStart(2, '0');
|
|
const todayISO = now.toISOString().split('T')[0];
|
|
const creDtTm = now.toISOString().slice(0, 19);
|
|
const msgId = `NDF-REGEN-${annee}${mois}-${Date.now().toString().slice(-7)}`;
|
|
const total = notes.recordset.reduce((s, n) => s + parseFloat(n.montant), 0).toFixed(2);
|
|
const cfg = await getConfigDebiteur();
|
|
const dbtrNom = cfg.companyName;
|
|
const dbtrIban = cfg.companyIban;
|
|
const dbtrBic = cfg.companyBic;
|
|
const dbtrAdrLine = cfg.companyAddress;
|
|
const dbtrCp = cfg.companyCp;
|
|
const dbtrVille = cfg.companyVille;
|
|
const dbtrPays = cfg.companyPays;
|
|
|
|
let transactions = '';
|
|
for (const n of notes.recordset) {
|
|
let ibanClair = 'FR0000000000000000000000000';
|
|
try {
|
|
if (n.iban && n.iban.includes(':')) ibanClair = decryptIban(n.iban);
|
|
else if (n.iban) ibanClair = n.iban;
|
|
} catch { }
|
|
|
|
const benefNom = `${n.nom.toUpperCase()} ${n.prenom}`;
|
|
const benefBicBlock = n.bic
|
|
? `<FinInstnId><BIC>${n.bic}</BIC></FinInstnId>`
|
|
: `<FinInstnId><Othr><Id>NOTPROVIDED</Id></Othr></FinInstnId>`;
|
|
|
|
transactions += `
|
|
<CdtTrfTxInf>
|
|
<PmtId>
|
|
<InstrId>VIREMENT NUM:${n.reference}</InstrId>
|
|
<EndToEndId>${n.reference}</EndToEndId>
|
|
</PmtId>
|
|
<Amt>
|
|
<InstdAmt Ccy="EUR">${parseFloat(n.montant).toFixed(2)}</InstdAmt>
|
|
</Amt>
|
|
<CdtrAgt>${benefBicBlock}</CdtrAgt>
|
|
<Cdtr><Nm>${benefNom}</Nm></Cdtr>
|
|
<CdtrAcct><Id><IBAN>${ibanClair}</IBAN></Id></CdtrAcct>
|
|
</CdtTrfTxInf>`;
|
|
}
|
|
|
|
const xml = `<?xml version="1.0" encoding="iso-8859-1"?>
|
|
<Document xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="urn:iso:std:iso:20022:tech:xsd:pain.001.001.03">
|
|
<CstmrCdtTrfInitn>
|
|
<GrpHdr>
|
|
<MsgId>${msgId}</MsgId>
|
|
<CreDtTm>${creDtTm}</CreDtTm>
|
|
<NbOfTxs>${notes.recordset.length}</NbOfTxs>
|
|
<CtrlSum>${total}</CtrlSum>
|
|
<InitgPty><Nm>${dbtrNom}</Nm></InitgPty>
|
|
</GrpHdr>
|
|
<PmtInf>
|
|
<PmtInfId>${msgId}</PmtInfId>
|
|
<PmtMtd>TRF</PmtMtd>
|
|
<BtchBookg>true</BtchBookg>
|
|
<NbOfTxs>${notes.recordset.length}</NbOfTxs>
|
|
<CtrlSum>${total}</CtrlSum>
|
|
<PmtTpInf><SvcLvl><Cd>SEPA</Cd></SvcLvl></PmtTpInf>
|
|
<ReqdExctnDt>${todayISO}</ReqdExctnDt>
|
|
<Dbtr>
|
|
<Nm>${dbtrNom}</Nm>
|
|
<PstlAdr>
|
|
<PstCd>${dbtrCp}</PstCd>
|
|
<TwnNm>${dbtrVille}</TwnNm>
|
|
<Ctry>${dbtrPays}</Ctry>
|
|
<AdrLine>${dbtrAdrLine}</AdrLine>
|
|
</PstlAdr>
|
|
</Dbtr>
|
|
<DbtrAcct><Id><IBAN>${dbtrIban}</IBAN></Id></DbtrAcct>
|
|
<DbtrAgt><FinInstnId><Othr><Id>NOTPROVIDED</Id></Othr></FinInstnId></DbtrAgt>
|
|
<ChrgBr>SLEV</ChrgBr>${transactions}
|
|
</PmtInf>
|
|
</CstmrCdtTrfInitn>
|
|
</Document>`;
|
|
|
|
const xmlFileName = `virements-ndf-REGEN-${todayISO}-${Date.now().toString().slice(-5)}.xml`;
|
|
res.setHeader('Content-Type', 'application/xml; charset=iso-8859-1');
|
|
res.setHeader('Content-Disposition', `attachment; filename=${xmlFileName}`);
|
|
res.send(xml);
|
|
|
|
} catch (error) {
|
|
console.error('Erreur régénération XML:', error.message);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// GET /api/paiements/config-debiteur
|
|
app.get('/api/paiements/config-debiteur', authenticateToken, async (req, res) => {
|
|
if (!hasAnyRole(req.user, 'Finance', 'superUtilisateur'))
|
|
return res.status(403).json({ error: 'Accès réservé Finance' });
|
|
try {
|
|
const result = await pool.request().query(`
|
|
SELECT TOP 1 id, companyName, companyIban, companyBic,
|
|
companyAddress, companyCp, companyVille, companyPays,
|
|
DateModification
|
|
FROM ConfigDebiteurXML WHERE actif = 1
|
|
ORDER BY DateModification DESC
|
|
`);
|
|
res.json(result.recordset[0] ?? null);
|
|
} catch (e) {
|
|
res.status(500).json({ error: e.message });
|
|
}
|
|
});
|
|
|
|
// PUT /api/paiements/config-debiteur
|
|
app.put('/api/paiements/config-debiteur', authenticateToken, async (req, res) => {
|
|
if (!hasAnyRole(req.user, 'Finance', 'superUtilisateur'))
|
|
return res.status(403).json({ error: 'Accès réservé Finance' });
|
|
|
|
const { companyName, companyIban, companyBic, companyAddress, companyCp, companyVille, companyPays } = req.body;
|
|
|
|
if (!companyName || !companyIban || !companyBic)
|
|
return res.status(400).json({ error: 'Nom, IBAN et BIC sont obligatoires' });
|
|
|
|
const ibanClean = companyIban.replace(/\s+/g, '').toUpperCase();
|
|
const bicClean = companyBic.replace(/\s+/g, '').toUpperCase();
|
|
|
|
try {
|
|
// Désactiver l'ancienne config et insérer la nouvelle
|
|
await pool.request().query(`UPDATE ConfigDebiteurXML SET actif = 0 WHERE actif = 1`);
|
|
|
|
await pool.request()
|
|
.input('companyName', sql.NVarChar, companyName.trim())
|
|
.input('companyIban', sql.NVarChar, ibanClean)
|
|
.input('companyBic', sql.NVarChar, bicClean)
|
|
.input('companyAddress', sql.NVarChar, (companyAddress || '').trim())
|
|
.input('companyCp', sql.NVarChar, (companyCp || '').trim())
|
|
.input('companyVille', sql.NVarChar, (companyVille || '').trim())
|
|
.input('companyPays', sql.NVarChar, (companyPays || 'FR').trim().slice(0, 2).toUpperCase())
|
|
.input('modifiePar', sql.Int, req.user.id)
|
|
.query(`
|
|
INSERT INTO ConfigDebiteurXML
|
|
(companyName, companyIban, companyBic, companyAddress,
|
|
companyCp, companyVille, companyPays, actif, modifiePar,
|
|
DateCreation, DateModification)
|
|
VALUES
|
|
(@companyName, @companyIban, @companyBic, @companyAddress,
|
|
@companyCp, @companyVille, @companyPays, 1, @modifiePar,
|
|
GETDATE(), GETDATE())
|
|
`);
|
|
|
|
console.log(`✅ Config débiteur XML mise à jour par ${req.user.email}`);
|
|
res.json({ success: true, companyName, companyIban: ibanClean, companyBic: bicClean });
|
|
} catch (e) {
|
|
console.error('PUT /api/paiements/config-debiteur:', e.message);
|
|
res.status(500).json({ error: e.message });
|
|
}
|
|
});
|
|
// POST /api/paiements/confirmer-paiement
|
|
// Body: { noteIds: number[], datePaiement: string (ISO) }
|
|
// POST /api/paiements/confirmer-paiement — Confirme paiement et passe statut à 'payee'
|
|
app.post('/api/paiements/confirmer-paiement', authenticateToken, async (req, res) => {
|
|
if (!hasAnyRole(req.user, 'Finance', 'ValidateurFinance', 'superUtilisateur'))
|
|
return res.status(403).json({ error: 'Accès réservé Finance' });
|
|
|
|
const { noteIds, datePaiement } = req.body;
|
|
if (!Array.isArray(noteIds) || noteIds.length === 0)
|
|
return res.status(400).json({ error: 'Aucune note sélectionnée' });
|
|
if (!datePaiement)
|
|
return res.status(400).json({ error: 'Date de paiement obligatoire' });
|
|
|
|
try {
|
|
const idList = noteIds.map(id => parseInt(id)).filter(Boolean).join(',');
|
|
const dateObj = new Date(datePaiement);
|
|
const mois = dateObj.getMonth() + 1;
|
|
const annee = dateObj.getFullYear();
|
|
|
|
// Récupérer les notes pour notifications
|
|
const notes = await pool.request().query(`
|
|
SELECT n.id, n.reference, n.montant, n.libelle,
|
|
c.id AS collabId, c.email, c.prenom, c.nom
|
|
FROM NoteDeFrais n
|
|
JOIN CollaborateurAD c ON c.id = n.collaborateurId
|
|
WHERE n.id IN (${idList})
|
|
AND n.statut = 'paiementenattente'
|
|
`);
|
|
|
|
if (!notes.recordset.length)
|
|
return res.status(404).json({ error: 'Aucune note en attente de paiement trouvée' });
|
|
|
|
// Mettre à jour statut → 'payee'
|
|
await pool.request()
|
|
.input('datePaiement', sql.DateTime, dateObj)
|
|
.input('moisPaiement', sql.Int, mois)
|
|
.input('anneePaiement', sql.Int, annee)
|
|
.query(`
|
|
UPDATE NoteDeFrais
|
|
SET statut = 'payee',
|
|
datePaiement = @datePaiement,
|
|
moisPaiement = @moisPaiement,
|
|
anneePaiement = @anneePaiement,
|
|
DateModification = GETDATE()
|
|
WHERE id IN (${idList})
|
|
AND statut = 'paiementenattente'
|
|
`);
|
|
|
|
// Notifier chaque collaborateur
|
|
for (const n of notes.recordset) {
|
|
try {
|
|
await creerNotification({
|
|
destinataireId: n.collabId,
|
|
destinataireEmail: n.email,
|
|
type: 'paiement',
|
|
titre: `Paiement effectué : ${n.reference}`,
|
|
message: `Votre note ${n.reference} de ${parseFloat(n.montant).toFixed(2)}€ a été payée le ${dateObj.toLocaleDateString('fr-FR')}.`,
|
|
noteId: n.id
|
|
});
|
|
await sendMailGraph(n.email, `Paiement effectué : ${n.reference}`, `
|
|
<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto">
|
|
<div style="background:linear-gradient(135deg,#10b981,#059669);color:white;padding:24px;border-radius:12px 12px 0 0">
|
|
<h2 style="margin:0">Paiement effectué</h2>
|
|
</div>
|
|
<div style="padding:24px;background:#f8fafc;border:1px solid #e2e8f0;border-top:none;border-radius:0 0 12px 12px">
|
|
<p>Bonjour <strong>${n.prenom} ${n.nom}</strong>,</p>
|
|
<p>Votre note <strong>${n.reference}</strong> — ${n.libelle} d'un montant de <strong>${parseFloat(n.montant).toFixed(2)}€</strong> a été payée le <strong>${dateObj.toLocaleDateString('fr-FR')}</strong>.</p>
|
|
<div style="text-align:center;margin-top:24px">
|
|
<a href="${process.env.FRONTEND_URL || 'myndf.ensup-adm.net'}" style="background:#6366f1;color:white;padding:13px 28px;text-decoration:none;border-radius:8px;font-weight:700">Voir mes notes</a>
|
|
</div>
|
|
</div>
|
|
</div>`);
|
|
} catch (e) { console.error('Notif paiement confirmé:', e.message); }
|
|
}
|
|
|
|
const total = notes.recordset.reduce((s, n) => s + parseFloat(n.montant), 0);
|
|
res.json({
|
|
success: true,
|
|
count: notes.recordset.length,
|
|
total: parseFloat(total.toFixed(2)),
|
|
datePaiement
|
|
});
|
|
} catch (error) {
|
|
console.error('Erreur confirmer-paiement:', error.message);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
|
|
|
|
// ================================================
|
|
// ROUTES DE TEST
|
|
// ================================================
|
|
app.get('/api/test-get-drive', async (req, res) => {
|
|
try {
|
|
const token = await getGraphToken();
|
|
const siteId = 'ensup.sharepoint.com,d94abc08-28eb-47ce-8e12-fbbd6f16b9ea,a052c325-d33a-40e3-9e7b-7896a2ea7ab7';
|
|
const r = await axios.get(`https://graph.microsoft.com/v1.0/sites/${siteId}/drive`, { headers: { Authorization: `Bearer ${token}` } });
|
|
res.json({ driveId: r.data.id, name: r.data.name });
|
|
} catch (e) { res.status(500).json({ error: e.message, details: e.response?.data }); }
|
|
});
|
|
|
|
app.get('/api/test-upload', async (req, res) => {
|
|
try {
|
|
const token = await getGraphToken();
|
|
const testContent = Buffer.from('Test upload NDF - ' + new Date().toISOString());
|
|
const path = `Notes de Frais/TEST_Upload/test_${Date.now()}.txt`;
|
|
const r = await axios.put(
|
|
`https://graph.microsoft.com/v1.0/sites/${process.env.SHAREPOINT_SITE_ID}/drives/${process.env.SHAREPOINT_DRIVE_ID}/root:/${path}:/content`,
|
|
testContent,
|
|
{ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'text/plain' } }
|
|
);
|
|
res.json({ ok: true, url: r.data.webUrl });
|
|
} catch (e) { res.status(500).json({ error: e.message, details: e.response?.data }); }
|
|
});
|
|
|
|
|
|
|
|
|
|
// ══════════════════════════════════════════════════════════════════
|
|
// BROUILLONS — Sauvegarde serveur (statut = 'brouillon')
|
|
// ══════════════════════════════════════════════════════════════════
|
|
|
|
// GET /api/notes/brouillons — récupère les brouillons du collaborateur connecté
|
|
app.get('/api/notes/brouillons', authenticateToken, async (req, res) => {
|
|
try {
|
|
const result = await pool.request()
|
|
.input('collaborateurId', sql.Int, req.user.id)
|
|
.query(`
|
|
SELECT id, libelle, date, description, montant, categorie,
|
|
lignesJson, DateCreation, DateModification
|
|
FROM NoteDeFrais
|
|
WHERE collaborateurId = @collaborateurId
|
|
AND statut = 'brouillon'
|
|
ORDER BY DateModification DESC
|
|
`);
|
|
res.json(result.recordset);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// POST /api/notes/brouillons — crée un nouveau brouillon
|
|
app.post('/api/notes/brouillons', authenticateToken, async (req, res) => {
|
|
try {
|
|
const { libelle, date, description, lignes } = req.body;
|
|
|
|
let lignesJson = '[]';
|
|
if (lignes) {
|
|
lignesJson = typeof lignes === 'string' ? lignes : JSON.stringify(lignes);
|
|
}
|
|
|
|
// Calcul du montant total estimé
|
|
let montantEstime = 0;
|
|
try {
|
|
const tarifKm = await getTarifKm();
|
|
const lignesParsed = JSON.parse(lignesJson);
|
|
montantEstime = lignesParsed.reduce((acc, l) => {
|
|
const isKm = (l.categorie || '').toLowerCase().includes('kilom');
|
|
const km = parseFloat(l.km) || 0;
|
|
const ttc = isKm ? parseFloat((km * tarifKm).toFixed(2)) : (parseFloat(l.montant) || 0);
|
|
return acc + ttc;
|
|
}, 0);
|
|
} catch (e) { /* montant reste 0 */ }
|
|
|
|
// Référence temporaire pour les brouillons (colonne NOT NULL)
|
|
const refBrouillon = `BRO-${req.user.id}-${Date.now().toString().slice(-6)}`;
|
|
|
|
const insertResult = await pool.request()
|
|
.input('collaborateurId', sql.Int, req.user.id)
|
|
.input('reference', sql.NVarChar, refBrouillon)
|
|
.input('libelle', sql.NVarChar, libelle || 'Brouillon sans titre')
|
|
.input('date', sql.Date, date ? new Date(date) : new Date())
|
|
.input('description', sql.NVarChar, description || null)
|
|
.input('montant', sql.Decimal, montantEstime)
|
|
.input('categorie', sql.NVarChar, 'Multiple')
|
|
.input('lignesJson', sql.NVarChar, lignesJson)
|
|
.input('statut', sql.NVarChar, 'brouillon')
|
|
.query(`
|
|
INSERT INTO NoteDeFrais
|
|
(reference, collaborateurId, libelle, date, description, montant,
|
|
categorie, lignesJson, statut, DateCreation, DateModification)
|
|
OUTPUT INSERTED.id, INSERTED.DateCreation
|
|
VALUES
|
|
(@reference, @collaborateurId, @libelle, @date, @description, @montant,
|
|
@categorie, @lignesJson, @statut, GETDATE(), GETDATE())
|
|
`);
|
|
|
|
const created = insertResult.recordset[0];
|
|
res.status(201).json({ success: true, id: created.id, createdAt: created.DateCreation });
|
|
} catch (error) {
|
|
console.error('Erreur POST brouillon:', error.message);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// PUT /api/notes/brouillons/:id — met à jour un brouillon existant
|
|
app.put('/api/notes/brouillons/:id', authenticateToken, upload.any(), async (req, res) => {
|
|
try {
|
|
const { libelle, date, description, lignes } = req.body;
|
|
const brouillonId = parseInt(req.params.id);
|
|
|
|
// Vérifier que ce brouillon appartient bien à ce collaborateur
|
|
const check = await pool.request()
|
|
.input('id', sql.Int, brouillonId)
|
|
.input('collaborateurId', sql.Int, req.user.id)
|
|
.query(`SELECT id FROM NoteDeFrais
|
|
WHERE id = @id AND collaborateurId = @collaborateurId AND statut = 'brouillon'`);
|
|
|
|
if (!check.recordset.length) {
|
|
return res.status(404).json({ error: 'Brouillon introuvable ou accès refusé' });
|
|
}
|
|
|
|
// Récupérer infos collaborateur pour le dossier SharePoint
|
|
const collabResult = await pool.request()
|
|
.input('id', sql.Int, req.user.id)
|
|
.query(`SELECT prenom, nom FROM CollaborateurAD WHERE id = @id`);
|
|
const collaborateur = collabResult.recordset[0];
|
|
const nomDossier = `${collaborateur.prenom}${collaborateur.nom}`.replace(/[^a-zA-Z0-9]/g, '_');
|
|
const now = new Date();
|
|
const moisDossier = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
|
|
const noteRef = `BRO-${brouillonId}`;
|
|
|
|
let lignesParsed = [];
|
|
if (lignes) {
|
|
try {
|
|
lignesParsed = typeof lignes === 'string' ? JSON.parse(lignes) : lignes;
|
|
} catch (e) { lignesParsed = []; }
|
|
}
|
|
|
|
const uploadedFiles = {};
|
|
// ✅ Upload des nouveaux fichiers vers SharePoint et injection dans lignesParsed
|
|
const allFiles = req.files || [];
|
|
for (const file of allFiles) {
|
|
const match = file.fieldname.match(/^files_(.+)$/);
|
|
if (!match) continue;
|
|
const depId = String(match[1]);
|
|
try {
|
|
const uploaded = await uploadToSharePointHierarchique(file, noteRef, nomDossier, moisDossier);
|
|
const ligne = lignesParsed.find(l => String(l.id) === depId);
|
|
if (ligne) {
|
|
if (!Array.isArray(ligne.qrFiles)) ligne.qrFiles = [];
|
|
const dejaSauve = ligne.qrFiles.some(f => f.fileName === uploaded.fileName);
|
|
if (!dejaSauve) {
|
|
// ✅ Stocker avec origin='upload'
|
|
ligne.qrFiles.push({
|
|
fileName: uploaded.fileName,
|
|
uploadUrl: uploaded.uploadUrl,
|
|
origin: 'upload' // ✅ AJOUT
|
|
});
|
|
// ✅ Tracker pour le retour
|
|
if (!uploadedFiles[depId]) uploadedFiles[depId] = [];
|
|
uploadedFiles[depId].push({
|
|
fileName: uploaded.fileName,
|
|
uploadUrl: uploaded.uploadUrl,
|
|
origin: 'upload'
|
|
});
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error(`Upload brouillon fichier ${file.originalname}:`, e.message);
|
|
}
|
|
}
|
|
|
|
|
|
// Recalcul montant
|
|
let montantEstime = 0;
|
|
try {
|
|
const tarifKm = await getTarifKm();
|
|
montantEstime = lignesParsed.reduce((acc, l) => {
|
|
const isKm = (l.categorie || '').toLowerCase().includes('kilom');
|
|
const km = parseFloat(l.km) || 0;
|
|
const ttc = isKm
|
|
? parseFloat((km * tarifKm).toFixed(2))
|
|
: (parseFloat(l.montant) || parseFloat(l.tvaItems?.[0]?.montantTTC) || 0);
|
|
return acc + ttc;
|
|
}, 0);
|
|
} catch (e) { /* montant reste 0 */ }
|
|
|
|
const lignesJson = JSON.stringify(lignesParsed);
|
|
|
|
await pool.request()
|
|
.input('id', sql.Int, brouillonId)
|
|
.input('libelle', sql.NVarChar, libelle || 'Brouillon sans titre')
|
|
.input('date', sql.Date, date ? new Date(date) : new Date())
|
|
.input('description', sql.NVarChar, description || null)
|
|
.input('montant', sql.Decimal, montantEstime)
|
|
.input('lignesJson', sql.NVarChar, lignesJson)
|
|
.query(`
|
|
UPDATE NoteDeFrais
|
|
SET libelle = @libelle,
|
|
date = @date,
|
|
description = @description,
|
|
montant = @montant,
|
|
lignesJson = @lignesJson,
|
|
DateModification = GETDATE()
|
|
WHERE id = @id
|
|
`);
|
|
|
|
res.json({ success: true, updatedAt: new Date().toISOString(), uploadedFiles });
|
|
} catch (error) {
|
|
console.error('Erreur PUT brouillon:', error.message);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// DELETE /api/notes/brouillons/:id — supprime un brouillon
|
|
app.delete('/api/notes/brouillons/:id', authenticateToken, async (req, res) => {
|
|
try {
|
|
const brouillonId = parseInt(req.params.id);
|
|
|
|
const check = await pool.request()
|
|
.input('id', sql.Int, brouillonId)
|
|
.input('collaborateurId', sql.Int, req.user.id)
|
|
.query(`SELECT id FROM NoteDeFrais
|
|
WHERE id = @id AND collaborateurId = @collaborateurId AND statut = 'brouillon'`);
|
|
|
|
if (!check.recordset.length) {
|
|
return res.status(404).json({ error: 'Brouillon introuvable ou accès refusé' });
|
|
}
|
|
|
|
await pool.request()
|
|
.input('id', sql.Int, brouillonId)
|
|
.query(`DELETE FROM NoteDeFrais WHERE id = @id`);
|
|
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error('Erreur DELETE brouillon:', error.message);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
|
|
// ══════════════════════════════════════════════════════════════════
|
|
// DOCUMENTS COLLABORATEUR — RIB / Carte grise / Permis
|
|
// Coller dans server.js après les routes /api/profil
|
|
// ══════════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
|
// ============================================================
|
|
// DOCUMENTS COLLABORATEUR — RIB / Carte grise / Permis
|
|
// ============================================================
|
|
const DOCTYPES = ['rib', 'cartegrise', 'permis'];
|
|
|
|
// GET /api/profil/documents
|
|
app.get('/api/profil/documents', authenticateToken, async (req, res) => {
|
|
try {
|
|
const result = await pool.request()
|
|
.input('collabId', sql.Int, req.user.id)
|
|
.query(`
|
|
SELECT type, fileName, sharepointUrl, dateUpload, DateModification, statut, commentaire
|
|
FROM DocumentsCollaborateur
|
|
WHERE collaborateurId = @collabId
|
|
AND type != 'rib' -- ← exclure le rib de cette table
|
|
`);
|
|
|
|
// Vérifier si IBAN saisi directement dans CollaborateurAD
|
|
const ibanResult = await pool.request()
|
|
.input('collabId', sql.Int, req.user.id)
|
|
.query(`SELECT IBAN FROM CollaborateurAD WHERE id = @collabId`);
|
|
|
|
const ibanSaisi = !!(ibanResult.recordset[0]?.IBAN);
|
|
|
|
const docs = {
|
|
rib: ibanSaisi
|
|
? { fileName: 'IBAN_saisi', sharepointUrl: '', updatedAt: new Date().toISOString(), statut: 'valide', commentaire: null }
|
|
: null,
|
|
carte_grise: null,
|
|
permis: null
|
|
};
|
|
|
|
for (const row of result.recordset) {
|
|
docs[row.type] = {
|
|
fileName: row.fileName,
|
|
sharepointUrl: row.sharepointUrl,
|
|
updatedAt: row.DateModification,
|
|
statut: row.statut ?? 'en_attente',
|
|
commentaire: row.commentaire ?? null
|
|
};
|
|
}
|
|
|
|
res.json(docs);
|
|
} catch (error) {
|
|
console.error('GET /api/profil/documents', error.message);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// POST /api/profil/documents/:type — upload ou remplacement
|
|
app.post('/api/profil/documents/:type', authenticateToken, upload.single('file'), async (req, res) => {
|
|
try {
|
|
const type = req.params.type;
|
|
if (!DOCTYPES.includes(type))
|
|
return res.status(400).json({ error: 'Type invalide. Valeurs : rib, cartegrise, permis' });
|
|
if (!req.file)
|
|
return res.status(400).json({ error: 'Aucun fichier fourni' });
|
|
|
|
// Infos collaborateur (campus pour notifier la bonne Finance)
|
|
const collabResult = await pool.request()
|
|
.input('id', sql.Int, req.user.id)
|
|
.query(`SELECT prenom, nom, campus FROM CollaborateurAD WHERE id = @id`);
|
|
if (!collabResult.recordset.length)
|
|
return res.status(404).json({ error: 'Collaborateur introuvable' });
|
|
|
|
const { prenom, nom, campus } = collabResult.recordset[0];
|
|
const nomDossier = `${prenom}${nom}`.replace(/[^a-zA-Z0-9]/g, '_');
|
|
const safeFileName = `${type}_${req.file.originalname.replace(/[^a-zA-Z0-9.\-]/g, '_')}`;
|
|
const folderPath = `Documents-Profil/${nomDossier}`;
|
|
const uploadPath = `${folderPath}/${safeFileName}`;
|
|
|
|
// Upload SharePoint
|
|
const accessToken = await getGraphToken();
|
|
if (!accessToken) throw new Error('Token Graph indisponible');
|
|
|
|
const spRes = await axios.put(
|
|
`https://graph.microsoft.com/v1.0/sites/${SHAREPOINT_CONFIG.siteId}/drives/${SHAREPOINT_CONFIG.driveId}/root:/${uploadPath}:/content`,
|
|
req.file.buffer,
|
|
{
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
'Content-Type': req.file.mimetype || 'application/octet-stream'
|
|
},
|
|
maxBodyLength: Infinity
|
|
}
|
|
);
|
|
const sharepointUrl = spRes.data.webUrl;
|
|
|
|
// UPSERT — remet en attente si document remplacé
|
|
await pool.request()
|
|
.input('collabId', sql.Int, req.user.id)
|
|
.input('type', sql.NVarChar, type)
|
|
.input('fileName', sql.NVarChar, safeFileName)
|
|
.input('sharepointUrl', sql.NVarChar, sharepointUrl)
|
|
.query(`
|
|
IF EXISTS (SELECT 1 FROM DocumentsCollaborateur WHERE collaborateurId = @collabId AND type = @type)
|
|
UPDATE DocumentsCollaborateur
|
|
SET fileName = @fileName, sharepointUrl = @sharepointUrl,
|
|
DateModification = GETDATE(), dateUpload = GETDATE(),
|
|
statut = 'en_attente', validePar = NULL, dateValidation = NULL, commentaire = NULL
|
|
WHERE collaborateurId = @collabId AND type = @type
|
|
ELSE
|
|
INSERT INTO DocumentsCollaborateur (collaborateurId, type, fileName, sharepointUrl, dateUpload, DateModification, statut)
|
|
VALUES (@collabId, @type, @fileName, @sharepointUrl, GETDATE(), GETDATE(), 'en_attente')
|
|
`);
|
|
|
|
// Notifier les Finance du même campus
|
|
const typeLabels = { rib: 'RIB', cartegrise: 'Carte grise', permis: 'Permis de conduire' };
|
|
const campusNorm = normalizeCampus(campus);
|
|
const financeResult = await pool.request()
|
|
.input('campus', sql.NVarChar, campusNorm ? `%${campusNorm}%` : '%')
|
|
.query(`
|
|
SELECT c.id, c.email, c.prenom, c.nom
|
|
FROM CollaborateurAD c
|
|
JOIN UtilisateurRoles r ON r.collaborateur_id = c.id
|
|
WHERE r.role = 'Finance' AND r.actif = 1
|
|
AND c.campus LIKE @campus AND c.Actif = 1
|
|
`);
|
|
|
|
const frontendUrl = process.env.FRONTEND_URL || 'https://myndf.ensup-adm.net';
|
|
for (const finance of financeResult.recordset) {
|
|
try {
|
|
await creerNotification({
|
|
destinataireId: finance.id,
|
|
destinataireEmail: finance.email,
|
|
type: 'validationdoc',
|
|
titre: `Document à valider — ${prenom} ${nom}`,
|
|
message: `${prenom} ${nom} (${campus}) a soumis son ${typeLabels[type]} pour validation.`,
|
|
noteId: null
|
|
});
|
|
} catch (e) { console.error('Notif BDD Finance doc', e.message); }
|
|
try {
|
|
await sendMailGraph(
|
|
finance.email,
|
|
`Document à valider — ${typeLabels[type]} de ${prenom} ${nom}`,
|
|
`<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto">
|
|
<div style="background:linear-gradient(135deg,#6366f1,#4f46e5);color:white;padding:24px;border-radius:12px 12px 0 0">
|
|
<h2 style="margin:0">Document à valider</h2>
|
|
</div>
|
|
<div style="padding:24px;background:#f8fafc;border:1px solid #e2e8f0;border-top:none;border-radius:0 0 12px 12px">
|
|
<p>Bonjour <strong>${finance.prenom} ${finance.nom}</strong>,</p>
|
|
<p><strong>${prenom} ${nom}</strong> (${campus}) a soumis son <strong>${typeLabels[type]}</strong> en attente de votre validation.</p>
|
|
<div style="text-align:center;margin-top:24px">
|
|
<a href="${frontendUrl}" style="background:#6366f1;color:white;padding:13px 28px;text-decoration:none;border-radius:8px;font-weight:700">
|
|
Valider les documents
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>`
|
|
);
|
|
} catch (e) { console.error('Email Finance doc', e.message); }
|
|
}
|
|
|
|
res.json({ success: true, type, fileName: safeFileName, sharepointUrl, statut: 'en_attente', updatedAt: new Date().toISOString() });
|
|
} catch (error) {
|
|
console.error(`POST /api/profil/documents/${req.params.type}`, error.message);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// DELETE /api/profil/documents/:type
|
|
app.delete('/api/profil/documents/:type', authenticateToken, async (req, res) => {
|
|
try {
|
|
const type = req.params.type;
|
|
if (!DOCTYPES.includes(type))
|
|
return res.status(400).json({ error: 'Type invalide' });
|
|
await pool.request()
|
|
.input('collabId', sql.Int, req.user.id)
|
|
.input('type', sql.NVarChar, type)
|
|
.query(`DELETE FROM DocumentsCollaborateur WHERE collaborateurId = @collabId AND type = @type`);
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
|
|
// GET /api/finance/documents-a-valider — Finance voit les docs en_attente de son campus
|
|
app.get('/api/finance/documents-a-valider', authenticateToken, async (req, res) => {
|
|
if (!hasAnyRole(req.user, 'Finance', 'superUtilisateur'))
|
|
return res.status(403).json({ error: 'Accès réservé Finance' });
|
|
try {
|
|
const request = pool.request();
|
|
let campusFilter = '';
|
|
if (req.user.campus && !hasAnyRole(req.user, 'superUtilisateur')) {
|
|
const campusCode = normalizeCampus(req.user.campus);
|
|
if (campusCode) {
|
|
request.input('campus', sql.NVarChar, `%${campusCode}%`);
|
|
campusFilter = 'AND c.campus LIKE @campus';
|
|
}
|
|
}
|
|
const result = await request.query(`
|
|
SELECT d.id, d.collaborateurId, d.type, d.fileName, d.sharepointUrl,
|
|
d.dateUpload, d.DateModification, d.statut, d.commentaire,
|
|
c.prenom, c.nom, c.email, c.campus, c.departement
|
|
FROM DocumentsCollaborateur d
|
|
JOIN CollaborateurAD c ON c.id = d.collaborateurId
|
|
WHERE d.statut = 'en_attente' ${campusFilter}
|
|
ORDER BY d.DateModification ASC
|
|
`);
|
|
res.json(result.recordset);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// PUT /api/finance/documents/:id/valider — Finance valide ou refuse un document
|
|
// PUT /api/finance/documents/:id/valider
|
|
app.put('/api/finance/documents/:id/valider', authenticateToken, async (req, res) => {
|
|
if (!hasAnyRole(req.user, 'Finance', 'superUtilisateur')) return res.status(403).json({ error: 'Accès réservé Finance' });
|
|
try {
|
|
const docId = parseInt(req.params.id);
|
|
const { action, commentaire } = req.body;
|
|
if (!['valider', 'refuser'].includes(action)) return res.status(400).json({ error: 'Action invalide' });
|
|
|
|
const newStatut = action === 'valider' ? 'valide' : 'refuse';
|
|
|
|
const docResult = await pool.request()
|
|
.input('id', sql.Int, docId)
|
|
.query(`SELECT d.*, c.prenom, c.nom, c.email, c.campus FROM DocumentsCollaborateur d JOIN CollaborateurAD c ON c.id = d.collaborateurId WHERE d.id = @id`);
|
|
if (!docResult.recordset.length) return res.status(404).json({ error: 'Document introuvable' });
|
|
|
|
const doc = docResult.recordset[0];
|
|
|
|
await pool.request()
|
|
.input('id', sql.Int, docId)
|
|
.input('statut', sql.NVarChar, newStatut)
|
|
.input('validePar', sql.Int, req.user.id)
|
|
.input('commentaire', sql.NVarChar, commentaire || null)
|
|
.query(`
|
|
UPDATE DocumentsCollaborateur
|
|
SET statut = @statut, validePar = @validePar,
|
|
dateValidation = GETDATE(), commentaire = @commentaire, DateModification = GETDATE()
|
|
WHERE id = @id
|
|
`);
|
|
|
|
const typeLabels = { rib: 'RIB', carte_grise: 'Carte grise', cartegrise: 'Carte grise', permis: 'Permis de conduire' };
|
|
const isValide = newStatut === 'valide';
|
|
const titre = isValide ? `${typeLabels[doc.type] || doc.type} validé ✅` : `${typeLabels[doc.type] || doc.type} refusé ❌`;
|
|
const message = isValide
|
|
? `Votre ${typeLabels[doc.type] || doc.type} a été validé par la Finance.`
|
|
: `Votre ${typeLabels[doc.type] || doc.type} a été refusé.${commentaire ? ' Motif : ' + commentaire : ''}`;
|
|
|
|
try {
|
|
await creerNotification({ destinataireId: doc.collaborateurId, destinataireEmail: doc.email, type: isValide ? 'doc_valide' : 'doc_refuse', titre, message, noteId: null });
|
|
} catch (e) { console.error('Notif doc validé', e.message); }
|
|
|
|
try {
|
|
const frontendUrl = process.env.FRONTEND_URL || 'https://myndf.ensup-adm.net';
|
|
await sendMailGraph(doc.email, titre,
|
|
`<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto">
|
|
<div style="background:linear-gradient(135deg,${isValide ? '#10b981,#059669' : '#ef4444,#dc2626'});color:white;padding:24px;border-radius:12px 12px 0 0">
|
|
<h2 style="margin:0">${titre}</h2>
|
|
</div>
|
|
<div style="padding:24px;background:#f8fafc;border:1px solid #e2e8f0;border-top:none;border-radius:0 0 12px 12px">
|
|
<p>Bonjour <strong>${doc.prenom} ${doc.nom}</strong>,</p>
|
|
<p>${message}</p>
|
|
${!isValide ? '<p>Veuillez soumettre un nouveau document corrigé depuis votre profil.</p>' : ''}
|
|
<div style="text-align:center;margin-top:24px">
|
|
<a href="${frontendUrl}" style="background:#6366f1;color:white;padding:13px 28px;text-decoration:none;border-radius:8px;font-weight:700">Accéder à mon profil</a>
|
|
</div>
|
|
</div>
|
|
</div>`
|
|
);
|
|
} catch (e) { console.error('Email doc validé', e.message); }
|
|
|
|
res.json({ success: true, statut: newStatut });
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// GET /api/profil/docs-statut — vérifie si le collaborateur peut soumettre (docs validés)
|
|
app.get('/api/profil/docs-statut', authenticateToken, async (req, res) => {
|
|
try {
|
|
const result = await pool.request()
|
|
.input('collaborateurId', sql.Int, req.user.id)
|
|
.query(`SELECT type, statut FROM DocumentsCollaborateur WHERE collaborateurId = @collaborateurId`);
|
|
const map = {};
|
|
for (const row of result.recordset) map[row.type] = row.statut;
|
|
res.json({
|
|
rib: map['rib'] || 'absent',
|
|
carte_grise: map['carte_grise'] || 'absent',
|
|
permis: map['permis'] || 'absent',
|
|
});
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// ── PUT /api/profil/iban ─────────────────────────────────────────
|
|
app.put('/api/profil/iban', authenticateToken, async (req, res) => {
|
|
try {
|
|
let { iban, bic } = req.body;
|
|
if (!iban) return res.status(400).json({ error: 'IBAN obligatoire' });
|
|
if (!bic) return res.status(400).json({ error: 'BIC obligatoire' });
|
|
|
|
iban = iban.replace(/\s+/g, '').toUpperCase();
|
|
bic = bic.replace(/\s+/g, '').toUpperCase();
|
|
|
|
if (!validateIban(iban))
|
|
return res.status(400).json({ error: 'IBAN invalide (format ou checksum incorrect)' });
|
|
|
|
const ibanChiffre = encryptIban(iban);
|
|
const ibanHash = hashIban(iban);
|
|
|
|
await pool.request()
|
|
.input('id', sql.Int, req.user.id)
|
|
.input('iban', sql.NVarChar, ibanChiffre)
|
|
.input('ibanHash', sql.NVarChar, ibanHash)
|
|
.input('bic', sql.NVarChar, bic)
|
|
.query(`
|
|
UPDATE CollaborateurAD
|
|
SET IBAN = @iban, IBAN_HASH = @ibanHash, BIC = @bic, DateModification = GETDATE()
|
|
WHERE id = @id
|
|
`);
|
|
|
|
// ← Le second bloc DocumentsCollaborateur est supprimé
|
|
|
|
res.json({ success: true, iban: maskIban(iban), bic, statut: 'valide' });
|
|
} catch (error) {
|
|
console.error('PUT /api/profil/iban :', error.message);
|
|
res.status(500).json({ error: 'Erreur serveur' });
|
|
}
|
|
});
|
|
|
|
// ── GET /api/profil/iban ─────────────────────────────────────────
|
|
app.get('/api/profil/iban', authenticateToken, async (req, res) => {
|
|
try {
|
|
const result = await pool.request()
|
|
.input('id', sql.Int, req.user.id)
|
|
.query(`SELECT IBAN, BIC FROM CollaborateurAD WHERE id = @id`);
|
|
|
|
const row = result.recordset[0];
|
|
if (!row) return res.status(404).json({ error: 'Utilisateur introuvable' });
|
|
|
|
let ibanMasque = null;
|
|
if (row.IBAN) {
|
|
try {
|
|
const ibanClair = decryptIban(row.IBAN);
|
|
ibanMasque = maskIban(ibanClair);
|
|
} catch {
|
|
// IBAN encore en clair en base (avant migration) — on masque directement
|
|
ibanMasque = maskIban(row.IBAN);
|
|
}
|
|
}
|
|
|
|
res.json({
|
|
iban: ibanMasque,
|
|
ibanSaisi: !!row.IBAN,
|
|
bic: row.BIC || null,
|
|
});
|
|
} catch (error) {
|
|
console.error('GET /api/profil/iban :', error.message);
|
|
res.status(500).json({ error: 'Erreur serveur' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/verificateur/notes/:id/non-conforme', authenticateToken, async (req, res) => {
|
|
if (!hasAnyRole(req.user, 'VerificateurFinance', 'Finance', 'superUtilisateur'))
|
|
return res.status(403).json({ error: 'Accès refusé' });
|
|
|
|
const { fileName, motif } = req.body;
|
|
const noteId = parseInt(req.params.id);
|
|
|
|
try {
|
|
const noteResult = await pool.request()
|
|
.input('id', sql.Int, noteId)
|
|
.query(`
|
|
SELECT
|
|
n.id, n.reference, n.libelle, n.montant,
|
|
n.collaborateurId,
|
|
c.prenom, c.nom, c.email,
|
|
v1.id AS n1Id,
|
|
v1.email AS emailN1,
|
|
v1.prenom AS prenomN1,
|
|
v1.nom AS nomN1
|
|
FROM NoteDeFrais n
|
|
JOIN CollaborateurAD c ON c.id = n.collaborateurId
|
|
LEFT JOIN CollaborateurAD v1 ON v1.id = n.validateurN1Id
|
|
WHERE n.id = @id
|
|
`);
|
|
|
|
if (!noteResult.recordset.length)
|
|
return res.status(404).json({ error: 'Note introuvable' });
|
|
|
|
const note = noteResult.recordset[0];
|
|
|
|
// ✅ NOUVEAU — sauvegarder en BDD
|
|
await pool.request()
|
|
.input('noteId', sql.Int, noteId)
|
|
.input('verificateurId', sql.Int, req.user.id)
|
|
.input('fileName', sql.NVarChar, fileName)
|
|
.input('motif', sql.NVarChar, motif)
|
|
.query(`
|
|
INSERT INTO JustificatifsNonConformes
|
|
(noteDeFraisId, verificateurId, fileName, motif, statut, dateSignalement)
|
|
VALUES
|
|
(@noteId, @verificateurId, @fileName, @motif, 'non_conforme', GETDATE())
|
|
`);
|
|
await pool.request()
|
|
.input('id', sql.Int, noteId)
|
|
.query(`
|
|
UPDATE NoteDeFrais SET
|
|
statut = 'non_conforme_verif',
|
|
DateModification = GETDATE()
|
|
WHERE id = @id AND statut = 'approuve'
|
|
`);
|
|
|
|
// ✅ NOUVEAU — tracer dans HistoriqueValidation
|
|
await pool.request()
|
|
.input('noteId', sql.Int, noteId)
|
|
.input('validateurId', sql.Int, req.user.id)
|
|
.input('commentaire', sql.NVarChar, `Justificatif non conforme : "${fileName}" — ${motif}`)
|
|
.input('statut', sql.NVarChar, 'approuve')
|
|
.query(`
|
|
INSERT INTO HistoriqueValidation
|
|
(NoteDeFraisId, ValidateurId, Niveau, Action, Commentaire, NouveauStatut, DateAction)
|
|
VALUES
|
|
(@noteId, @validateurId, 'VERIF', 'non_conforme', @commentaire, @statut, GETDATE())
|
|
`);
|
|
|
|
const verificateurNom = `${req.user.prenom} ${req.user.nom}`;
|
|
const montantFormate = parseFloat(note.montant).toFixed(2);
|
|
const frontendUrl = process.env.FRONTEND_URL || 'https://myndf.ensup-adm.net';
|
|
|
|
// Notifications (votre code existant inchangé)
|
|
try {
|
|
await creerNotification({
|
|
destinataireId: note.collaborateurId,
|
|
destinataireEmail: note.email,
|
|
type: 'refus',
|
|
titre: `⚠️ Justificatif non conforme — ${note.reference}`,
|
|
message: `Le justificatif "${fileName}" de votre note ${note.reference} est non conforme. Motif : ${motif}`,
|
|
noteId
|
|
});
|
|
} catch (e) { console.error('Notif BDD collab non-conforme:', e.message); }
|
|
|
|
|
|
try {
|
|
await sendMailGraph(
|
|
note.email,
|
|
`⚠️ Justificatif non conforme — ${note.reference}`,
|
|
`<div style="font-family:Arial,sans-serif;max-width:620px;margin:0 auto">
|
|
<div style="background:linear-gradient(135deg,#ef4444,#dc2626);color:white;padding:24px;border-radius:12px 12px 0 0">
|
|
<h2 style="margin:0;font-size:18px">⚠️ Justificatif non conforme</h2>
|
|
<p style="margin:8px 0 0;opacity:.85;font-size:13px">Une correction est nécessaire</p>
|
|
</div>
|
|
<div style="padding:24px;background:#f8fafc;border:1px solid #e2e8f0;border-top:none;border-radius:0 0 12px 12px">
|
|
<p>Bonjour <strong>${note.prenom} ${note.nom}</strong>,</p>
|
|
<p>Le vérificateur Finance <strong>${verificateurNom}</strong> a signalé
|
|
un justificatif non conforme sur votre note <strong>${note.reference}</strong>.</p>
|
|
|
|
<div style="background:#fef2f2;border:1.5px solid #fecaca;border-left:4px solid #ef4444;
|
|
border-radius:0 8px 8px 0;padding:16px;margin:20px 0">
|
|
<div style="font-size:12px;font-weight:700;color:#991b1b;
|
|
text-transform:uppercase;letter-spacing:.5px;margin-bottom:6px">
|
|
Justificatif concerné
|
|
</div>
|
|
<div style="font-size:13px;color:#dc2626;font-weight:600;margin-bottom:10px">
|
|
📄 ${fileName}
|
|
</div>
|
|
<div style="font-size:12px;font-weight:700;color:#991b1b;
|
|
text-transform:uppercase;letter-spacing:.5px;margin-bottom:6px">
|
|
Motif
|
|
</div>
|
|
<div style="font-size:14px;color:#dc2626;font-weight:600">${motif}</div>
|
|
</div>
|
|
|
|
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:8px;
|
|
padding:16px;margin:16px 0">
|
|
<table style="width:100%;border-collapse:collapse;font-size:13px">
|
|
<tr><td style="color:#64748b;padding:4px 0;width:140px">Référence</td>
|
|
<td style="font-weight:700;font-family:monospace;color:#6366f1">${note.reference}</td></tr>
|
|
<tr><td style="color:#64748b;padding:4px 0">Libellé</td>
|
|
<td style="font-weight:600">${note.libelle}</td></tr>
|
|
<tr><td style="color:#64748b;padding:4px 0">Montant</td>
|
|
<td style="font-weight:700">${montantFormate} €</td></tr>
|
|
</table>
|
|
</div>
|
|
|
|
<div style="background:#eff6ff;border:1px solid #bfdbfe;border-radius:8px;
|
|
padding:16px;margin:16px 0">
|
|
<div style="font-size:13px;font-weight:700;color:#1e40af;margin-bottom:8px">
|
|
📝 Que faire ?
|
|
</div>
|
|
<ol style="margin:0;padding-left:18px;font-size:13px;color:#1d4ed8;line-height:2">
|
|
<li>Retrouvez le justificatif original corrigé</li>
|
|
<li>Contactez votre responsable ou la Finance</li>
|
|
<li>Soumettez un nouveau justificatif via la plateforme</li>
|
|
</ol>
|
|
</div>
|
|
|
|
<div style="text-align:center;margin-top:28px">
|
|
<a href="${frontendUrl}"
|
|
style="background:linear-gradient(135deg,#6366f1,#4f46e5);color:white;
|
|
padding:14px 36px;text-decoration:none;border-radius:8px;
|
|
font-weight:700;font-size:14px;display:inline-block">
|
|
Accéder à ma note →
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>`
|
|
);
|
|
} catch (e) { console.error('Email collab non-conforme:', e.message); }
|
|
|
|
// ── Notifier le validateur N1 ──
|
|
if (note.n1Id && note.emailN1) {
|
|
try {
|
|
await creerNotification({
|
|
destinataireId: note.n1Id,
|
|
destinataireEmail: note.emailN1,
|
|
type: 'refus',
|
|
titre: `⚠️ Justificatif non conforme — ${note.reference}`,
|
|
message: `La note ${note.reference} de ${note.prenom} ${note.nom} comporte un justificatif non conforme : "${fileName}". Motif : ${motif}`,
|
|
noteId
|
|
});
|
|
} catch (e) { console.error('Notif BDD N1 non-conforme:', e.message); }
|
|
|
|
try {
|
|
await sendMailGraph(
|
|
note.emailN1,
|
|
`⚠️ Note ${note.reference} — justificatif non conforme`,
|
|
`<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto">
|
|
<div style="background:linear-gradient(135deg,#f59e0b,#d97706);color:white;
|
|
padding:20px;border-radius:12px 12px 0 0">
|
|
<h2 style="margin:0">⚠️ Justificatif non conforme signalé</h2>
|
|
</div>
|
|
<div style="padding:20px;background:#f8fafc;border:1px solid #e2e8f0;
|
|
border-top:none;border-radius:0 0 12px 12px">
|
|
<p>Bonjour <strong>${note.prenomN1} ${note.nomN1}</strong>,</p>
|
|
<p>Le vérificateur Finance <strong>${verificateurNom}</strong> a signalé
|
|
un justificatif non conforme sur la note
|
|
<strong>${note.reference}</strong> de
|
|
<strong>${note.prenom} ${note.nom}</strong>.</p>
|
|
<div style="background:#fef3c7;border:1px solid #fde68a;border-radius:8px;
|
|
padding:14px;margin:16px 0">
|
|
<div style="font-size:13px;font-weight:700;color:#92400e">
|
|
📄 ${fileName}
|
|
</div>
|
|
<div style="font-size:13px;color:#b45309;margin-top:6px">
|
|
Motif : ${motif}
|
|
</div>
|
|
</div>
|
|
<p style="font-size:13px;color:#64748b">
|
|
Le collaborateur a été notifié et doit fournir un justificatif corrigé.
|
|
</p>
|
|
</div>
|
|
</div>`
|
|
);
|
|
} catch (e) { console.error('Email N1 non-conforme:', e.message); }
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
notifiedCollab: true,
|
|
notifiedN1: !!(note.n1Id && note.emailN1)
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error('Erreur POST non-conforme:', error.message);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
app.get('/api/paiements/filtres-disponibles', authenticateToken, async (req, res) => {
|
|
if (!hasAnyRole(req.user, 'Finance', 'ValidateurFinance', 'superUtilisateur'))
|
|
return res.status(403).json({ error: 'Accès réservé Finance' });
|
|
|
|
try {
|
|
const result = await pool.request().query(`
|
|
SELECT DISTINCT
|
|
c.campus,
|
|
c.societe
|
|
FROM CollaborateurAD c
|
|
WHERE c.Actif = 1
|
|
AND c.campus IS NOT NULL
|
|
AND c.campus != ''
|
|
ORDER BY c.campus, c.societe
|
|
`);
|
|
|
|
const campusSet = new Set();
|
|
const societeSet = new Set();
|
|
|
|
for (const row of result.recordset) {
|
|
if (row.campus) {
|
|
const code = (() => {
|
|
const c = row.campus.toUpperCase();
|
|
if (c.includes('SQY') || c.includes('SAINT')) return 'SQY';
|
|
if (c.includes('CGY') || c.includes('CERGY')) return 'CGY';
|
|
if (c.includes('MRS') || c.includes('MARSEILLE')) return 'MRS';
|
|
if (c.includes('NTE') || c.includes('NANTES')) return 'NTE';
|
|
return row.campus;
|
|
})();
|
|
campusSet.add(code);
|
|
}
|
|
if (row.societe && row.societe.trim()) {
|
|
societeSet.add(row.societe.trim());
|
|
}
|
|
}
|
|
|
|
res.json({
|
|
campus: [...campusSet].sort(),
|
|
societes: [...societeSet].sort()
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error('GET /api/paiements/filtres-disponibles:', error.message);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
app.get('/api/verificateur/notes/:id/non-conformes', authenticateToken, async (req, res) => {
|
|
if (!hasAnyRole(req.user, 'VerificateurFinance', 'Finance', 'superUtilisateur'))
|
|
return res.status(403).json({ error: 'Accès refusé' });
|
|
|
|
try {
|
|
const result = await pool.request()
|
|
.input('noteId', sql.Int, parseInt(req.params.id))
|
|
.query(`
|
|
SELECT j.id, j.fileName, j.motif, j.statut, j.dateSignalement,
|
|
c.prenom + ' ' + c.nom AS verificateur
|
|
FROM JustificatifsNonConformes j
|
|
JOIN CollaborateurAD c ON c.id = j.verificateurId
|
|
WHERE j.noteDeFraisId = @noteId
|
|
ORDER BY j.dateSignalement DESC
|
|
`);
|
|
res.json(result.recordset);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// =========================================================
|
|
// ROUTES À AJOUTER DANS server.js
|
|
// Coller après les routes /api/profil/iban existantes
|
|
// =========================================================
|
|
|
|
// ── GET /api/profil/vehicule ─────────────────────────────
|
|
app.get('/api/profil/vehicule', authenticateToken, async (req, res) => {
|
|
try {
|
|
const result = await pool.request()
|
|
.input('id', sql.Int, req.user.id)
|
|
.query(`
|
|
SELECT chevauxFiscaux, vehiculeMarque, vehiculeModele,
|
|
vehiculeImmat, vehiculeDateMaj
|
|
FROM CollaborateurAD
|
|
WHERE id = @id
|
|
`);
|
|
|
|
const row = result.recordset[0];
|
|
if (!row) return res.status(404).json({ error: 'Utilisateur introuvable' });
|
|
|
|
// Retourner null si pas encore configuré
|
|
if (!row.chevauxFiscaux) {
|
|
return res.json({ configured: false, vehicule: null });
|
|
}
|
|
|
|
res.json({
|
|
configured: true,
|
|
vehicule: {
|
|
chevaux: row.chevauxFiscaux,
|
|
marque: row.vehiculeMarque || '',
|
|
modele: row.vehiculeModele || '',
|
|
immatriculation: row.vehiculeImmat || '',
|
|
dateMaj: row.vehiculeDateMaj,
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('GET /api/profil/vehicule :', error.message);
|
|
res.status(500).json({ error: 'Erreur serveur' });
|
|
}
|
|
});
|
|
|
|
// ── PUT /api/profil/vehicule ─────────────────────────────
|
|
app.put('/api/profil/vehicule', authenticateToken, async (req, res) => {
|
|
try {
|
|
const { chevaux, marque, modele, immatriculation } = req.body;
|
|
|
|
// Validation
|
|
const cv = parseInt(chevaux);
|
|
if (!cv || cv < 3 || cv > 7) {
|
|
return res.status(400).json({
|
|
error: 'Cheval fiscal invalide (valeurs acceptées : 3, 4, 5, 6, 7)'
|
|
});
|
|
}
|
|
|
|
const immatClean = (immatriculation || '')
|
|
.replace(/\s+/g, '-')
|
|
.toUpperCase()
|
|
.slice(0, 20);
|
|
|
|
await pool.request()
|
|
.input('id', sql.Int, req.user.id)
|
|
.input('chevaux', sql.Int, cv)
|
|
.input('marque', sql.NVarChar, (marque || '').trim().slice(0, 100))
|
|
.input('modele', sql.NVarChar, (modele || '').trim().slice(0, 100))
|
|
.input('immat', sql.NVarChar, immatClean)
|
|
.query(`
|
|
UPDATE CollaborateurAD SET
|
|
chevauxFiscaux = @chevaux,
|
|
vehiculeMarque = @marque,
|
|
vehiculeModele = @modele,
|
|
vehiculeImmat = @immat,
|
|
vehiculeDateMaj = GETDATE(),
|
|
DateModification = GETDATE()
|
|
WHERE id = @id
|
|
`);
|
|
|
|
console.log(`✅ Profil véhicule mis à jour — user ${req.user.email} : ${cv} CV`);
|
|
|
|
res.json({
|
|
success: true,
|
|
vehicule: { chevaux: cv, marque: marque || '', modele: modele || '', immatriculation: immatClean }
|
|
});
|
|
} catch (error) {
|
|
console.error('PUT /api/profil/vehicule :', error.message);
|
|
res.status(500).json({ error: 'Erreur serveur' });
|
|
}
|
|
});
|
|
|
|
// ── DELETE /api/profil/vehicule ──────────────────────────
|
|
app.delete('/api/profil/vehicule', authenticateToken, async (req, res) => {
|
|
try {
|
|
await pool.request()
|
|
.input('id', sql.Int, req.user.id)
|
|
.query(`
|
|
UPDATE CollaborateurAD SET
|
|
chevauxFiscaux = NULL,
|
|
vehiculeMarque = NULL,
|
|
vehiculeModele = NULL,
|
|
vehiculeImmat = NULL,
|
|
vehiculeDateMaj = NULL,
|
|
DateModification = GETDATE()
|
|
WHERE id = @id
|
|
`);
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// ================================================
|
|
// GESTION DES ERREURS
|
|
// ================================================
|
|
app.use((err, req, res, next) => {
|
|
console.error('❌ Erreur middleware:', err.stack);
|
|
res.status(500).json({ error: 'Une erreur est survenue', details: process.env.NODE_ENV === 'development' ? err.message : undefined });
|
|
});
|
|
|
|
// ================================================
|
|
// DÉMARRAGE DU SERVEUR
|
|
// ================================================
|
|
const server = app.listen(PORT, '0.0.0.0', () => {
|
|
console.log('\n================================================');
|
|
console.log(`✅ SERVEUR DÉMARRÉ sur http://0.0.0.0:${PORT}`);
|
|
console.log('================================================');
|
|
|
|
setTimeout(async () => {
|
|
console.log('\n🚀 Lancement synchronisation automatique Entra ID...');
|
|
await syncEntraIdUsers();
|
|
setInterval(async () => {
|
|
console.log('\n🔁 Synchronisation périodique Entra ID...');
|
|
await syncEntraIdUsers();
|
|
}, 6 * 60 * 60 * 1000);
|
|
}, 5000);
|
|
});
|
|
|
|
server.on('error', (error) => {
|
|
console.error('\n❌ ERREUR SERVEUR:', error);
|
|
if (error.code === 'EADDRINUSE') console.error(`⚠️ Le port ${PORT} est déjà utilisé`);
|
|
process.exit(1);
|
|
});
|
|
|
|
setInterval(() => { }, 60000); |