Checkpoint: Migration complète localStorage → BDD : useBudgetData, ParametresContext, Budget2027, DsiCapex, DsiOpex, ImportModal (indicateur inventaires + suppression double-écriture). Ajout procédures tRPC : inventaire.listMeta, inventaire.getStats, opex.initFromSource, opex.deleteAll, opex.cloneFromN1. 14 tests Vitest passants. 0 erreur TypeScript.

This commit is contained in:
Manus
2026-06-10 12:49:29 +00:00
parent caf95da1c1
commit 020156d62c
10 changed files with 1027 additions and 1088 deletions

View File

@@ -1,64 +1,11 @@
// Hook pour charger et gérer les données budget depuis localStorage (import inventaire par année)
// Hook pour charger et gérer les données budget depuis la BDD via tRPC
// Design: Corporate Modernism — Itinova Budget SI 2027
import { useState, useMemo, useEffect } from 'react';
import type { BudgetData, Etablissement, EtablissementStats, SortField, SortOrder, FilterType } from '../types/budget';
import { useState, useMemo } from 'react';
import { trpc } from '@/lib/trpc';
import type { SortField, SortOrder, FilterType } from '../types/budget';
import { useParametres } from '../contexts/ParametresContext';
import { useAnnee, getInventaireStorageKey } from '../contexts/AnneeContext';
// ─── Lecture localStorage ─────────────────────────────────────────────────────
function loadInventaireData(annee: number): BudgetData | null {
try {
const raw = localStorage.getItem(getInventaireStorageKey(annee));
if (!raw) return null;
return JSON.parse(raw) as BudgetData;
} catch {
return null;
}
}
// ─── Recalcul des stats ───────────────────────────────────────────────────────
function recalculerStats(
etab: Etablissement,
seuilFixesAns: number,
seuilPortablesAns: number,
coutFixe: number,
coutPortable: number
): EtablissementStats {
const fixes = etab.fixes ?? [];
const portables = etab.portables ?? [];
const nbFixesTotal = fixes.length;
const nbPortablesTotal = portables.length;
const nbFixesRenouveler = fixes.filter(
(f) => f.age_ans !== null && f.age_ans >= seuilFixesAns
).length;
const nbPortablesRenouveler = portables.filter(
(p) => p.age_ans !== null && p.age_ans >= seuilPortablesAns
).length;
const nbFixesSansDate = fixes.filter((f) => f.age_ans === null).length;
const nbPortablesSansDate = portables.filter((p) => p.age_ans === null).length;
const budgetFixes = nbFixesRenouveler * coutFixe;
const budgetPortables = nbPortablesRenouveler * coutPortable;
const budgetTotal = budgetFixes + budgetPortables;
return {
nb_fixes_total: nbFixesTotal,
nb_portables_total: nbPortablesTotal,
nb_fixes_renouveler: nbFixesRenouveler,
nb_portables_renouveler: nbPortablesRenouveler,
nb_fixes_sans_date: nbFixesSansDate,
nb_portables_sans_date: nbPortablesSansDate,
budget_fixes: budgetFixes,
budget_portables: budgetPortables,
budget_total: budgetTotal,
};
}
import { useAnnee } from '../contexts/AnneeContext';
// ─── Hook principal ───────────────────────────────────────────────────────────
@@ -70,82 +17,65 @@ export function useBudgetData() {
const [sortOrder, setSortOrder] = useState<SortOrder>('desc');
const [filterType, setFilterType] = useState<FilterType>('all');
// Données brutes depuis localStorage — rechargées à chaque changement d'année + écoute storage
const [rawData, setRawData] = useState<BudgetData | null>(() => loadInventaireData(annee));
// Charger les stats depuis la BDD via tRPC
const { data, isLoading } = trpc.inventaire.getStats.useQuery(
{
annee,
seuilFixesAns: parametres.seuilFixesAns,
seuilPortablesAns: parametres.seuilPortablesAns,
coutFixe: parametres.coutFixe,
coutPortable: parametres.coutPortable,
},
{
retry: false,
refetchOnWindowFocus: false,
}
);
// Recharger quand l'année change
useEffect(() => {
setRawData(loadInventaireData(annee));
}, [annee]);
useEffect(() => {
const onStorage = (e: StorageEvent) => {
if (e.key === getInventaireStorageKey(annee)) {
setRawData(loadInventaireData(annee));
}
};
window.addEventListener('storage', onStorage);
// Écouter aussi les changements dans le même onglet via custom event
const onImport = (e: Event) => {
const detail = (e as CustomEvent).detail;
// Recharger seulement si l'import concerne l'année active
if (!detail || detail.annee === annee) {
setRawData(loadInventaireData(annee));
}
};
window.addEventListener('budgetsi_inventaire_updated', onImport);
return () => {
window.removeEventListener('storage', onStorage);
window.removeEventListener('budgetsi_inventaire_updated', onImport);
};
}, [annee]);
// Recalcul de tous les établissements selon les paramètres courants
const etablissementsRecalcules = useMemo<Etablissement[]>(() => {
if (!rawData) return [];
return rawData.etablissements.map((etab) => ({
...etab,
stats: recalculerStats(
etab,
parametres.seuilFixesAns,
parametres.seuilPortablesAns,
parametres.coutFixe,
parametres.coutPortable
),
}));
}, [rawData, parametres]);
const etablissementsRecalcules = data?.etablissements ?? [];
const metaDb = data?.meta;
// Méta recalculée (totaux globaux)
const metaRecalculee = useMemo(() => {
const totalFixesR = etablissementsRecalcules.reduce((s, e) => s + e.stats.nb_fixes_renouveler, 0);
const totalPortablesR = etablissementsRecalcules.reduce((s, e) => s + e.stats.nb_portables_renouveler, 0);
const totalFixes = etablissementsRecalcules.reduce((s, e) => s + e.stats.nb_fixes_total, 0);
const totalPortables = etablissementsRecalcules.reduce((s, e) => s + e.stats.nb_portables_total, 0);
const budgetGlobal = etablissementsRecalcules.reduce((s, e) => s + e.stats.budget_total, 0);
if (metaDb) {
return {
date_calcul: metaDb.dateImport?.toISOString() ?? new Date().toISOString(),
date_reference: `${annee}-01-01`,
seuil_fixes_ans: metaDb.seuil_fixes_ans,
seuil_portables_ans: metaDb.seuil_portables_ans,
cout_fixe: metaDb.cout_fixe,
cout_portable: metaDb.cout_portable,
nb_etablissements: metaDb.nb_etablissements,
budget_global: metaDb.budget_global,
total_fixes: metaDb.total_fixes,
total_portables: metaDb.total_portables,
total_fixes_renouveler: metaDb.total_fixes_renouveler,
total_portables_renouveler: metaDb.total_portables_renouveler,
};
}
return {
date_calcul: rawData?.meta?.date_calcul ?? new Date().toISOString(),
date_calcul: new Date().toISOString(),
date_reference: `${annee}-01-01`,
seuil_fixes_ans: parametres.seuilFixesAns,
seuil_portables_ans: parametres.seuilPortablesAns,
cout_fixe: parametres.coutFixe,
cout_portable: parametres.coutPortable,
nb_etablissements: etablissementsRecalcules.length,
budget_global: budgetGlobal,
total_fixes: totalFixes,
total_portables: totalPortables,
total_fixes_renouveler: totalFixesR,
total_portables_renouveler: totalPortablesR,
nb_etablissements: 0,
budget_global: 0,
total_fixes: 0,
total_portables: 0,
total_fixes_renouveler: 0,
total_portables_renouveler: 0,
};
}, [etablissementsRecalcules, rawData, parametres, annee]);
}, [metaDb, parametres, annee]);
const filteredAndSorted = useMemo(() => {
let list: Etablissement[] = [...etablissementsRecalcules];
let list = [...etablissementsRecalcules];
if (search.trim()) {
const q = search.toLowerCase().trim();
list = list.filter(
(e) =>
e.nom.toLowerCase().includes(q) ||
e.code.toLowerCase().includes(q)
);
}
@@ -162,8 +92,8 @@ export function useBudgetData() {
switch (sortField) {
case 'nom':
valA = a.nom;
valB = b.nom;
valA = a.code;
valB = b.code;
break;
case 'code':
valA = a.code;
@@ -213,7 +143,8 @@ export function useBudgetData() {
meta: metaRecalculee,
etablissements: filteredAndSorted,
totalEtablissements: etablissementsRecalcules.length,
hasInventaire: rawData !== null,
hasInventaire: metaDb !== null && metaDb !== undefined,
isLoading,
search,
setSearch,
sortField,