Checkpoint: Migration complète vers authentification locale (JWT + bcrypt) : schéma DB (10 tables), routes tRPC (auth, users, etablissements, inventaire, capex, opex, parametres), page de login avec branding Itinova/Santinova, protection des routes, onglets Établissements et Utilisateurs branchés sur tRPC, import inventaire/établissements/utilisateurs via tRPC, 14 tests Vitest passants.

This commit is contained in:
Manus
2026-06-10 12:17:07 +00:00
parent fe14ad99e1
commit caf95da1c1
59 changed files with 9739 additions and 399 deletions

View File

@@ -1,7 +1,8 @@
// ImportModal.tsx — Fenêtre d'import de données (Inventaire, Établissements, Utilisateurs)
// Design: Corporate Modernism — Itinova Budget SI
import { useState, useRef, useEffect } from 'react';
import { useState, useRef, useEffect, useCallback } from 'react';
import { trpc } from '@/lib/trpc';
import { useAnnee, getInventaireStorageKey } from '../contexts/AnneeContext';
import * as XLSX from 'xlsx';
import {
@@ -253,6 +254,17 @@ interface ImportModalProps {
export function ImportModal({ open, onClose }: ImportModalProps) {
const { annee } = useAnnee();
const utils = trpc.useUtils();
const importInventaireMutation = trpc.inventaire.import.useMutation({
onSuccess: () => {
utils.inventaire.get.invalidate();
window.dispatchEvent(new CustomEvent('budgetsi_inventaire_updated', { detail: { annee } }));
},
});
const upsertEtabMutation = trpc.etablissements.upsert.useMutation({
onSuccess: () => utils.etablissements.list.invalidate(),
});
const importUsersMutation = trpc.users.importBulk.useMutation();
const [tab, setTab] = useState<ImportTab>('inventaire');
const [inventaireStatus, setInventaireStatus] = useState<'idle' | 'success' | 'error'>('idle');
const [inventaireMsg, setInventaireMsg] = useState('');
@@ -514,14 +526,24 @@ export function ImportModal({ open, onClose }: ImportModalProps) {
return;
}
// ── Sauvegarder les données complètes de l'inventaire dans localStorage ──────
// Structure : { meta: {...}, etablissements: [ { code, nom, fixes[], portables[], stats } ] }
// ── Construire le payload pour tRPC ──────────────────────────────────────
const postesPayload: Array<{ etablissementCode: string; libelle?: string | null; typePoste: 'fixe' | 'portable'; dateRef?: string | null; ageAns?: string | null; modele?: string | null; fabricant?: string | null }> = [];
etabData.forEach((d, code) => {
d.fixes.forEach(p => postesPayload.push({ etablissementCode: code, libelle: p.libelle || null, typePoste: 'fixe', dateRef: p.date_achat || null, ageAns: p.age_ans !== null ? String(p.age_ans) : null, modele: p.modele || null, fabricant: p.fabricant || null }));
d.portables.forEach(p => postesPayload.push({ etablissementCode: code, libelle: p.libelle || null, typePoste: 'portable', dateRef: p.date_achat || null, ageAns: p.age_ans !== null ? String(p.age_ans) : null, modele: p.modele || null, fabricant: p.fabricant || null }));
});
// ── Envoyer à la base de données via tRPC ──────────────────────────────────
const result = await importInventaireMutation.mutateAsync({ annee, filename: file.name, postes: postesPayload });
// ── Aussi sauvegarder dans localStorage pour compatibilité avec les pages existantes ──
const etablissementsArray = Array.from(etabData.entries()).map(([code, d]) => ({
code,
nom: d.nom,
fixes: d.fixes,
portables: d.portables,
stats: { // stats provisoires, recalculées dynamiquement par useBudgetData
stats: {
nb_fixes_total: d.fixes.length,
nb_portables_total: d.portables.length,
nb_fixes_renouveler: 0,
@@ -533,63 +555,27 @@ export function ImportModal({ open, onClose }: ImportModalProps) {
budget_total: 0,
},
}));
const inventaireData = {
meta: {
date_calcul: new Date().toISOString(),
date_reference: `${annee}-01-01`,
nb_etablissements: etabData.size,
total_fixes: nbFixes,
total_portables: nbPortables,
filename: file.name,
},
meta: { date_calcul: new Date().toISOString(), date_reference: `${annee}-01-01`, nb_etablissements: etabData.size, total_fixes: nbFixes, total_portables: nbPortables, filename: file.name },
etablissements: etablissementsArray,
};
localStorage.setItem(getInventaireStorageKey(annee), JSON.stringify(inventaireData));
localStorage.setItem(`budgetsi_inventaire_import_${annee}`, JSON.stringify({ filename: file.name, date: new Date().toISOString(), size: file.size, nbLignes: rows.length - 1, nbEtablissements: etabData.size, nbFixes, nbPortables, format: useIsiFormat ? 'isi-app' : 'classique' }));
// ── Mettre à jour aussi la liste des établissements (code + nom) ────────────
const existing = loadEtablissements();
const existingMap = new Map(existing.map(e => [e.code, e]));
let nbNouveaux = 0;
let nbMisAJour = 0;
etabData.forEach((info, code) => {
if (existingMap.has(code)) {
const ex = existingMap.get(code)!;
if (!ex.nom && info.nom) {
existingMap.set(code, { ...ex, nom: info.nom });
nbMisAJour++;
}
} else {
existingMap.set(code, { code, nom: info.nom, groupe: info.groupe, ville: info.ville, actif: true });
nbNouveaux++;
}
});
saveEtablissements(Array.from(existingMap.values()));
// ── Métadonnées d'import ────────────────────────────────────────────────────────
const nbLignes = rows.length - 1;
localStorage.setItem(`budgetsi_inventaire_import_${annee}`, JSON.stringify({
filename: file.name,
date: new Date().toISOString(),
size: file.size,
nbLignes,
nbEtablissements: etabData.size,
nbFixes,
nbPortables,
format: useIsiFormat ? 'isi-app' : 'classique',
}));
// ── Upsert établissements dans la BDD ──────────────────────────────────────
for (const [code, info] of Array.from(etabData.entries())) {
try {
await upsertEtabMutation.mutateAsync({ code, nom: info.nom, groupe: info.groupe ?? null, ville: info.ville ?? null, actif: true });
} catch { /* ignore les erreurs d'upsert établissement */ }
}
const nbNouveaux = result.nbEtablissements;
const details = [
`${nbLignes} poste${nbLignes > 1 ? 's' : ''}`,
`${rows.length - 1} poste${rows.length - 1 > 1 ? 's' : ''}`,
`${etabData.size} établissement${etabData.size > 1 ? 's' : ''} détectés`,
nbNouveaux > 0 ? `${nbNouveaux} nouveau${nbNouveaux > 1 ? 'x' : ''}` : null,
`${nbFixes} fixes`,
`${nbPortables} portables`,
].filter(Boolean).join(' — ');
// Notifier les autres composants (useBudgetData, DsiCapex, Budget2027)
window.dispatchEvent(new CustomEvent('budgetsi_inventaire_updated', { detail: { annee } }));
].join(' — ');
setInventaireStatus('success');
setInventaireMsg(`"${file.name}" importé — ${details}`);
@@ -643,10 +629,19 @@ export function ImportModal({ open, onClose }: ImportModalProps) {
};
}).filter(e => e.code && e.nom);
// Upsert dans la BDD via tRPC
let nbImported = 0;
for (const etab of etabs) {
try {
await upsertEtabMutation.mutateAsync({ code: etab.code, nom: etab.nom, groupe: etab.groupe ?? null, ville: etab.ville ?? null, actif: etab.actif });
nbImported++;
} catch { /* ignore */ }
}
// Aussi sauvegarder en localStorage pour compatibilité
saveEtablissements(etabs);
setEtabStatus('success');
setEtabMsg(`${etabs.length} établissements importés`);
toast.success('Établissements importés', { description: `${etabs.length} établissements chargés` });
setEtabMsg(`${nbImported} établissements importés`);
toast.success('Établissements importés', { description: `${nbImported} établissements chargés` });
} catch {
setEtabStatus('error');
setEtabMsg('Erreur lors de la lecture du fichier');
@@ -701,10 +696,27 @@ export function ImportModal({ open, onClose }: ImportModalProps) {
};
}).filter(u => u.nom && u.email);
saveUtilisateurs(users);
setUserStatus('success');
setUserMsg(`${users.length} utilisateurs importés`);
toast.success('Utilisateurs importés', { description: `${users.length} utilisateurs chargés` });
// Import dans la BDD via tRPC
const usersPayload = users.map(u => ({
login: u.email.split('@')[0].replace(/[^a-zA-Z0-9._-]/g, '') || `user_${Date.now()}`,
password: 'Itinova2027!',
email: u.email || null,
firstName: u.prenom || null,
lastName: u.nom || null,
role: (u.role === 'lecture' ? 'readonly' : u.role) as 'admin' | 'standard' | 'readonly',
}));
try {
const result = await importUsersMutation.mutateAsync(usersPayload);
setUserStatus('success');
setUserMsg(`${result.created} utilisateurs créés, ${result.skipped} ignorés (login existant)`);
toast.success('Utilisateurs importés', { description: `${result.created} créés — mot de passe par défaut : Itinova2027!` });
} catch (err) {
// Fallback localStorage
saveUtilisateurs(users);
setUserStatus('success');
setUserMsg(`${users.length} utilisateurs importés (local)`);
toast.success('Utilisateurs importés', { description: `${users.length} utilisateurs chargés` });
}
} catch {
setUserStatus('error');
setUserMsg('Erreur lors de la lecture du fichier');