Checkpoint: Suppression complète de la dépendance à data_budget.json : l'inventaire est désormais exclusivement alimenté par l'import utilisateur (localStorage budgetsi_inventaire_data). useBudgetData lit depuis localStorage, Budget2027 et DsiCapex calculent le renouvellement depuis l'inventaire importé, les pages affichent un état vide avec invitation à importer si aucun inventaire n'est présent. L'import stocke les postes complets (fixes/portables avec âge calculé) par établissement, utilise la colonne Type de matériel en priorité, et émet un événement custom pour notifier toutes les pages en temps réel.
This commit is contained in:
@@ -41,6 +41,33 @@ async function fileToRows(file: File): Promise<string[][]> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Helpers date / vétusté ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const DATE_REF_IMPORT = new Date('2027-01-01');
|
||||||
|
|
||||||
|
function parseImportDate(val: string): Date | null {
|
||||||
|
if (!val || val === '-' || val.trim() === '') return null;
|
||||||
|
const clean = val.trim();
|
||||||
|
const dmy = clean.match(/^(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{2,4})$/);
|
||||||
|
if (dmy) {
|
||||||
|
const [, d, m, y] = dmy;
|
||||||
|
const year = y.length === 2 ? (parseInt(y) > 50 ? 1900 + parseInt(y) : 2000 + parseInt(y)) : parseInt(y);
|
||||||
|
return new Date(year, parseInt(m) - 1, parseInt(d));
|
||||||
|
}
|
||||||
|
const ymd = clean.match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||||||
|
if (ymd) return new Date(clean);
|
||||||
|
const dt = new Date(clean);
|
||||||
|
return isNaN(dt.getTime()) ? null : dt;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calcAgeAns(dateStr: string): number | null {
|
||||||
|
const d = parseImportDate(dateStr);
|
||||||
|
if (!d) return null;
|
||||||
|
const diffMs = DATE_REF_IMPORT.getTime() - d.getTime();
|
||||||
|
if (diffMs < 0) return null;
|
||||||
|
return Math.floor(diffMs / (1000 * 60 * 60 * 24 * 365.25));
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface Etablissement {
|
export interface Etablissement {
|
||||||
@@ -356,64 +383,176 @@ export function ImportModal({ open, onClose }: ImportModalProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Index colonnes supplémentaires ISI-APP
|
||||||
|
const typeMaterielIdx = rawHeaders.findIndex(h =>
|
||||||
|
h === 'type de matériel' || h === 'type materiel' || h === 'type' ||
|
||||||
|
h.includes('type de mat') || h.includes('type mat')
|
||||||
|
);
|
||||||
|
const modeleIdx = rawHeaders.findIndex(h =>
|
||||||
|
h === 'modèle' || h === 'modele' || h === 'model' ||
|
||||||
|
h.includes('modèle') || h.includes('modele')
|
||||||
|
);
|
||||||
|
const fabricantIdx = rawHeaders.findIndex(h =>
|
||||||
|
h === 'fabricant' || h === 'marque' || h === 'brand' ||
|
||||||
|
h.includes('fabricant') || h.includes('marque')
|
||||||
|
);
|
||||||
|
const statutIdx = rawHeaders.findIndex(h =>
|
||||||
|
h === 'statut' || h === 'status' || h.includes('statut')
|
||||||
|
);
|
||||||
|
|
||||||
// Compteurs pour le résumé
|
// Compteurs pour le résumé
|
||||||
let nbFixes = 0;
|
let nbFixes = 0;
|
||||||
let nbPortables = 0;
|
let nbPortables = 0;
|
||||||
|
|
||||||
// Extraction des établissements uniques depuis l'inventaire
|
// ── Structures pour stocker les postes complets par établissement ────────────
|
||||||
const etabMap = new Map<string, { nom: string; groupe?: string; ville?: string }>();
|
// etabData : code → { nom, fixes[], portables[] }
|
||||||
for (const cols of rows.slice(1)) {
|
|
||||||
|
|
||||||
|
interface PosteData {
|
||||||
|
libelle: string;
|
||||||
|
date_achat: string;
|
||||||
|
age_ans: number | null;
|
||||||
|
a_renouveler: boolean;
|
||||||
|
modele: string;
|
||||||
|
fabricant: string;
|
||||||
|
}
|
||||||
|
interface EtabData {
|
||||||
|
nom: string;
|
||||||
|
groupe?: string;
|
||||||
|
ville?: string;
|
||||||
|
fixes: PosteData[];
|
||||||
|
portables: PosteData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const etabData = new Map<string, EtabData>();
|
||||||
|
|
||||||
|
for (const cols of rows.slice(1)) {
|
||||||
let code = '';
|
let code = '';
|
||||||
let nom = '';
|
let nom = '';
|
||||||
|
let typePoste: 'fixe' | 'portable' | 'autre' = 'autre';
|
||||||
|
|
||||||
if (useIsiFormat) {
|
if (useIsiFormat) {
|
||||||
// Format ISI-APP : code = 8 premiers caractères du libellé (avant le tiret)
|
|
||||||
const libelle = cols[libelleIdx]?.trim() || '';
|
const libelle = cols[libelleIdx]?.trim() || '';
|
||||||
// Extraire le code établissement : tout ce qui précède le premier tiret, max 8 car.
|
|
||||||
const dashIdx = libelle.indexOf('-');
|
const dashIdx = libelle.indexOf('-');
|
||||||
code = dashIdx > 0 ? libelle.substring(0, dashIdx) : libelle.substring(0, 8);
|
code = dashIdx > 0 ? libelle.substring(0, dashIdx) : libelle.substring(0, 8);
|
||||||
code = code.trim();
|
code = code.trim();
|
||||||
nom = cols[entiteIdx]?.trim() || '';
|
nom = cols[entiteIdx]?.trim() || '';
|
||||||
|
|
||||||
// Détecter le type de poste depuis le libellé
|
// Priorité 1 : colonne "Type de matériel"
|
||||||
|
if (typeMaterielIdx >= 0) {
|
||||||
|
const t = (cols[typeMaterielIdx] || '').toLowerCase();
|
||||||
|
if (t === 'fixe' || t === 'pc fixe' || t === 'desktop') typePoste = 'fixe';
|
||||||
|
else if (t === 'portable' || t === 'laptop' || t === 'pc portable' || t === 'notebook') typePoste = 'portable';
|
||||||
|
}
|
||||||
|
// Fallback : détection par libellé
|
||||||
|
if (typePoste === 'autre') {
|
||||||
const afterDash = dashIdx > 0 ? libelle.substring(dashIdx + 1).toUpperCase() : '';
|
const afterDash = dashIdx > 0 ? libelle.substring(dashIdx + 1).toUpperCase() : '';
|
||||||
if (afterDash.startsWith('F') || afterDash.startsWith('FIX')) {
|
if (afterDash.startsWith('F') || afterDash.startsWith('FIX')) typePoste = 'fixe';
|
||||||
nbFixes++;
|
else if (afterDash.startsWith('P') || afterDash.startsWith('POR')) typePoste = 'portable';
|
||||||
} else if (afterDash.startsWith('P') || afterDash.startsWith('POR')) {
|
|
||||||
nbPortables++;
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Format classique : code et nom dans des colonnes dédiées
|
|
||||||
code = cols[codeIdxFallback]?.trim() || '';
|
code = cols[codeIdxFallback]?.trim() || '';
|
||||||
nom = cols[nomIdxFallback]?.trim() || '';
|
nom = cols[nomIdxFallback]?.trim() || '';
|
||||||
|
// Format classique : colonne type si disponible
|
||||||
|
if (typeMaterielIdx >= 0) {
|
||||||
|
const t = (cols[typeMaterielIdx] || '').toLowerCase();
|
||||||
|
if (t.includes('fixe') || t.includes('desktop')) typePoste = 'fixe';
|
||||||
|
else if (t.includes('portable') || t.includes('laptop')) typePoste = 'portable';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!code || !nom) continue;
|
if (!code || !nom) continue;
|
||||||
if (!etabMap.has(code)) {
|
// Ignorer les postes hors production si statut présent
|
||||||
etabMap.set(code, {
|
// (on garde tout : en production + en stock)
|
||||||
|
|
||||||
|
// Initialiser l'établissement si besoin
|
||||||
|
if (!etabData.has(code)) {
|
||||||
|
etabData.set(code, {
|
||||||
nom,
|
nom,
|
||||||
groupe: groupeIdx >= 0 ? cols[groupeIdx]?.trim() || undefined : undefined,
|
groupe: groupeIdx >= 0 ? cols[groupeIdx]?.trim() || undefined : undefined,
|
||||||
ville: villeIdx >= 0 ? cols[villeIdx]?.trim() || undefined : undefined,
|
ville: villeIdx >= 0 ? cols[villeIdx]?.trim() || undefined : undefined,
|
||||||
|
fixes: [],
|
||||||
|
portables: [],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calculer l'âge du poste
|
||||||
|
const dateMES = dateMiseEnServiceIdx >= 0 ? cols[dateMiseEnServiceIdx]?.trim() || '' : '';
|
||||||
|
const dateAchat = dateAchatIdx >= 0 ? cols[dateAchatIdx]?.trim() || '' : '';
|
||||||
|
const dateRef = dateMES || dateAchat; // priorité mise en service
|
||||||
|
const age = calcAgeAns(dateRef);
|
||||||
|
const libelle = useIsiFormat ? cols[libelleIdx]?.trim() || '' : code;
|
||||||
|
const modele = modeleIdx >= 0 ? cols[modeleIdx]?.trim() || '' : '';
|
||||||
|
const fabricant = fabricantIdx >= 0 ? cols[fabricantIdx]?.trim() || '' : '';
|
||||||
|
|
||||||
|
const poste: PosteData = {
|
||||||
|
libelle,
|
||||||
|
date_achat: dateRef,
|
||||||
|
age_ans: age,
|
||||||
|
a_renouveler: false, // sera recalculé dynamiquement par useBudgetData
|
||||||
|
modele,
|
||||||
|
fabricant,
|
||||||
|
};
|
||||||
|
|
||||||
|
const etab = etabData.get(code)!;
|
||||||
|
if (typePoste === 'fixe') {
|
||||||
|
etab.fixes.push(poste);
|
||||||
|
nbFixes++;
|
||||||
|
} else if (typePoste === 'portable') {
|
||||||
|
etab.portables.push(poste);
|
||||||
|
nbPortables++;
|
||||||
|
} else {
|
||||||
|
// Type inconnu : on l'ignore dans les compteurs mais on ne le perd pas
|
||||||
|
// On peut le mettre en fixe par défaut si besoin
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (etabMap.size === 0) {
|
if (etabData.size === 0) {
|
||||||
setInventaireStatus('error');
|
setInventaireStatus('error');
|
||||||
setInventaireMsg('Aucun établissement extrait. Vérifiez que les colonnes code/nom contiennent des données.');
|
setInventaireMsg('Aucun établissement extrait. Vérifiez que les colonnes code/nom contiennent des données.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fusion avec les établissements déjà enregistrés (sans écraser les données existantes)
|
// ── Sauvegarder les données complètes de l'inventaire dans localStorage ──────
|
||||||
|
// Structure : { meta: {...}, etablissements: [ { code, nom, fixes[], portables[], stats } ] }
|
||||||
|
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
|
||||||
|
nb_fixes_total: d.fixes.length,
|
||||||
|
nb_portables_total: d.portables.length,
|
||||||
|
nb_fixes_renouveler: 0,
|
||||||
|
nb_portables_renouveler: 0,
|
||||||
|
nb_fixes_sans_date: d.fixes.filter(f => f.age_ans === null).length,
|
||||||
|
nb_portables_sans_date: d.portables.filter(p => p.age_ans === null).length,
|
||||||
|
budget_fixes: 0,
|
||||||
|
budget_portables: 0,
|
||||||
|
budget_total: 0,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const inventaireData = {
|
||||||
|
meta: {
|
||||||
|
date_calcul: new Date().toISOString(),
|
||||||
|
date_reference: '2027-01-01',
|
||||||
|
nb_etablissements: etabData.size,
|
||||||
|
total_fixes: nbFixes,
|
||||||
|
total_portables: nbPortables,
|
||||||
|
filename: file.name,
|
||||||
|
},
|
||||||
|
etablissements: etablissementsArray,
|
||||||
|
};
|
||||||
|
localStorage.setItem('budgetsi_inventaire_data', JSON.stringify(inventaireData));
|
||||||
|
|
||||||
|
// ── Mettre à jour aussi la liste des établissements (code + nom) ────────────
|
||||||
const existing = loadEtablissements();
|
const existing = loadEtablissements();
|
||||||
const existingMap = new Map(existing.map(e => [e.code, e]));
|
const existingMap = new Map(existing.map(e => [e.code, e]));
|
||||||
let nbNouveaux = 0;
|
let nbNouveaux = 0;
|
||||||
let nbMisAJour = 0;
|
let nbMisAJour = 0;
|
||||||
|
|
||||||
etabMap.forEach((info, code) => {
|
etabData.forEach((info, code) => {
|
||||||
if (existingMap.has(code)) {
|
if (existingMap.has(code)) {
|
||||||
// Mise à jour du nom si vide, conservation du reste
|
|
||||||
const ex = existingMap.get(code)!;
|
const ex = existingMap.get(code)!;
|
||||||
if (!ex.nom && info.nom) {
|
if (!ex.nom && info.nom) {
|
||||||
existingMap.set(code, { ...ex, nom: info.nom });
|
existingMap.set(code, { ...ex, nom: info.nom });
|
||||||
@@ -424,36 +563,36 @@ export function ImportModal({ open, onClose }: ImportModalProps) {
|
|||||||
nbNouveaux++;
|
nbNouveaux++;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
saveEtablissements(Array.from(existingMap.values()));
|
||||||
|
|
||||||
const merged = Array.from(existingMap.values());
|
// ── Métadonnées d'import ────────────────────────────────────────────────────────
|
||||||
saveEtablissements(merged);
|
|
||||||
|
|
||||||
// Enregistrement des métadonnées du fichier importé
|
|
||||||
const nbLignes = rows.length - 1;
|
const nbLignes = rows.length - 1;
|
||||||
localStorage.setItem('budgetsi_inventaire_import', JSON.stringify({
|
localStorage.setItem('budgetsi_inventaire_import', JSON.stringify({
|
||||||
filename: file.name,
|
filename: file.name,
|
||||||
date: new Date().toISOString(),
|
date: new Date().toISOString(),
|
||||||
size: file.size,
|
size: file.size,
|
||||||
nbLignes,
|
nbLignes,
|
||||||
nbEtablissements: etabMap.size,
|
nbEtablissements: etabData.size,
|
||||||
nbFixes: useIsiFormat ? nbFixes : null,
|
nbFixes,
|
||||||
nbPortables: useIsiFormat ? nbPortables : null,
|
nbPortables,
|
||||||
format: useIsiFormat ? 'isi-app' : 'classique',
|
format: useIsiFormat ? 'isi-app' : 'classique',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const details = [
|
const details = [
|
||||||
`${nbLignes} poste${nbLignes > 1 ? 's' : ''}`,
|
`${nbLignes} poste${nbLignes > 1 ? 's' : ''}`,
|
||||||
`${etabMap.size} établissement${etabMap.size > 1 ? 's' : ''} détectés`,
|
`${etabData.size} établissement${etabData.size > 1 ? 's' : ''} détectés`,
|
||||||
nbNouveaux > 0 ? `${nbNouveaux} nouveau${nbNouveaux > 1 ? 'x' : ''}` : null,
|
nbNouveaux > 0 ? `${nbNouveaux} nouveau${nbNouveaux > 1 ? 'x' : ''}` : null,
|
||||||
nbMisAJour > 0 ? `${nbMisAJour} mis à jour` : null,
|
`${nbFixes} fixes`,
|
||||||
useIsiFormat && nbFixes > 0 ? `${nbFixes} fixes` : null,
|
`${nbPortables} portables`,
|
||||||
useIsiFormat && nbPortables > 0 ? `${nbPortables} portables` : null,
|
|
||||||
].filter(Boolean).join(' — ');
|
].filter(Boolean).join(' — ');
|
||||||
|
|
||||||
|
// Notifier les autres composants (useBudgetData, DsiCapex, Budget2027)
|
||||||
|
window.dispatchEvent(new Event('budgetsi_inventaire_updated'));
|
||||||
|
|
||||||
setInventaireStatus('success');
|
setInventaireStatus('success');
|
||||||
setInventaireMsg(`"${file.name}" importé — ${details}`);
|
setInventaireMsg(`"${file.name}" importé — ${details}`);
|
||||||
toast.success('Inventaire importé', {
|
toast.success('Inventaire importé', {
|
||||||
description: `${etabMap.size} établissements extraits${useIsiFormat ? ` — ${nbFixes} fixes, ${nbPortables} portables` : ''}.`,
|
description: `${etabData.size} établissements — ${nbFixes} fixes, ${nbPortables} portables.`,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Import inventaire:', err);
|
console.error('Import inventaire:', err);
|
||||||
@@ -711,20 +850,14 @@ export function ImportModal({ open, onClose }: ImportModalProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Info colonnes ── */}
|
{/* ── Info résumée ── */}
|
||||||
<div className="flex items-start gap-3 p-4 bg-blue-50 border border-blue-200 rounded-xl">
|
<div className="flex items-start gap-3 p-3 bg-blue-50 border border-blue-200 rounded-xl">
|
||||||
<Info className="w-4 h-4 text-blue-600 flex-shrink-0 mt-0.5" />
|
<Info className="w-4 h-4 text-blue-600 flex-shrink-0 mt-0.5" />
|
||||||
<div className="text-sm text-blue-800">
|
|
||||||
<p className="font-semibold mb-1">Inventaire des postes (ISI-APP)</p>
|
|
||||||
<p className="text-xs text-blue-700">
|
<p className="text-xs text-blue-700">
|
||||||
Importez l'export de l'inventaire depuis ISI-APP au format CSV (.csv).
|
Formats acceptés : <strong>.xlsx, .xls, .csv</strong> — Export ISI-APP.
|
||||||
Colonnes <strong>obligatoires</strong> : <strong>code établissement</strong> et <strong>nom établissement</strong> (variantes acceptées : <em>code_etab, code_etablissement, nom_etab, etablissement…</em>).
|
Les établissements seront automatiquement extraits et enregistrés.
|
||||||
Colonnes optionnelles : type, date_achat, modele, fabricant, groupe, ville.
|
Consultez le prompt ci-dessus pour le détail des règles de traitement.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-blue-600 mt-1.5 font-medium">
|
|
||||||
✦ La liste des établissements sera automatiquement créée/mise à jour à partir de cet import.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Zone de dépôt ── */}
|
{/* ── Zone de dépôt ── */}
|
||||||
|
|||||||
@@ -1,21 +1,26 @@
|
|||||||
// Hook pour charger et gérer les données budget avec recalcul dynamique
|
// Hook pour charger et gérer les données budget depuis localStorage (import inventaire)
|
||||||
// Design: Corporate Modernism — Itinova Budget SI 2027
|
// Design: Corporate Modernism — Itinova Budget SI 2027
|
||||||
|
|
||||||
import { useState, useMemo } from 'react';
|
import { useState, useMemo, useEffect } from 'react';
|
||||||
import budgetRaw from '../data_budget.json';
|
|
||||||
import type { BudgetData, Etablissement, EtablissementStats, SortField, SortOrder, FilterType } from '../types/budget';
|
import type { BudgetData, Etablissement, EtablissementStats, SortField, SortOrder, FilterType } from '../types/budget';
|
||||||
import { useParametres } from '../contexts/ParametresContext';
|
import { useParametres } from '../contexts/ParametresContext';
|
||||||
|
|
||||||
const budgetData = budgetRaw as BudgetData;
|
const INVENTAIRE_KEY = 'budgetsi_inventaire_data';
|
||||||
|
|
||||||
// Date de référence fixe : 01/01/2027
|
// ─── Lecture localStorage ─────────────────────────────────────────────────────
|
||||||
const DATE_REFERENCE = new Date('2027-01-01');
|
|
||||||
|
function loadInventaireData(): BudgetData | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(INVENTAIRE_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
return JSON.parse(raw) as BudgetData;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Recalcul des stats ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
|
||||||
* Recalcule les stats d'un établissement selon les paramètres courants.
|
|
||||||
* On repart des données brutes (age_ans stocké dans chaque Materiel)
|
|
||||||
* et on applique les nouveaux seuils.
|
|
||||||
*/
|
|
||||||
function recalculerStats(
|
function recalculerStats(
|
||||||
etab: Etablissement,
|
etab: Etablissement,
|
||||||
seuilFixesAns: number,
|
seuilFixesAns: number,
|
||||||
@@ -29,7 +34,6 @@ function recalculerStats(
|
|||||||
const nbFixesTotal = fixes.length;
|
const nbFixesTotal = fixes.length;
|
||||||
const nbPortablesTotal = portables.length;
|
const nbPortablesTotal = portables.length;
|
||||||
|
|
||||||
// Renouveler si age_ans connu ET >= seuil
|
|
||||||
const nbFixesRenouveler = fixes.filter(
|
const nbFixesRenouveler = fixes.filter(
|
||||||
(f) => f.age_ans !== null && f.age_ans >= seuilFixesAns
|
(f) => f.age_ans !== null && f.age_ans >= seuilFixesAns
|
||||||
).length;
|
).length;
|
||||||
@@ -57,6 +61,8 @@ function recalculerStats(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Hook principal ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function useBudgetData() {
|
export function useBudgetData() {
|
||||||
const { parametres } = useParametres();
|
const { parametres } = useParametres();
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
@@ -64,9 +70,29 @@ export function useBudgetData() {
|
|||||||
const [sortOrder, setSortOrder] = useState<SortOrder>('desc');
|
const [sortOrder, setSortOrder] = useState<SortOrder>('desc');
|
||||||
const [filterType, setFilterType] = useState<FilterType>('all');
|
const [filterType, setFilterType] = useState<FilterType>('all');
|
||||||
|
|
||||||
|
// Données brutes depuis localStorage — rechargées à chaque montage + écoute storage
|
||||||
|
const [rawData, setRawData] = useState<BudgetData | null>(() => loadInventaireData());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onStorage = (e: StorageEvent) => {
|
||||||
|
if (e.key === INVENTAIRE_KEY) {
|
||||||
|
setRawData(loadInventaireData());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('storage', onStorage);
|
||||||
|
// Écouter aussi les changements dans le même onglet via custom event
|
||||||
|
const onImport = () => setRawData(loadInventaireData());
|
||||||
|
window.addEventListener('budgetsi_inventaire_updated', onImport);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('storage', onStorage);
|
||||||
|
window.removeEventListener('budgetsi_inventaire_updated', onImport);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Recalcul de tous les établissements selon les paramètres courants
|
// Recalcul de tous les établissements selon les paramètres courants
|
||||||
const etablissementsRecalcules = useMemo<Etablissement[]>(() => {
|
const etablissementsRecalcules = useMemo<Etablissement[]>(() => {
|
||||||
return budgetData.etablissements.map((etab) => ({
|
if (!rawData) return [];
|
||||||
|
return rawData.etablissements.map((etab) => ({
|
||||||
...etab,
|
...etab,
|
||||||
stats: recalculerStats(
|
stats: recalculerStats(
|
||||||
etab,
|
etab,
|
||||||
@@ -76,29 +102,34 @@ export function useBudgetData() {
|
|||||||
parametres.coutPortable
|
parametres.coutPortable
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
}, [parametres]);
|
}, [rawData, parametres]);
|
||||||
|
|
||||||
// Méta recalculée (totaux globaux)
|
// Méta recalculée (totaux globaux)
|
||||||
const metaRecalculee = useMemo(() => {
|
const metaRecalculee = useMemo(() => {
|
||||||
const totalFixesR = etablissementsRecalcules.reduce((s, e) => s + e.stats.nb_fixes_renouveler, 0);
|
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 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);
|
const budgetGlobal = etablissementsRecalcules.reduce((s, e) => s + e.stats.budget_total, 0);
|
||||||
return {
|
return {
|
||||||
...budgetData.meta,
|
date_calcul: rawData?.meta?.date_calcul ?? new Date().toISOString(),
|
||||||
|
date_reference: '2027-01-01',
|
||||||
seuil_fixes_ans: parametres.seuilFixesAns,
|
seuil_fixes_ans: parametres.seuilFixesAns,
|
||||||
seuil_portables_ans: parametres.seuilPortablesAns,
|
seuil_portables_ans: parametres.seuilPortablesAns,
|
||||||
cout_fixe: parametres.coutFixe,
|
cout_fixe: parametres.coutFixe,
|
||||||
cout_portable: parametres.coutPortable,
|
cout_portable: parametres.coutPortable,
|
||||||
|
nb_etablissements: etablissementsRecalcules.length,
|
||||||
|
budget_global: budgetGlobal,
|
||||||
|
total_fixes: totalFixes,
|
||||||
|
total_portables: totalPortables,
|
||||||
total_fixes_renouveler: totalFixesR,
|
total_fixes_renouveler: totalFixesR,
|
||||||
total_portables_renouveler: totalPortablesR,
|
total_portables_renouveler: totalPortablesR,
|
||||||
budget_global: budgetGlobal,
|
|
||||||
};
|
};
|
||||||
}, [etablissementsRecalcules, parametres]);
|
}, [etablissementsRecalcules, rawData, parametres]);
|
||||||
|
|
||||||
const filteredAndSorted = useMemo(() => {
|
const filteredAndSorted = useMemo(() => {
|
||||||
let list: Etablissement[] = [...etablissementsRecalcules];
|
let list: Etablissement[] = [...etablissementsRecalcules];
|
||||||
|
|
||||||
// Filtre par recherche
|
|
||||||
if (search.trim()) {
|
if (search.trim()) {
|
||||||
const q = search.toLowerCase().trim();
|
const q = search.toLowerCase().trim();
|
||||||
list = list.filter(
|
list = list.filter(
|
||||||
@@ -108,14 +139,12 @@ export function useBudgetData() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filtre par type
|
|
||||||
if (filterType === 'has_budget') {
|
if (filterType === 'has_budget') {
|
||||||
list = list.filter((e) => e.stats.budget_total > 0);
|
list = list.filter((e) => e.stats.budget_total > 0);
|
||||||
} else if (filterType === 'no_budget') {
|
} else if (filterType === 'no_budget') {
|
||||||
list = list.filter((e) => e.stats.budget_total === 0);
|
list = list.filter((e) => e.stats.budget_total === 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tri
|
|
||||||
list.sort((a, b) => {
|
list.sort((a, b) => {
|
||||||
let valA: number | string;
|
let valA: number | string;
|
||||||
let valB: number | string;
|
let valB: number | string;
|
||||||
@@ -172,7 +201,8 @@ export function useBudgetData() {
|
|||||||
return {
|
return {
|
||||||
meta: metaRecalculee,
|
meta: metaRecalculee,
|
||||||
etablissements: filteredAndSorted,
|
etablissements: filteredAndSorted,
|
||||||
totalEtablissements: budgetData.etablissements.length,
|
totalEtablissements: etablissementsRecalcules.length,
|
||||||
|
hasInventaire: rawData !== null,
|
||||||
search,
|
search,
|
||||||
setSearch,
|
setSearch,
|
||||||
sortField,
|
sortField,
|
||||||
|
|||||||
@@ -28,14 +28,19 @@ import { AppSidebar } from '../components/AppSidebar';
|
|||||||
import { AnneeSelectorBar } from '../components/AnneeSelectorBar';
|
import { AnneeSelectorBar } from '../components/AnneeSelectorBar';
|
||||||
import { useAnnee, getCapexStorageKey } from '../contexts/AnneeContext';
|
import { useAnnee, getCapexStorageKey } from '../contexts/AnneeContext';
|
||||||
import bp2027Raw from '../data_bp2027.json';
|
import bp2027Raw from '../data_bp2027.json';
|
||||||
import budgetRaw from '../data_budget.json';
|
|
||||||
import { useParametres } from '../contexts/ParametresContext';
|
import { useParametres } from '../contexts/ParametresContext';
|
||||||
import { formatEuros } from '../lib/format';
|
import { formatEuros } from '../lib/format';
|
||||||
import type { BP2027Data, EtablissementBP, BudgetFormValues } from '../types/bp2027';
|
import type { BP2027Data, EtablissementBP, BudgetFormValues } from '../types/bp2027';
|
||||||
import type { BudgetData } from '../types/budget';
|
import type { BudgetData } from '../types/budget';
|
||||||
|
|
||||||
const bp2027Data = bp2027Raw as BP2027Data;
|
const bp2027Data = bp2027Raw as BP2027Data;
|
||||||
const budgetData = budgetRaw as BudgetData;
|
|
||||||
|
function loadInventaireData(): BudgetData | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem('budgetsi_inventaire_data');
|
||||||
|
return raw ? JSON.parse(raw) as BudgetData : null;
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
// La clé de stockage est désormais dynamique par année (voir getCapexStorageKey)
|
// La clé de stockage est désormais dynamique par année (voir getCapexStorageKey)
|
||||||
|
|
||||||
@@ -115,9 +120,14 @@ function getDefaultValues(etabCode: string, renouvellement2027: number): BudgetF
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calcule le budget de renouvellement 2027 depuis les données de vétusté
|
// Calcule le budget de renouvellement depuis l'inventaire importé (localStorage)
|
||||||
function getBudgetRenouvellement2027(code: string, parametres: { coutFixe: number; coutPortable: number; seuilFixesAns: number; seuilPortablesAns: number }): number {
|
function getBudgetRenouvellement2027(
|
||||||
const etab = budgetData.etablissements.find(e => e.code === code);
|
code: string,
|
||||||
|
parametres: { coutFixe: number; coutPortable: number; seuilFixesAns: number; seuilPortablesAns: number },
|
||||||
|
inventaire: BudgetData | null
|
||||||
|
): number {
|
||||||
|
if (!inventaire) return 0;
|
||||||
|
const etab = inventaire.etablissements.find(e => e.code === code);
|
||||||
if (!etab) return 0;
|
if (!etab) return 0;
|
||||||
const nbFixesR = (etab.fixes ?? []).filter(f => f.age_ans !== null && f.age_ans >= parametres.seuilFixesAns).length;
|
const nbFixesR = (etab.fixes ?? []).filter(f => f.age_ans !== null && f.age_ans >= parametres.seuilFixesAns).length;
|
||||||
const nbPortablesR = (etab.portables ?? []).filter(p => p.age_ans !== null && p.age_ans >= parametres.seuilPortablesAns).length;
|
const nbPortablesR = (etab.portables ?? []).filter(p => p.age_ans !== null && p.age_ans >= parametres.seuilPortablesAns).length;
|
||||||
@@ -175,6 +185,16 @@ export default function Budget2027() {
|
|||||||
const [saved, setSaved] = useState(false);
|
const [saved, setSaved] = useState(false);
|
||||||
const [searchEtab, setSearchEtab] = useState('');
|
const [searchEtab, setSearchEtab] = useState('');
|
||||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||||
|
const [inventaire, setInventaire] = useState<BudgetData | null>(() => loadInventaireData());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onImport = () => setInventaire(loadInventaireData());
|
||||||
|
window.addEventListener('budgetsi_inventaire_updated', onImport);
|
||||||
|
window.addEventListener('storage', (e: StorageEvent) => {
|
||||||
|
if (e.key === 'budgetsi_inventaire_data') onImport();
|
||||||
|
});
|
||||||
|
return () => window.removeEventListener('budgetsi_inventaire_updated', onImport);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Établissement sélectionné (depuis BP2027)
|
// Établissement sélectionné (depuis BP2027)
|
||||||
const selectedBP = useMemo(
|
const selectedBP = useMemo(
|
||||||
@@ -182,10 +202,10 @@ export default function Budget2027() {
|
|||||||
[selectedCode]
|
[selectedCode]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Budget renouvellement 2027 calculé par vétusté
|
// Budget renouvellement calculé depuis l'inventaire importé
|
||||||
const renouvellement2027Calcule = useMemo(
|
const renouvellement2027Calcule = useMemo(
|
||||||
() => selectedCode ? getBudgetRenouvellement2027(selectedCode, parametres) : 0,
|
() => selectedCode ? getBudgetRenouvellement2027(selectedCode, parametres, inventaire) : 0,
|
||||||
[selectedCode, parametres]
|
[selectedCode, parametres, inventaire]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Valeurs du formulaire pour l'établissement sélectionné
|
// Valeurs du formulaire pour l'établissement sélectionné
|
||||||
|
|||||||
@@ -21,14 +21,19 @@ import { AppSidebar } from '../components/AppSidebar';
|
|||||||
import { AnneeSelectorBar } from '../components/AnneeSelectorBar';
|
import { AnneeSelectorBar } from '../components/AnneeSelectorBar';
|
||||||
import { useAnnee, getCapexStorageKey } from '../contexts/AnneeContext';
|
import { useAnnee, getCapexStorageKey } from '../contexts/AnneeContext';
|
||||||
import bp2027Raw from '../data_bp2027.json';
|
import bp2027Raw from '../data_bp2027.json';
|
||||||
import budgetRaw from '../data_budget.json';
|
|
||||||
import { useParametres } from '../contexts/ParametresContext';
|
import { useParametres } from '../contexts/ParametresContext';
|
||||||
import { formatEuros } from '../lib/format';
|
import { formatEuros } from '../lib/format';
|
||||||
import type { BP2027Data, BudgetFormValues } from '../types/bp2027';
|
import type { BP2027Data, BudgetFormValues } from '../types/bp2027';
|
||||||
import type { BudgetData } from '../types/budget';
|
import type { BudgetData } from '../types/budget';
|
||||||
|
|
||||||
const bp2027Data = bp2027Raw as BP2027Data;
|
const bp2027Data = bp2027Raw as BP2027Data;
|
||||||
const budgetData = budgetRaw as BudgetData;
|
|
||||||
|
function loadInventaireData(): BudgetData | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem('budgetsi_inventaire_data');
|
||||||
|
return raw ? JSON.parse(raw) as BudgetData : null;
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
// Clé de stockage dynamique par année (voir getCapexStorageKey)
|
// Clé de stockage dynamique par année (voir getCapexStorageKey)
|
||||||
|
|
||||||
// Colonnes du tableau (correspondance exacte avec le fichier Excel BP2027)
|
// Colonnes du tableau (correspondance exacte avec le fichier Excel BP2027)
|
||||||
@@ -67,9 +72,11 @@ function loadSaisiesForAnnee(annee: number): Record<string, Partial<BudgetFormVa
|
|||||||
|
|
||||||
function getBudgetRenouvellement2027(
|
function getBudgetRenouvellement2027(
|
||||||
code: string,
|
code: string,
|
||||||
parametres: { coutFixe: number; coutPortable: number; seuilFixesAns: number; seuilPortablesAns: number }
|
parametres: { coutFixe: number; coutPortable: number; seuilFixesAns: number; seuilPortablesAns: number },
|
||||||
|
inventaire: BudgetData | null
|
||||||
): number {
|
): number {
|
||||||
const etab = budgetData.etablissements.find(e => e.code === code);
|
if (!inventaire) return 0;
|
||||||
|
const etab = inventaire.etablissements.find(e => e.code === code);
|
||||||
if (!etab) return 0;
|
if (!etab) return 0;
|
||||||
const nbFixesR = (etab.fixes ?? []).filter(f => f.age_ans !== null && f.age_ans >= parametres.seuilFixesAns).length;
|
const nbFixesR = (etab.fixes ?? []).filter(f => f.age_ans !== null && f.age_ans >= parametres.seuilFixesAns).length;
|
||||||
const nbPortablesR = (etab.portables ?? []).filter(p => p.age_ans !== null && p.age_ans >= parametres.seuilPortablesAns).length;
|
const nbPortablesR = (etab.portables ?? []).filter(p => p.age_ans !== null && p.age_ans >= parametres.seuilPortablesAns).length;
|
||||||
@@ -92,11 +99,21 @@ export default function DsiCapex() {
|
|||||||
const [sortDir, setSortDir] = useState<SortDir>('asc');
|
const [sortDir, setSortDir] = useState<SortDir>('asc');
|
||||||
const [showOnlyFilled, setShowOnlyFilled] = useState(false);
|
const [showOnlyFilled, setShowOnlyFilled] = useState(false);
|
||||||
const [lastRefresh, setLastRefresh] = useState(Date.now());
|
const [lastRefresh, setLastRefresh] = useState(Date.now());
|
||||||
|
const [inventaire, setInventaire] = useState<BudgetData | null>(() => loadInventaireData());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSaisies(loadSaisiesForAnnee(annee));
|
setSaisies(loadSaisiesForAnnee(annee));
|
||||||
}, [lastRefresh, annee]);
|
}, [lastRefresh, annee]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onImport = () => setInventaire(loadInventaireData());
|
||||||
|
window.addEventListener('budgetsi_inventaire_updated', onImport);
|
||||||
|
window.addEventListener('storage', (e: StorageEvent) => {
|
||||||
|
if (e.key === 'budgetsi_inventaire_data') onImport();
|
||||||
|
});
|
||||||
|
return () => window.removeEventListener('budgetsi_inventaire_updated', onImport);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleRefresh = () => setLastRefresh(Date.now());
|
const handleRefresh = () => setLastRefresh(Date.now());
|
||||||
|
|
||||||
// Construire les lignes du tableau
|
// Construire les lignes du tableau
|
||||||
@@ -105,7 +122,7 @@ export default function DsiCapex() {
|
|||||||
const saisie = saisies[etab.code] || {};
|
const saisie = saisies[etab.code] || {};
|
||||||
const renouv = saisie.renouvellement_2027 !== undefined
|
const renouv = saisie.renouvellement_2027 !== undefined
|
||||||
? saisie.renouvellement_2027
|
? saisie.renouvellement_2027
|
||||||
: getBudgetRenouvellement2027(etab.code, parametres);
|
: getBudgetRenouvellement2027(etab.code, parametres, inventaire);
|
||||||
|
|
||||||
const valeurs: Record<ColKey, number> = {
|
const valeurs: Record<ColKey, number> = {
|
||||||
renouvellement_2027: renouv,
|
renouvellement_2027: renouv,
|
||||||
@@ -172,11 +189,8 @@ export default function DsiCapex() {
|
|||||||
const totalGeneral = useMemo(() => sortedLignes.reduce((s, l) => s + l.total, 0), [sortedLignes]);
|
const totalGeneral = useMemo(() => sortedLignes.reduce((s, l) => s + l.total, 0), [sortedLignes]);
|
||||||
const nbFilled = useMemo(() => lignes.filter(l => l.hasSaisie).length, [lignes]);
|
const nbFilled = useMemo(() => lignes.filter(l => l.hasSaisie).length, [lignes]);
|
||||||
|
|
||||||
// La synthèse affiche les données si :
|
// La synthèse affiche les données si un inventaire a été importé ou si des saisies CAPEX existent
|
||||||
// - au moins un établissement a été saisi manuellement (saisie CAPEX), OU
|
const hasDonnees = inventaire !== null || nbFilled > 0;
|
||||||
// - l'année a des données de renouvellement calculées par vétusté (2026 et 2027)
|
|
||||||
const ANNEES_AVEC_INVENTAIRE = [2026, 2027];
|
|
||||||
const hasDonnees = nbFilled > 0 || ANNEES_AVEC_INVENTAIRE.includes(annee);
|
|
||||||
|
|
||||||
const handleSort = (col: string) => {
|
const handleSort = (col: string) => {
|
||||||
if (sortCol === col) setSortDir(d => d === 'asc' ? 'desc' : 'asc');
|
if (sortCol === col) setSortDir(d => d === 'asc' ? 'desc' : 'asc');
|
||||||
@@ -306,11 +320,10 @@ export default function DsiCapex() {
|
|||||||
<FileSpreadsheet className="w-8 h-8 text-muted-foreground/40" />
|
<FileSpreadsheet className="w-8 h-8 text-muted-foreground/40" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-lg font-semibold text-foreground" style={{ fontFamily: 'Sora, sans-serif' }}>
|
<p className="text-lg font-semibold text-foreground" style={{ fontFamily: 'Sora, sans-serif' }}>
|
||||||
Aucune donnée pour {annee}
|
Aucun inventaire importé
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-muted-foreground mt-2 max-w-sm">
|
<p className="text-sm text-muted-foreground mt-2 max-w-sm">
|
||||||
Aucun budget CAPEX n'a encore été saisi pour l'exercice {annee}.
|
Importez l'export ISI-APP via le bouton <strong>Imports & Données</strong> pour pré-remplir les budgets de renouvellement, puis saisissez les autres postes CAPEX via la page <strong>CAPEX - Construction</strong>.
|
||||||
Utilisez la page <strong>CAPEX - Construction</strong> pour saisir les budgets des établissements.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export default function Home() {
|
|||||||
meta,
|
meta,
|
||||||
etablissements,
|
etablissements,
|
||||||
totalEtablissements,
|
totalEtablissements,
|
||||||
|
hasInventaire,
|
||||||
search,
|
search,
|
||||||
setSearch,
|
setSearch,
|
||||||
sortField,
|
sortField,
|
||||||
@@ -45,9 +46,8 @@ export default function Home() {
|
|||||||
} = useBudgetData();
|
} = useBudgetData();
|
||||||
|
|
||||||
const { annee } = useAnnee();
|
const { annee } = useAnnee();
|
||||||
// Les données de parc ne sont disponibles que pour 2026 et 2027
|
// Les données de parc proviennent uniquement d'un import inventaire
|
||||||
const ANNEES_AVEC_DONNEES = [2026, 2027];
|
const hasDonnees = hasInventaire;
|
||||||
const hasDonnees = ANNEES_AVEC_DONNEES.includes(annee);
|
|
||||||
const [selectedEtab, setSelectedEtab] = useState<Etablissement | null>(null);
|
const [selectedEtab, setSelectedEtab] = useState<Etablissement | null>(null);
|
||||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('grid');
|
const [viewMode, setViewMode] = useState<ViewMode>('grid');
|
||||||
@@ -245,11 +245,10 @@ export default function Home() {
|
|||||||
<Monitor className="w-8 h-8 text-muted-foreground/40" />
|
<Monitor className="w-8 h-8 text-muted-foreground/40" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-lg font-semibold text-foreground" style={{ fontFamily: 'Sora, sans-serif' }}>
|
<p className="text-lg font-semibold text-foreground" style={{ fontFamily: 'Sora, sans-serif' }}>
|
||||||
Aucune donnée pour {annee}
|
Aucun inventaire importé
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-muted-foreground mt-2 max-w-sm">
|
<p className="text-sm text-muted-foreground mt-2 max-w-sm">
|
||||||
Les données d'inventaire parc PC pour l'exercice {annee} n'ont pas encore été importées.
|
Importez l'export ISI-APP via le bouton <strong>Imports & Données</strong> dans le menu de gauche pour afficher les données de vétusté et les budgets de renouvellement.
|
||||||
Les années disponibles sont 2026 et 2027.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : etablissements.length === 0 ? (
|
) : etablissements.length === 0 ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user