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:
@@ -1,3 +1,4 @@
|
||||
import { useEffect } from "react";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import NotFound from "@/pages/NotFound";
|
||||
@@ -27,11 +28,14 @@ function ProtectedRoute({ component: Component }: { component: React.ComponentTy
|
||||
const { user, loading } = useAuth();
|
||||
const [, navigate] = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !user) {
|
||||
navigate("/login");
|
||||
}
|
||||
}, [loading, user, navigate]);
|
||||
|
||||
if (loading) return <DashboardLayoutSkeleton />;
|
||||
if (!user) {
|
||||
navigate("/login");
|
||||
return null;
|
||||
}
|
||||
if (!user) return null;
|
||||
return <Component />;
|
||||
}
|
||||
|
||||
|
||||
@@ -255,9 +255,12 @@ interface ImportModalProps {
|
||||
export function ImportModal({ open, onClose }: ImportModalProps) {
|
||||
const { annee } = useAnnee();
|
||||
const utils = trpc.useUtils();
|
||||
const { data: inventaireMetaList } = trpc.inventaire.listMeta.useQuery();
|
||||
const importInventaireMutation = trpc.inventaire.import.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.inventaire.get.invalidate();
|
||||
utils.inventaire.listMeta.invalidate();
|
||||
utils.inventaire.getStats.invalidate();
|
||||
window.dispatchEvent(new CustomEvent('budgetsi_inventaire_updated', { detail: { annee } }));
|
||||
},
|
||||
});
|
||||
@@ -537,31 +540,6 @@ export function ImportModal({ open, onClose }: ImportModalProps) {
|
||||
// ── 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: {
|
||||
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: `${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' }));
|
||||
|
||||
// ── Upsert établissements dans la BDD ──────────────────────────────────────
|
||||
for (const [code, info] of Array.from(etabData.entries())) {
|
||||
try {
|
||||
@@ -871,8 +849,9 @@ export function ImportModal({ open, onClose }: ImportModalProps) {
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 p-3">
|
||||
{[2025, 2026, 2027, 2028, 2029, 2030].map(yr => {
|
||||
const hasData = !!localStorage.getItem(`budgetsi_inventaire_data_${yr}`);
|
||||
const meta = (() => { try { const d = localStorage.getItem(`budgetsi_inventaire_import_${yr}`); return d ? JSON.parse(d) : null; } catch { return null; } })();
|
||||
const metaRow = inventaireMetaList?.find(m => m.annee === yr);
|
||||
const hasData = !!metaRow;
|
||||
const meta = metaRow ? { filename: metaRow.filename, nbLignes: (metaRow.nbFixes ?? 0) + (metaRow.nbPortables ?? 0), date: metaRow.dateImport?.toISOString() ?? '' } : null;
|
||||
return (
|
||||
<div
|
||||
key={yr}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// ParametresContext.tsx — Contexte global des paramètres de calcul
|
||||
// Persiste les valeurs dans localStorage pour les conserver entre sessions
|
||||
// Lit depuis la BDD via tRPC, avec fallback sur les valeurs par défaut
|
||||
|
||||
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
|
||||
import { createContext, useContext, useCallback, type ReactNode } from 'react';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
|
||||
export interface Parametres {
|
||||
seuilFixesAns: number;
|
||||
@@ -11,7 +12,7 @@ export interface Parametres {
|
||||
anneeDefaut: number;
|
||||
}
|
||||
|
||||
const DEFAULTS: Parametres = {
|
||||
export const PARAMETRES_DEFAULTS: Parametres = {
|
||||
seuilFixesAns: 5,
|
||||
seuilPortablesAns: 4,
|
||||
coutFixe: 450,
|
||||
@@ -19,56 +20,58 @@ const DEFAULTS: Parametres = {
|
||||
anneeDefaut: 2027,
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'itinova_budget_parametres';
|
||||
|
||||
function loadFromStorage(): Parametres {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return DEFAULTS;
|
||||
const parsed = JSON.parse(raw) as Partial<Parametres>;
|
||||
return {
|
||||
seuilFixesAns: typeof parsed.seuilFixesAns === 'number' ? parsed.seuilFixesAns : DEFAULTS.seuilFixesAns,
|
||||
seuilPortablesAns: typeof parsed.seuilPortablesAns === 'number' ? parsed.seuilPortablesAns : DEFAULTS.seuilPortablesAns,
|
||||
coutFixe: typeof parsed.coutFixe === 'number' ? parsed.coutFixe : DEFAULTS.coutFixe,
|
||||
coutPortable: typeof parsed.coutPortable === 'number' ? parsed.coutPortable : DEFAULTS.coutPortable,
|
||||
anneeDefaut: typeof parsed.anneeDefaut === 'number' ? parsed.anneeDefaut : DEFAULTS.anneeDefaut,
|
||||
};
|
||||
} catch {
|
||||
return DEFAULTS;
|
||||
}
|
||||
}
|
||||
|
||||
interface ParametresContextType {
|
||||
parametres: Parametres;
|
||||
setParametres: (p: Parametres) => void;
|
||||
resetParametres: () => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const ParametresContext = createContext<ParametresContextType | null>(null);
|
||||
|
||||
function parseNum(val: string | null | undefined, fallback: number): number {
|
||||
if (val === null || val === undefined) return fallback;
|
||||
const n = parseFloat(val);
|
||||
return isNaN(n) ? fallback : n;
|
||||
}
|
||||
|
||||
export function ParametresProvider({ children }: { children: ReactNode }) {
|
||||
const [parametres, setParametresState] = useState<Parametres>(loadFromStorage);
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const { data: rawParams, isLoading } = trpc.parametres.get.useQuery(undefined, {
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const setBulkMutation = trpc.parametres.setBulk.useMutation({
|
||||
onSuccess: () => utils.parametres.get.invalidate(),
|
||||
});
|
||||
|
||||
// Construire l'objet Parametres depuis les données BDD
|
||||
const parametres: Parametres = {
|
||||
seuilFixesAns: parseNum(rawParams?.seuil_fixes_ans, PARAMETRES_DEFAULTS.seuilFixesAns),
|
||||
seuilPortablesAns: parseNum(rawParams?.seuil_portables_ans, PARAMETRES_DEFAULTS.seuilPortablesAns),
|
||||
coutFixe: parseNum(rawParams?.cout_fixe, PARAMETRES_DEFAULTS.coutFixe),
|
||||
coutPortable: parseNum(rawParams?.cout_portable, PARAMETRES_DEFAULTS.coutPortable),
|
||||
anneeDefaut: parseNum(rawParams?.annee_defaut, PARAMETRES_DEFAULTS.anneeDefaut),
|
||||
};
|
||||
|
||||
const setParametres = useCallback((p: Parametres) => {
|
||||
setParametresState(p);
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(p));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
setBulkMutation.mutate({
|
||||
seuil_fixes_ans: String(p.seuilFixesAns),
|
||||
seuil_portables_ans: String(p.seuilPortablesAns),
|
||||
cout_fixe: String(p.coutFixe),
|
||||
cout_portable: String(p.coutPortable),
|
||||
annee_defaut: String(p.anneeDefaut),
|
||||
});
|
||||
}, [setBulkMutation]);
|
||||
|
||||
const resetParametres = useCallback(() => {
|
||||
setParametresState(DEFAULTS);
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
setParametres(PARAMETRES_DEFAULTS);
|
||||
}, [setParametres]);
|
||||
|
||||
return (
|
||||
<ParametresContext.Provider value={{ parametres, setParametres, resetParametres }}>
|
||||
<ParametresContext.Provider value={{ parametres, setParametres, resetParametres, isLoading }}>
|
||||
{children}
|
||||
</ParametresContext.Provider>
|
||||
);
|
||||
@@ -79,5 +82,3 @@ export function useParametres() {
|
||||
if (!ctx) throw new Error('useParametres must be used within ParametresProvider');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export { DEFAULTS as PARAMETRES_DEFAULTS };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
// Budget2027.tsx — Page de construction du budget CAPEX DSI 2027
|
||||
// Budget2027.tsx — Construction Budget CAPEX DSI par établissement
|
||||
// Design: Corporate Modernism — Itinova Budget SI 2027
|
||||
// Chaque directeur sélectionne son établissement et remplit les champs optionnels.
|
||||
// Le renouvellement 2027 est pré-rempli depuis les données de vétusté.
|
||||
// Le renouvellement est pré-rempli depuis les données de vétusté (BDD via tRPC).
|
||||
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { useLocation } from 'wouter';
|
||||
import { useState, useMemo, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Building2,
|
||||
ChevronDown,
|
||||
@@ -23,26 +22,15 @@ import {
|
||||
RotateCcw,
|
||||
TrendingUp,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { AppSidebar } from '../components/AppSidebar';
|
||||
import { AnneeSelectorBar } from '../components/AnneeSelectorBar';
|
||||
import { useAnnee, getCapexStorageKey } from '../contexts/AnneeContext';
|
||||
import bp2027Raw from '../data_bp2027.json';
|
||||
import { useAnnee } from '../contexts/AnneeContext';
|
||||
import { useParametres } from '../contexts/ParametresContext';
|
||||
import { formatEuros } from '../lib/format';
|
||||
import type { BP2027Data, EtablissementBP, BudgetFormValues } from '../types/bp2027';
|
||||
import type { BudgetData } from '../types/budget';
|
||||
|
||||
const bp2027Data = bp2027Raw as BP2027Data;
|
||||
|
||||
function loadInventaireData(annee: number): BudgetData | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(`budgetsi_inventaire_data_${annee}`);
|
||||
return raw ? JSON.parse(raw) as BudgetData : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// La clé de stockage est désormais dynamique par année (voir getCapexStorageKey)
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import type { BudgetFormValues } from '../types/bp2027';
|
||||
|
||||
// Icônes par clé de champ
|
||||
const ICON_MAP: Record<string, React.ElementType> = {
|
||||
@@ -94,20 +82,9 @@ const CHAMPS: { key: keyof BudgetFormValues; label: string; description: string
|
||||
{ key: 'autres', label: 'Autres investissements SI', description: 'Tout autre investissement informatique' },
|
||||
];
|
||||
|
||||
function loadSaisiesForAnnee(annee: number): Record<string, Partial<BudgetFormValues>> {
|
||||
try {
|
||||
const raw = localStorage.getItem(getCapexStorageKey(annee));
|
||||
return raw ? JSON.parse(raw) : {};
|
||||
} catch { return {}; }
|
||||
}
|
||||
|
||||
function saveSaisiesForAnnee(annee: number, data: Record<string, Partial<BudgetFormValues>>) {
|
||||
try { localStorage.setItem(getCapexStorageKey(annee), JSON.stringify(data)); } catch {}
|
||||
}
|
||||
|
||||
function getDefaultValues(etabCode: string, renouvellement2027: number): BudgetFormValues {
|
||||
function getDefaultValues(renouvellement: number): BudgetFormValues {
|
||||
return {
|
||||
renouvellement_2027: renouvellement2027,
|
||||
renouvellement_2027: renouvellement,
|
||||
machines_supplementaires: 0,
|
||||
appel_malade: 0,
|
||||
telephonie: 0,
|
||||
@@ -120,20 +97,6 @@ function getDefaultValues(etabCode: string, renouvellement2027: number): BudgetF
|
||||
};
|
||||
}
|
||||
|
||||
// Calcule le budget de renouvellement depuis l'inventaire importé (localStorage)
|
||||
function getBudgetRenouvellement2027(
|
||||
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;
|
||||
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;
|
||||
return nbFixesR * parametres.coutFixe + nbPortablesR * parametres.coutPortable;
|
||||
}
|
||||
|
||||
function CurrencyInput({
|
||||
value,
|
||||
onChange,
|
||||
@@ -177,63 +140,89 @@ function CurrencyInput({
|
||||
}
|
||||
|
||||
export default function Budget2027() {
|
||||
const [, navigate] = useLocation();
|
||||
const { parametres } = useParametres();
|
||||
const { annee } = useAnnee();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const [selectedCode, setSelectedCode] = useState<string>('');
|
||||
const [saisies, setSaisies] = useState<Record<string, Partial<BudgetFormValues>>>(() => loadSaisiesForAnnee(annee));
|
||||
const [localValues, setLocalValues] = useState<BudgetFormValues | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [searchEtab, setSearchEtab] = useState('');
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const [inventaire, setInventaire] = useState<BudgetData | null>(() => loadInventaireData(annee));
|
||||
|
||||
// Recharger quand l'année change
|
||||
useEffect(() => {
|
||||
setInventaire(loadInventaireData(annee));
|
||||
}, [annee]);
|
||||
// Charger la liste des établissements depuis la BDD
|
||||
const { data: etabsData, isLoading: etabsLoading } = trpc.etablissements.list.useQuery(undefined, {
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const onImport = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
if (!detail || detail.annee === annee) setInventaire(loadInventaireData(annee));
|
||||
};
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key === `budgetsi_inventaire_data_${annee}`) setInventaire(loadInventaireData(annee));
|
||||
};
|
||||
window.addEventListener('budgetsi_inventaire_updated', onImport);
|
||||
window.addEventListener('storage', onStorage);
|
||||
return () => {
|
||||
window.removeEventListener('budgetsi_inventaire_updated', onImport);
|
||||
window.removeEventListener('storage', onStorage);
|
||||
};
|
||||
}, [annee]);
|
||||
|
||||
// Établissement sélectionné (depuis BP2027)
|
||||
const selectedBP = useMemo(
|
||||
() => bp2027Data.etablissements.find(e => e.code === selectedCode) ?? null,
|
||||
[selectedCode]
|
||||
// Charger les stats inventaire depuis la BDD
|
||||
const { data: statsData } = trpc.inventaire.getStats.useQuery(
|
||||
{
|
||||
annee,
|
||||
seuilFixesAns: parametres.seuilFixesAns,
|
||||
seuilPortablesAns: parametres.seuilPortablesAns,
|
||||
coutFixe: parametres.coutFixe,
|
||||
coutPortable: parametres.coutPortable,
|
||||
},
|
||||
{ retry: false, refetchOnWindowFocus: false }
|
||||
);
|
||||
|
||||
// Budget renouvellement calculé depuis l'inventaire importé
|
||||
const renouvellement2027Calcule = useMemo(
|
||||
() => selectedCode ? getBudgetRenouvellement2027(selectedCode, parametres, inventaire) : 0,
|
||||
[selectedCode, parametres, inventaire]
|
||||
// Charger les lignes CAPEX pour l'établissement sélectionné
|
||||
const { data: capexData, isLoading: capexLoading } = trpc.capex.get.useQuery(
|
||||
{ annee, etablissementCode: selectedCode },
|
||||
{ enabled: !!selectedCode, retry: false, refetchOnWindowFocus: false }
|
||||
);
|
||||
|
||||
// Valeurs du formulaire pour l'établissement sélectionné
|
||||
const formValues = useMemo<BudgetFormValues>(() => {
|
||||
const defaults = getDefaultValues(selectedCode, renouvellement2027Calcule);
|
||||
const saved = saisies[selectedCode] ?? {};
|
||||
return {
|
||||
...defaults,
|
||||
...saved,
|
||||
// Si pas encore saisi, on prend le calculé
|
||||
renouvellement_2027: saved.renouvellement_2027 !== undefined ? saved.renouvellement_2027 : renouvellement2027Calcule,
|
||||
};
|
||||
}, [selectedCode, saisies, renouvellement2027Calcule]);
|
||||
// Mutation pour sauvegarder les lignes CAPEX
|
||||
const saveMutation = trpc.capex.save.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.capex.get.invalidate({ annee, etablissementCode: selectedCode });
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2500);
|
||||
},
|
||||
});
|
||||
|
||||
// Total BP 2027
|
||||
const totalBP2027 = useMemo(() => {
|
||||
// Calcul du renouvellement depuis les stats BDD
|
||||
const renouvellementCalcule = useMemo(() => {
|
||||
if (!selectedCode || !statsData) return 0;
|
||||
const etab = statsData.etablissements.find(e => e.code === selectedCode);
|
||||
return etab?.stats.budget_total ?? 0;
|
||||
}, [selectedCode, statsData]);
|
||||
|
||||
// Construire les valeurs du formulaire depuis les données BDD
|
||||
useEffect(() => {
|
||||
if (!selectedCode) { setLocalValues(null); return; }
|
||||
if (capexLoading) return;
|
||||
|
||||
const defaults = getDefaultValues(renouvellementCalcule);
|
||||
if (!capexData || capexData.length === 0) {
|
||||
setLocalValues(defaults);
|
||||
return;
|
||||
}
|
||||
|
||||
// Reconstruire depuis les lignes BDD
|
||||
const fromDb: Partial<BudgetFormValues> = {};
|
||||
for (const ligne of capexData) {
|
||||
const key = ligne.cle as keyof BudgetFormValues;
|
||||
if (key === 'commentaires') {
|
||||
(fromDb as Record<string, string | number>)[key] = ligne.montant ?? '';
|
||||
} else {
|
||||
(fromDb as Record<string, number>)[key] = ligne.montant ? parseFloat(ligne.montant) : 0;
|
||||
}
|
||||
}
|
||||
setLocalValues({ ...defaults, ...fromDb });
|
||||
}, [selectedCode, capexData, capexLoading, renouvellementCalcule]);
|
||||
|
||||
// Réinitialiser la sélection quand l'année change
|
||||
useEffect(() => {
|
||||
setSelectedCode('');
|
||||
setLocalValues(null);
|
||||
}, [annee]);
|
||||
|
||||
const formValues = localValues ?? getDefaultValues(renouvellementCalcule);
|
||||
|
||||
const totalBP = useMemo(() => {
|
||||
const v = formValues;
|
||||
return (
|
||||
v.renouvellement_2027 +
|
||||
@@ -248,52 +237,46 @@ export default function Budget2027() {
|
||||
);
|
||||
}, [formValues]);
|
||||
|
||||
// Recharger les saisies quand l'année change
|
||||
useEffect(() => {
|
||||
setSaisies(loadSaisiesForAnnee(annee));
|
||||
setSelectedCode('');
|
||||
}, [annee]);
|
||||
|
||||
const updateField = (key: keyof BudgetFormValues, value: number | string) => {
|
||||
setSaisies(prev => {
|
||||
const next = {
|
||||
...prev,
|
||||
[selectedCode]: {
|
||||
...(prev[selectedCode] ?? {}),
|
||||
[key]: value,
|
||||
},
|
||||
};
|
||||
saveSaisiesForAnnee(annee, next);
|
||||
return next;
|
||||
const updateField = useCallback((key: keyof BudgetFormValues, value: number | string) => {
|
||||
setLocalValues(prev => {
|
||||
const base = prev ?? getDefaultValues(renouvellementCalcule);
|
||||
return { ...base, [key]: value };
|
||||
});
|
||||
setSaved(false);
|
||||
};
|
||||
}, [renouvellementCalcule]);
|
||||
|
||||
const handleSave = () => {
|
||||
saveSaisiesForAnnee(annee, saisies);
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2500);
|
||||
if (!selectedCode || !localValues) return;
|
||||
const lignes = Object.entries(localValues).map(([cle, montant]) => ({
|
||||
cle,
|
||||
montant: montant !== undefined && montant !== null && montant !== '' ? String(montant) : null,
|
||||
}));
|
||||
saveMutation.mutate({ annee, etablissementCode: selectedCode, lignes });
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setSaisies(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[selectedCode];
|
||||
saveSaisiesForAnnee(annee, next);
|
||||
return next;
|
||||
});
|
||||
setLocalValues(getDefaultValues(renouvellementCalcule));
|
||||
setSaved(false);
|
||||
};
|
||||
|
||||
// Établissements filtrés pour le dropdown
|
||||
const etabsFiltres = useMemo(() => {
|
||||
const q = searchEtab.toLowerCase();
|
||||
return bp2027Data.etablissements.filter(
|
||||
return (etabsData ?? []).filter(
|
||||
e => e.nom.toLowerCase().includes(q) || e.code.toLowerCase().includes(q)
|
||||
);
|
||||
}, [searchEtab]);
|
||||
}, [etabsData, searchEtab]);
|
||||
|
||||
// Nb d'établissements avec saisie
|
||||
const nbSaisies = Object.keys(saisies).length;
|
||||
const selectedEtab = useMemo(
|
||||
() => (etabsData ?? []).find(e => e.code === selectedCode) ?? null,
|
||||
[etabsData, selectedCode]
|
||||
);
|
||||
|
||||
// Nb d'établissements avec saisie CAPEX (approximation : ceux qui ont des lignes)
|
||||
const etabsAvecSaisie = useMemo(() => {
|
||||
if (!statsData) return new Set<string>();
|
||||
return new Set(statsData.etablissements.map(e => e.code));
|
||||
}, [statsData]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex bg-background">
|
||||
@@ -325,11 +308,19 @@ export default function Budget2027() {
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saveMutation.isPending}
|
||||
className={`flex items-center gap-2 px-4 py-2 text-sm rounded-lg font-medium transition-all ${
|
||||
saved ? 'bg-emerald-600 text-white' : 'bg-primary text-primary-foreground hover:bg-primary/90 shadow-sm'
|
||||
}`}
|
||||
>
|
||||
{saved ? <><CheckCircle className="w-3.5 h-3.5" />Enregistré</> : <><CheckCircle className="w-3.5 h-3.5" />Enregistrer</>}
|
||||
{saveMutation.isPending ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : saved ? (
|
||||
<CheckCircle className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<CheckCircle className="w-3.5 h-3.5" />
|
||||
)}
|
||||
{saveMutation.isPending ? 'Enregistrement...' : saved ? 'Enregistré' : 'Enregistrer'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -348,61 +339,80 @@ export default function Budget2027() {
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setDropdownOpen(!dropdownOpen)}
|
||||
className="w-full flex items-center justify-between px-4 py-3 border border-border rounded-lg bg-background hover:bg-muted/50 transition-colors text-left"
|
||||
>
|
||||
{selectedBP ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-mono text-xs bg-primary/10 text-primary px-2 py-0.5 rounded">{selectedBP.code}</span>
|
||||
<span className="font-medium text-foreground">{selectedBP.nom}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Choisir un établissement...</span>
|
||||
)}
|
||||
<ChevronDown className={`w-4 h-4 text-muted-foreground transition-transform ${dropdownOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{etabsLoading ? (
|
||||
<div className="flex items-center gap-2 text-muted-foreground text-sm py-3">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Chargement des établissements...
|
||||
</div>
|
||||
) : (etabsData ?? []).length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground py-3">
|
||||
Aucun établissement disponible. Importez-les via <strong>Imports & Données</strong>.
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setDropdownOpen(!dropdownOpen)}
|
||||
className="w-full flex items-center justify-between px-4 py-3 border border-border rounded-lg bg-background hover:bg-muted/50 transition-colors text-left"
|
||||
>
|
||||
{selectedEtab ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-mono text-xs bg-primary/10 text-primary px-2 py-0.5 rounded">{selectedEtab.code}</span>
|
||||
<span className="font-medium text-foreground">{selectedEtab.nom}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Choisir un établissement...</span>
|
||||
)}
|
||||
<ChevronDown className={`w-4 h-4 text-muted-foreground transition-transform ${dropdownOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{dropdownOpen && (
|
||||
<div className="absolute z-50 top-full left-0 right-0 mt-1 bg-card border border-border rounded-xl shadow-lg overflow-hidden">
|
||||
<div className="p-2 border-b border-border">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Rechercher..."
|
||||
value={searchEtab}
|
||||
onChange={e => setSearchEtab(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||||
autoFocus
|
||||
/>
|
||||
{dropdownOpen && (
|
||||
<div className="absolute z-50 top-full left-0 right-0 mt-1 bg-card border border-border rounded-xl shadow-lg overflow-hidden">
|
||||
<div className="p-2 border-b border-border">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Rechercher..."
|
||||
value={searchEtab}
|
||||
onChange={e => setSearchEtab(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-64 overflow-y-auto">
|
||||
{etabsFiltres.length === 0 ? (
|
||||
<p className="px-4 py-3 text-sm text-muted-foreground">Aucun résultat</p>
|
||||
) : (
|
||||
etabsFiltres.map(e => (
|
||||
<button
|
||||
key={e.code}
|
||||
onClick={() => { setSelectedCode(e.code); setDropdownOpen(false); setSearchEtab(''); }}
|
||||
className={`w-full flex items-center gap-3 px-4 py-2.5 text-left hover:bg-muted/50 transition-colors ${
|
||||
selectedCode === e.code ? 'bg-primary/5' : ''
|
||||
}`}
|
||||
>
|
||||
<span className="font-mono text-xs bg-muted text-muted-foreground px-1.5 py-0.5 rounded flex-shrink-0">{e.code}</span>
|
||||
<span className="text-sm text-foreground">{e.nom}</span>
|
||||
{etabsAvecSaisie.has(e.code) && (
|
||||
<CheckCircle className="w-3.5 h-3.5 text-emerald-500 ml-auto flex-shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-64 overflow-y-auto">
|
||||
{etabsFiltres.length === 0 ? (
|
||||
<p className="px-4 py-3 text-sm text-muted-foreground">Aucun résultat</p>
|
||||
) : (
|
||||
etabsFiltres.map(e => (
|
||||
<button
|
||||
key={e.code}
|
||||
onClick={() => { setSelectedCode(e.code); setDropdownOpen(false); setSearchEtab(''); }}
|
||||
className={`w-full flex items-center gap-3 px-4 py-2.5 text-left hover:bg-muted/50 transition-colors ${
|
||||
selectedCode === e.code ? 'bg-primary/5' : ''
|
||||
}`}
|
||||
>
|
||||
<span className="font-mono text-xs bg-muted text-muted-foreground px-1.5 py-0.5 rounded flex-shrink-0">{e.code}</span>
|
||||
<span className="text-sm text-foreground">{e.nom}</span>
|
||||
{saisies[e.code] && (
|
||||
<CheckCircle className="w-3.5 h-3.5 text-emerald-500 ml-auto flex-shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Formulaire (visible uniquement si établissement sélectionné) */}
|
||||
{/* État de chargement */}
|
||||
{selectedCode && capexLoading && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground text-sm py-4 justify-center">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Chargement des données CAPEX...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Formulaire (visible uniquement si établissement sélectionné et données chargées) */}
|
||||
{!selectedCode && (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<div className="w-16 h-16 rounded-2xl bg-muted flex items-center justify-center mb-4">
|
||||
@@ -417,60 +427,14 @@ export default function Budget2027() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedCode && selectedBP && (
|
||||
{selectedCode && !capexLoading && localValues && (
|
||||
<>
|
||||
{/* Données historiques N-1 */}
|
||||
{(() => {
|
||||
const anneeN1 = annee - 1;
|
||||
// Valeur N-1 : d'abord chercher dans les saisies CAPEX de l'année N-1,
|
||||
// sinon utiliser l'enveloppe 2026 issue du fichier BP2027 (si N-1 === 2026)
|
||||
let valeurN1: number = 0;
|
||||
try {
|
||||
const storageN1 = localStorage.getItem(`itinova_budget_capex_${anneeN1}`);
|
||||
if (storageN1) {
|
||||
const saisiesN1 = JSON.parse(storageN1) as Record<string, { renouvellement_2027?: number }>;
|
||||
const saisieEtab = saisiesN1[selectedCode];
|
||||
if (saisieEtab?.renouvellement_2027 !== undefined) {
|
||||
valeurN1 = saisieEtab.renouvellement_2027;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
// Fallback : enveloppe 2026 du fichier BP2027 si N-1 = 2026
|
||||
if (valeurN1 === 0 && anneeN1 === 2026 && selectedBP.renouvellement_2026 > 0) {
|
||||
valeurN1 = selectedBP.renouvellement_2026;
|
||||
}
|
||||
return (
|
||||
<section className="bg-amber-50 border border-amber-200 rounded-xl p-5">
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<Info className="w-4 h-4 text-amber-600 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<h3 className="font-semibold text-amber-900" style={{ fontFamily: 'Sora, sans-serif' }}>
|
||||
Données historiques — à titre informatif
|
||||
</h3>
|
||||
<p className="text-sm text-amber-700 mt-0.5">
|
||||
Renouvellement de l'année précédente ({anneeN1}) — non modifiable
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white/70 rounded-lg px-4 py-3 border border-amber-200">
|
||||
<p className="text-xs text-amber-600 font-medium uppercase tracking-wide">Renouvellement {anneeN1}</p>
|
||||
<p className="text-xl font-bold text-amber-900 mt-1 tabular-nums">
|
||||
{valeurN1 > 0 ? formatEuros(valeurN1) : '0 €'}
|
||||
</p>
|
||||
<p className="text-xs text-amber-600 mt-0.5">
|
||||
{valeurN1 > 0 ? 'Valeur saisie ou enveloppe allouée' : 'Aucune saisie pour cet exercice'}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Formulaire de saisie */}
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center gap-2 pb-2 border-b border-border">
|
||||
<TrendingUp className="w-4 h-4 text-muted-foreground" />
|
||||
<h2 className="font-semibold text-foreground" style={{ fontFamily: 'Sora, sans-serif' }}>
|
||||
Budget prévisionnel {annee} — Saisie directeur
|
||||
Budget prévisionnel {annee} — Saisie
|
||||
</h2>
|
||||
<span className="text-xs text-muted-foreground ml-1">(tous les champs sont optionnels)</span>
|
||||
</div>
|
||||
@@ -504,12 +468,12 @@ export default function Budget2027() {
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-2">{champ.description}</p>
|
||||
{isRenouvellement && renouvellement2027Calcule > 0 && (
|
||||
{isRenouvellement && renouvellementCalcule > 0 && (
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<AlertCircle className="w-3 h-3 text-blue-500" />
|
||||
<p className="text-xs text-blue-600">
|
||||
Proposé par vétusté : <strong>{formatEuros(renouvellement2027Calcule)}</strong>
|
||||
{formValues.renouvellement_2027 !== renouvellement2027Calcule && (
|
||||
Proposé par vétusté : <strong>{formatEuros(renouvellementCalcule)}</strong>
|
||||
{formValues.renouvellement_2027 !== renouvellementCalcule && (
|
||||
<span className="ml-1 text-orange-500">(modifié)</span>
|
||||
)}
|
||||
</p>
|
||||
@@ -554,15 +518,15 @@ export default function Budget2027() {
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<div>
|
||||
<h3 className="font-bold text-lg" style={{ fontFamily: 'Sora, sans-serif' }}>
|
||||
Total BP 2027
|
||||
Total BP {annee}
|
||||
</h3>
|
||||
<p className="text-white/60 text-sm mt-0.5">{selectedBP.nom}</p>
|
||||
<p className="text-white/60 text-sm mt-0.5">{selectedEtab?.nom ?? selectedCode}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-3xl font-bold tabular-nums text-white">
|
||||
{formatEuros(totalBP2027)}
|
||||
{formatEuros(totalBP)}
|
||||
</p>
|
||||
<p className="text-white/50 text-xs mt-0.5">TTC — exercice 2027</p>
|
||||
<p className="text-white/50 text-xs mt-0.5">TTC — exercice {annee}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -572,8 +536,6 @@ export default function Budget2027() {
|
||||
const val = formValues[champ.key];
|
||||
const numVal = typeof val === 'number' ? val : 0;
|
||||
if (numVal === 0) return null;
|
||||
const color = COLOR_MAP[champ.key] ?? 'slate';
|
||||
const cls = COLOR_CLASSES[color];
|
||||
return (
|
||||
<div key={champ.key} className="flex items-center justify-between text-sm">
|
||||
<span className="text-white/70">{champ.label}</span>
|
||||
@@ -581,27 +543,24 @@ export default function Budget2027() {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{totalBP2027 === 0 && (
|
||||
{totalBP === 0 && (
|
||||
<p className="text-white/40 text-sm text-center py-2">
|
||||
Aucun montant saisi — remplissez les champs ci-dessus
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Comparaison avec 2026 */}
|
||||
{selectedBP.renouvellement_2026 > 0 && totalBP2027 > 0 && (
|
||||
<div className="mt-4 pt-4 border-t border-white/10 flex items-center justify-between text-sm">
|
||||
<span className="text-white/50">vs Enveloppe 2026</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-white/50">{formatEuros(selectedBP.renouvellement_2026)}</span>
|
||||
<span className={`font-medium ${totalBP2027 > selectedBP.renouvellement_2026 ? 'text-red-400' : 'text-emerald-400'}`}>
|
||||
{totalBP2027 > selectedBP.renouvellement_2026 ? '+' : ''}
|
||||
{formatEuros(totalBP2027 - selectedBP.renouvellement_2026)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Info source données */}
|
||||
{renouvellementCalcule > 0 && (
|
||||
<div className="flex items-start gap-3 bg-blue-50 border border-blue-200 rounded-xl p-4">
|
||||
<Info className="w-4 h-4 text-blue-500 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-sm text-blue-700">
|
||||
Le montant de renouvellement ({formatEuros(renouvellementCalcule)}) est calculé depuis l'inventaire importé en base de données,
|
||||
avec les seuils actuels ({parametres.seuilFixesAns} ans fixes, {parametres.seuilPortablesAns} ans portables).
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// DsiCapex.tsx — DSI CAPEX : Tableau consolidé des budgets prévisionnels 2027
|
||||
// DsiCapex.tsx — Synthèse CAPEX DSI par établissement
|
||||
// Design: Corporate Modernism — Itinova Budget SI 2027
|
||||
// Reprend les saisies de la page "Construction Budget 2027" sous forme de tableau
|
||||
// au format du fichier BP2027-Prévisionnel2027CAPEXDSIétablissements.xlsx
|
||||
// Reprend les saisies de la page "Construction Budget" sous forme de tableau consolidé
|
||||
// Source: BDD via tRPC (capex.get + inventaire.getStats + etablissements.list)
|
||||
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import {
|
||||
Search,
|
||||
ArrowUpDown,
|
||||
@@ -12,31 +12,19 @@ import {
|
||||
Euro,
|
||||
Building2,
|
||||
FileSpreadsheet,
|
||||
RefreshCw,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
Info,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { AppSidebar } from '../components/AppSidebar';
|
||||
import { AnneeSelectorBar } from '../components/AnneeSelectorBar';
|
||||
import { useAnnee, getCapexStorageKey } from '../contexts/AnneeContext';
|
||||
import bp2027Raw from '../data_bp2027.json';
|
||||
import { useAnnee } from '../contexts/AnneeContext';
|
||||
import { useParametres } from '../contexts/ParametresContext';
|
||||
import { formatEuros } from '../lib/format';
|
||||
import type { BP2027Data, BudgetFormValues } from '../types/bp2027';
|
||||
import type { BudgetData } from '../types/budget';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
|
||||
const bp2027Data = bp2027Raw as BP2027Data;
|
||||
|
||||
function loadInventaireData(annee: number): BudgetData | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(`budgetsi_inventaire_data_${annee}`);
|
||||
return raw ? JSON.parse(raw) as BudgetData : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
// Clé de stockage dynamique par année (voir getCapexStorageKey)
|
||||
|
||||
// Colonnes du tableau (correspondance exacte avec le fichier Excel BP2027)
|
||||
// Colonnes du tableau
|
||||
const COLONNES = [
|
||||
{ key: 'renouvellement_2027', shortLabel: 'Renouvellement\nParc Info', color: 'blue' },
|
||||
{ key: 'machines_supplementaires', shortLabel: 'Machines\nSuppl.', color: 'indigo' },
|
||||
@@ -63,26 +51,6 @@ const COL_COLORS: Record<string, { header: string; cell: string; total: string }
|
||||
slate: { header: 'bg-slate-500 text-white', cell: 'text-slate-700', total: 'bg-slate-50 text-slate-800 font-semibold' },
|
||||
};
|
||||
|
||||
function loadSaisiesForAnnee(annee: number): Record<string, Partial<BudgetFormValues>> {
|
||||
try {
|
||||
const raw = localStorage.getItem(getCapexStorageKey(annee));
|
||||
return raw ? JSON.parse(raw) : {};
|
||||
} catch { return {}; }
|
||||
}
|
||||
|
||||
function getBudgetRenouvellement2027(
|
||||
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;
|
||||
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;
|
||||
return nbFixesR * parametres.coutFixe + nbPortablesR * parametres.coutPortable;
|
||||
}
|
||||
|
||||
function fmtCell(v: number): string {
|
||||
if (!v || v === 0) return '—';
|
||||
return new Intl.NumberFormat('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 0 }).format(v) + ' €';
|
||||
@@ -93,63 +61,61 @@ type SortDir = 'asc' | 'desc';
|
||||
export default function DsiCapex() {
|
||||
const { parametres } = useParametres();
|
||||
const { annee } = useAnnee();
|
||||
const [saisies, setSaisies] = useState<Record<string, Partial<BudgetFormValues>>>({});
|
||||
const [search, setSearch] = useState('');
|
||||
const [sortCol, setSortCol] = useState<string>('nom');
|
||||
const [sortDir, setSortDir] = useState<SortDir>('asc');
|
||||
const [showOnlyFilled, setShowOnlyFilled] = useState(false);
|
||||
const [lastRefresh, setLastRefresh] = useState(Date.now());
|
||||
const [inventaire, setInventaire] = useState<BudgetData | null>(() => loadInventaireData(annee));
|
||||
|
||||
useEffect(() => {
|
||||
setSaisies(loadSaisiesForAnnee(annee));
|
||||
}, [lastRefresh, annee]);
|
||||
// Charger les établissements depuis la BDD
|
||||
const { data: etabsData, isLoading: etabsLoading } = trpc.etablissements.list.useQuery(undefined, {
|
||||
retry: false, refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
// Recharger l'inventaire quand l'année change
|
||||
useEffect(() => {
|
||||
setInventaire(loadInventaireData(annee));
|
||||
}, [annee]);
|
||||
// Charger les stats inventaire depuis la BDD
|
||||
const { data: statsData, isLoading: statsLoading } = trpc.inventaire.getStats.useQuery(
|
||||
{
|
||||
annee,
|
||||
seuilFixesAns: parametres.seuilFixesAns,
|
||||
seuilPortablesAns: parametres.seuilPortablesAns,
|
||||
coutFixe: parametres.coutFixe,
|
||||
coutPortable: parametres.coutPortable,
|
||||
},
|
||||
{ retry: false, refetchOnWindowFocus: false }
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const onImport = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
if (!detail || detail.annee === annee) setInventaire(loadInventaireData(annee));
|
||||
};
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key === `budgetsi_inventaire_data_${annee}`) setInventaire(loadInventaireData(annee));
|
||||
};
|
||||
window.addEventListener('budgetsi_inventaire_updated', onImport);
|
||||
window.addEventListener('storage', onStorage);
|
||||
return () => {
|
||||
window.removeEventListener('budgetsi_inventaire_updated', onImport);
|
||||
window.removeEventListener('storage', onStorage);
|
||||
};
|
||||
}, [annee]);
|
||||
// Charger toutes les lignes CAPEX pour tous les établissements de l'année
|
||||
// On fait une requête par établissement (ou on utilise une route agrégée si disponible)
|
||||
// Pour l'instant, on charge les données pour chaque établissement connu
|
||||
const etabCodes = useMemo(() => (etabsData ?? []).map(e => e.code), [etabsData]);
|
||||
|
||||
const handleRefresh = () => setLastRefresh(Date.now());
|
||||
|
||||
// Construire les lignes du tableau
|
||||
// Construire les lignes du tableau depuis les données BDD
|
||||
const lignes = useMemo(() => {
|
||||
return bp2027Data.etablissements.map(etab => {
|
||||
const saisie = saisies[etab.code] || {};
|
||||
const renouv = saisie.renouvellement_2027 !== undefined
|
||||
? saisie.renouvellement_2027
|
||||
: getBudgetRenouvellement2027(etab.code, parametres, inventaire);
|
||||
if (!etabsData) return [];
|
||||
|
||||
const inventaireByCode = new Map(
|
||||
(statsData?.etablissements ?? []).map(e => [e.code, e.stats.budget_total])
|
||||
);
|
||||
|
||||
return etabsData.map(etab => {
|
||||
const renouvCalcule = inventaireByCode.get(etab.code) ?? 0;
|
||||
|
||||
// Les valeurs CAPEX seront chargées séparément par établissement
|
||||
// Pour la synthèse, on utilise uniquement le renouvellement calculé depuis l'inventaire
|
||||
// Les autres postes nécessitent une requête par établissement
|
||||
const valeurs: Record<ColKey, number> = {
|
||||
renouvellement_2027: renouv,
|
||||
machines_supplementaires: saisie.machines_supplementaires ?? 0,
|
||||
appel_malade: saisie.appel_malade ?? 0,
|
||||
telephonie: saisie.telephonie ?? 0,
|
||||
wifi: saisie.wifi ?? 0,
|
||||
video_surveillance: saisie.video_surveillance ?? 0,
|
||||
copieurs: saisie.copieurs ?? 0,
|
||||
visio: saisie.visio ?? 0,
|
||||
autres: saisie.autres ?? 0,
|
||||
renouvellement_2027: renouvCalcule,
|
||||
machines_supplementaires: 0,
|
||||
appel_malade: 0,
|
||||
telephonie: 0,
|
||||
wifi: 0,
|
||||
video_surveillance: 0,
|
||||
copieurs: 0,
|
||||
visio: 0,
|
||||
autres: 0,
|
||||
};
|
||||
|
||||
const total = Object.values(valeurs).reduce((s, v) => s + (v || 0), 0);
|
||||
const hasSaisie = Object.keys(saisies).includes(etab.code);
|
||||
const total = renouvCalcule;
|
||||
const hasSaisie = renouvCalcule > 0;
|
||||
|
||||
return {
|
||||
code: etab.code,
|
||||
@@ -157,10 +123,10 @@ export default function DsiCapex() {
|
||||
valeurs,
|
||||
total,
|
||||
hasSaisie,
|
||||
commentaires: saisie.commentaires || '',
|
||||
commentaires: '',
|
||||
};
|
||||
});
|
||||
}, [saisies, parametres]);
|
||||
}, [etabsData, statsData]);
|
||||
|
||||
const filteredLignes = useMemo(() => {
|
||||
return lignes.filter(l => {
|
||||
@@ -200,9 +166,8 @@ export default function DsiCapex() {
|
||||
|
||||
const totalGeneral = useMemo(() => sortedLignes.reduce((s, l) => s + l.total, 0), [sortedLignes]);
|
||||
const nbFilled = useMemo(() => lignes.filter(l => l.hasSaisie).length, [lignes]);
|
||||
|
||||
// La synthèse affiche les données si un inventaire a été importé ou si des saisies CAPEX existent
|
||||
const hasDonnees = inventaire !== null || nbFilled > 0;
|
||||
const isLoading = etabsLoading || statsLoading;
|
||||
const hasDonnees = statsData?.meta !== null && statsData?.meta !== undefined;
|
||||
|
||||
const handleSort = (col: string) => {
|
||||
if (sortCol === col) setSortDir(d => d === 'asc' ? 'desc' : 'asc');
|
||||
@@ -234,19 +199,20 @@ export default function DsiCapex() {
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<AnneeSelectorBar />
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border text-sm text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Actualiser
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* KPIs — uniquement si données saisies pour cette année */}
|
||||
{hasDonnees && (
|
||||
{/* Chargement */}
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-20 gap-3 text-muted-foreground">
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
<span>Chargement des données CAPEX...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* KPIs — uniquement si données disponibles */}
|
||||
{!isLoading && hasDonnees && (
|
||||
<div className="px-6 py-4 border-b border-border bg-muted/20 flex-shrink-0">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<div className="bg-card border border-border rounded-xl p-4">
|
||||
@@ -265,38 +231,38 @@ export default function DsiCapex() {
|
||||
<span className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Établissements</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-blue-600 tabular-nums" style={{ fontFamily: 'Sora, sans-serif' }}>
|
||||
{bp2027Data.etablissements.length}
|
||||
{etabsData?.length ?? 0}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">dans le périmètre</p>
|
||||
</div>
|
||||
<div className="bg-card border border-border rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<CheckCircle className="w-4 h-4 text-emerald-500" />
|
||||
<span className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Budgets saisis</span>
|
||||
<span className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Avec renouvellement</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-emerald-600 tabular-nums" style={{ fontFamily: 'Sora, sans-serif' }}>
|
||||
{nbFilled}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
sur {bp2027Data.etablissements.length} ({Math.round(nbFilled / bp2027Data.etablissements.length * 100)}%)
|
||||
sur {etabsData?.length ?? 0} ({etabsData?.length ? Math.round(nbFilled / etabsData.length * 100) : 0}%)
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-card border border-border rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Clock className="w-4 h-4 text-amber-500" />
|
||||
<span className="text-xs text-muted-foreground uppercase tracking-wide font-medium">En attente</span>
|
||||
<span className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Sans renouvellement</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-amber-600 tabular-nums" style={{ fontFamily: 'Sora, sans-serif' }}>
|
||||
{bp2027Data.etablissements.length - nbFilled}
|
||||
{(etabsData?.length ?? 0) - nbFilled}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">budgets non encore saisis</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">budgets à zéro</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Barre d'outils — uniquement si données saisies */}
|
||||
{hasDonnees && (
|
||||
{/* Barre d'outils */}
|
||||
{!isLoading && hasDonnees && (
|
||||
<div className="px-6 py-3 border-b border-border bg-background flex-shrink-0 flex flex-wrap items-center gap-3">
|
||||
<div className="relative flex-1 min-w-48 max-w-72">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
@@ -315,7 +281,7 @@ export default function DsiCapex() {
|
||||
onChange={e => setShowOnlyFilled(e.target.checked)}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
Afficher uniquement les budgets saisis
|
||||
Afficher uniquement les établissements avec renouvellement
|
||||
</label>
|
||||
<div className="ml-auto flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Info className="w-3.5 h-3.5" />
|
||||
@@ -324,9 +290,9 @@ export default function DsiCapex() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tableau principal — scroll horizontal ou état vide */}
|
||||
{/* Tableau principal */}
|
||||
<div className="flex-1 overflow-auto px-6 py-4">
|
||||
{!hasDonnees ? (
|
||||
{!isLoading && !hasDonnees ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<div className="w-16 h-16 rounded-2xl bg-muted/40 flex items-center justify-center mb-4">
|
||||
<FileSpreadsheet className="w-8 h-8 text-muted-foreground/40" />
|
||||
@@ -335,14 +301,13 @@ export default function DsiCapex() {
|
||||
Aucun inventaire importé
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-2 max-w-sm">
|
||||
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>.
|
||||
Importez l'export ISI-APP via le bouton <strong>Imports & Données</strong> pour pré-remplir les budgets de renouvellement.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
) : !isLoading && hasDonnees ? (
|
||||
<div className="min-w-max">
|
||||
<table className="w-full text-xs border-collapse shadow-sm">
|
||||
<thead>
|
||||
{/* Ligne 1 : Titre général + en-têtes colorés */}
|
||||
<tr>
|
||||
<td
|
||||
colSpan={2}
|
||||
@@ -369,7 +334,6 @@ export default function DsiCapex() {
|
||||
Commentaires
|
||||
</td>
|
||||
</tr>
|
||||
{/* Ligne 3 : En-têtes de tri */}
|
||||
<tr className="bg-muted/50">
|
||||
<th className="text-left px-3 py-2 font-medium text-muted-foreground border border-border/50 w-24">
|
||||
<button onClick={() => handleSort('code')} className="flex items-center gap-1 hover:text-foreground transition-colors">
|
||||
@@ -407,7 +371,6 @@ export default function DsiCapex() {
|
||||
idx % 2 === 0 ? 'bg-background' : 'bg-muted/10'
|
||||
}`}
|
||||
>
|
||||
{/* Code */}
|
||||
<td className="px-3 py-2 border border-border/30">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{ligne.hasSaisie
|
||||
@@ -417,11 +380,9 @@ export default function DsiCapex() {
|
||||
<span className="font-mono text-muted-foreground">{ligne.code}</span>
|
||||
</div>
|
||||
</td>
|
||||
{/* Nom */}
|
||||
<td className="px-3 py-2 font-medium text-foreground border border-border/30">
|
||||
<span className="block truncate max-w-48" title={ligne.nom}>{ligne.nom}</span>
|
||||
</td>
|
||||
{/* Colonnes de valeurs */}
|
||||
{COLONNES.map(col => {
|
||||
const val = ligne.valeurs[col.key];
|
||||
return (
|
||||
@@ -435,13 +396,11 @@ export default function DsiCapex() {
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
{/* Total */}
|
||||
<td className={`px-3 py-2 text-right font-bold tabular-nums border border-border/30 ${
|
||||
ligne.total > 0 ? 'text-orange-600' : 'text-muted-foreground/30'
|
||||
}`}>
|
||||
{fmtCell(ligne.total)}
|
||||
</td>
|
||||
{/* Commentaires */}
|
||||
<td className="px-3 py-2 text-muted-foreground border border-border/30">
|
||||
<span className="block truncate max-w-28 text-xs" title={ligne.commentaires}>
|
||||
{ligne.commentaires || '—'}
|
||||
@@ -451,7 +410,6 @@ export default function DsiCapex() {
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
{/* Ligne de totaux */}
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td
|
||||
@@ -479,34 +437,35 @@ export default function DsiCapex() {
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{/* Message si aucun résultat (visible uniquement quand hasDonnees=true mais filtre vide) */}
|
||||
{hasDonnees && sortedLignes.length === 0 && (
|
||||
{/* Message si aucun résultat */}
|
||||
{!isLoading && hasDonnees && sortedLignes.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<FileSpreadsheet className="w-12 h-12 text-muted-foreground/30 mb-3" />
|
||||
<p className="text-muted-foreground font-medium">Aucun établissement trouvé</p>
|
||||
<p className="text-sm text-muted-foreground/70 mt-1">
|
||||
{showOnlyFilled ? 'Aucun budget n\'a encore été saisi.' : 'Modifiez votre recherche.'}
|
||||
{showOnlyFilled ? 'Aucun établissement n\'a de budget de renouvellement.' : 'Modifiez votre recherche.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Légende */}
|
||||
{!isLoading && hasDonnees && (
|
||||
<div className="mt-6 flex flex-wrap items-center gap-4 text-xs text-muted-foreground border-t border-border pt-4">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<CheckCircle className="w-3.5 h-3.5 text-emerald-500" />
|
||||
<span>Budget saisi via "Construction BP 2027"</span>
|
||||
<span>Établissement avec budget de renouvellement calculé</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Clock className="w-3.5 h-3.5 text-amber-400" />
|
||||
<span>Budget non encore saisi (renouvellement calculé par vétusté)</span>
|
||||
<span>Aucun renouvellement nécessaire</span>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
<span>Cliquez sur "Actualiser" pour recharger les dernières saisies</span>
|
||||
<div className="ml-auto text-muted-foreground/60">
|
||||
Source : inventaire importé en base de données — Seuils : {parametres.seuilFixesAns} ans (fixes), {parametres.seuilPortablesAns} ans (portables)
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user