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:
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user