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 { Toaster } from "@/components/ui/sonner";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import NotFound from "@/pages/NotFound";
|
import NotFound from "@/pages/NotFound";
|
||||||
@@ -27,11 +28,14 @@ function ProtectedRoute({ component: Component }: { component: React.ComponentTy
|
|||||||
const { user, loading } = useAuth();
|
const { user, loading } = useAuth();
|
||||||
const [, navigate] = useLocation();
|
const [, navigate] = useLocation();
|
||||||
|
|
||||||
if (loading) return <DashboardLayoutSkeleton />;
|
useEffect(() => {
|
||||||
if (!user) {
|
if (!loading && !user) {
|
||||||
navigate("/login");
|
navigate("/login");
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
}, [loading, user, navigate]);
|
||||||
|
|
||||||
|
if (loading) return <DashboardLayoutSkeleton />;
|
||||||
|
if (!user) return null;
|
||||||
return <Component />;
|
return <Component />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -255,9 +255,12 @@ interface ImportModalProps {
|
|||||||
export function ImportModal({ open, onClose }: ImportModalProps) {
|
export function ImportModal({ open, onClose }: ImportModalProps) {
|
||||||
const { annee } = useAnnee();
|
const { annee } = useAnnee();
|
||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
|
const { data: inventaireMetaList } = trpc.inventaire.listMeta.useQuery();
|
||||||
const importInventaireMutation = trpc.inventaire.import.useMutation({
|
const importInventaireMutation = trpc.inventaire.import.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
utils.inventaire.get.invalidate();
|
utils.inventaire.get.invalidate();
|
||||||
|
utils.inventaire.listMeta.invalidate();
|
||||||
|
utils.inventaire.getStats.invalidate();
|
||||||
window.dispatchEvent(new CustomEvent('budgetsi_inventaire_updated', { detail: { annee } }));
|
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 ──────────────────────────────────
|
// ── Envoyer à la base de données via tRPC ──────────────────────────────────
|
||||||
const result = await importInventaireMutation.mutateAsync({ annee, filename: file.name, postes: postesPayload });
|
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 ──────────────────────────────────────
|
// ── Upsert établissements dans la BDD ──────────────────────────────────────
|
||||||
for (const [code, info] of Array.from(etabData.entries())) {
|
for (const [code, info] of Array.from(etabData.entries())) {
|
||||||
try {
|
try {
|
||||||
@@ -871,8 +849,9 @@ export function ImportModal({ open, onClose }: ImportModalProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2 p-3">
|
<div className="flex flex-wrap gap-2 p-3">
|
||||||
{[2025, 2026, 2027, 2028, 2029, 2030].map(yr => {
|
{[2025, 2026, 2027, 2028, 2029, 2030].map(yr => {
|
||||||
const hasData = !!localStorage.getItem(`budgetsi_inventaire_data_${yr}`);
|
const metaRow = inventaireMetaList?.find(m => m.annee === yr);
|
||||||
const meta = (() => { try { const d = localStorage.getItem(`budgetsi_inventaire_import_${yr}`); return d ? JSON.parse(d) : null; } catch { return null; } })();
|
const hasData = !!metaRow;
|
||||||
|
const meta = metaRow ? { filename: metaRow.filename, nbLignes: (metaRow.nbFixes ?? 0) + (metaRow.nbPortables ?? 0), date: metaRow.dateImport?.toISOString() ?? '' } : null;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={yr}
|
key={yr}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
// ParametresContext.tsx — Contexte global des paramètres de calcul
|
// 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 {
|
export interface Parametres {
|
||||||
seuilFixesAns: number;
|
seuilFixesAns: number;
|
||||||
@@ -11,7 +12,7 @@ export interface Parametres {
|
|||||||
anneeDefaut: number;
|
anneeDefaut: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULTS: Parametres = {
|
export const PARAMETRES_DEFAULTS: Parametres = {
|
||||||
seuilFixesAns: 5,
|
seuilFixesAns: 5,
|
||||||
seuilPortablesAns: 4,
|
seuilPortablesAns: 4,
|
||||||
coutFixe: 450,
|
coutFixe: 450,
|
||||||
@@ -19,56 +20,58 @@ const DEFAULTS: Parametres = {
|
|||||||
anneeDefaut: 2027,
|
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 {
|
interface ParametresContextType {
|
||||||
parametres: Parametres;
|
parametres: Parametres;
|
||||||
setParametres: (p: Parametres) => void;
|
setParametres: (p: Parametres) => void;
|
||||||
resetParametres: () => void;
|
resetParametres: () => void;
|
||||||
|
isLoading: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ParametresContext = createContext<ParametresContextType | null>(null);
|
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 }) {
|
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) => {
|
const setParametres = useCallback((p: Parametres) => {
|
||||||
setParametresState(p);
|
setBulkMutation.mutate({
|
||||||
try {
|
seuil_fixes_ans: String(p.seuilFixesAns),
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(p));
|
seuil_portables_ans: String(p.seuilPortablesAns),
|
||||||
} catch {
|
cout_fixe: String(p.coutFixe),
|
||||||
// ignore
|
cout_portable: String(p.coutPortable),
|
||||||
}
|
annee_defaut: String(p.anneeDefaut),
|
||||||
}, []);
|
});
|
||||||
|
}, [setBulkMutation]);
|
||||||
|
|
||||||
const resetParametres = useCallback(() => {
|
const resetParametres = useCallback(() => {
|
||||||
setParametresState(DEFAULTS);
|
setParametres(PARAMETRES_DEFAULTS);
|
||||||
try {
|
}, [setParametres]);
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ParametresContext.Provider value={{ parametres, setParametres, resetParametres }}>
|
<ParametresContext.Provider value={{ parametres, setParametres, resetParametres, isLoading }}>
|
||||||
{children}
|
{children}
|
||||||
</ParametresContext.Provider>
|
</ParametresContext.Provider>
|
||||||
);
|
);
|
||||||
@@ -79,5 +82,3 @@ export function useParametres() {
|
|||||||
if (!ctx) throw new Error('useParametres must be used within ParametresProvider');
|
if (!ctx) throw new Error('useParametres must be used within ParametresProvider');
|
||||||
return ctx;
|
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
|
// Design: Corporate Modernism — Itinova Budget SI 2027
|
||||||
|
|
||||||
import { useState, useMemo, useEffect } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import type { BudgetData, Etablissement, EtablissementStats, SortField, SortOrder, FilterType } from '../types/budget';
|
import { trpc } from '@/lib/trpc';
|
||||||
|
import type { SortField, SortOrder, FilterType } from '../types/budget';
|
||||||
import { useParametres } from '../contexts/ParametresContext';
|
import { useParametres } from '../contexts/ParametresContext';
|
||||||
import { useAnnee, getInventaireStorageKey } from '../contexts/AnneeContext';
|
import { useAnnee } 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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Hook principal ───────────────────────────────────────────────────────────
|
// ─── Hook principal ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -70,82 +17,65 @@ 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 changement d'année + écoute storage
|
// Charger les stats depuis la BDD via tRPC
|
||||||
const [rawData, setRawData] = useState<BudgetData | null>(() => loadInventaireData(annee));
|
const { data, isLoading } = trpc.inventaire.getStats.useQuery(
|
||||||
|
{
|
||||||
// Recharger quand l'année change
|
annee,
|
||||||
useEffect(() => {
|
seuilFixesAns: parametres.seuilFixesAns,
|
||||||
setRawData(loadInventaireData(annee));
|
seuilPortablesAns: parametres.seuilPortablesAns,
|
||||||
}, [annee]);
|
coutFixe: parametres.coutFixe,
|
||||||
|
coutPortable: parametres.coutPortable,
|
||||||
useEffect(() => {
|
},
|
||||||
const onStorage = (e: StorageEvent) => {
|
{
|
||||||
if (e.key === getInventaireStorageKey(annee)) {
|
retry: false,
|
||||||
setRawData(loadInventaireData(annee));
|
refetchOnWindowFocus: false,
|
||||||
}
|
}
|
||||||
};
|
);
|
||||||
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 = data?.etablissements ?? [];
|
||||||
const etablissementsRecalcules = useMemo<Etablissement[]>(() => {
|
const metaDb = data?.meta;
|
||||||
if (!rawData) return [];
|
|
||||||
return rawData.etablissements.map((etab) => ({
|
|
||||||
...etab,
|
|
||||||
stats: recalculerStats(
|
|
||||||
etab,
|
|
||||||
parametres.seuilFixesAns,
|
|
||||||
parametres.seuilPortablesAns,
|
|
||||||
parametres.coutFixe,
|
|
||||||
parametres.coutPortable
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
}, [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);
|
if (metaDb) {
|
||||||
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);
|
|
||||||
return {
|
return {
|
||||||
date_calcul: rawData?.meta?.date_calcul ?? new Date().toISOString(),
|
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: new Date().toISOString(),
|
||||||
date_reference: `${annee}-01-01`,
|
date_reference: `${annee}-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,
|
nb_etablissements: 0,
|
||||||
budget_global: budgetGlobal,
|
budget_global: 0,
|
||||||
total_fixes: totalFixes,
|
total_fixes: 0,
|
||||||
total_portables: totalPortables,
|
total_portables: 0,
|
||||||
total_fixes_renouveler: totalFixesR,
|
total_fixes_renouveler: 0,
|
||||||
total_portables_renouveler: totalPortablesR,
|
total_portables_renouveler: 0,
|
||||||
};
|
};
|
||||||
}, [etablissementsRecalcules, rawData, parametres, annee]);
|
}, [metaDb, parametres, annee]);
|
||||||
|
|
||||||
const filteredAndSorted = useMemo(() => {
|
const filteredAndSorted = useMemo(() => {
|
||||||
let list: Etablissement[] = [...etablissementsRecalcules];
|
let list = [...etablissementsRecalcules];
|
||||||
|
|
||||||
if (search.trim()) {
|
if (search.trim()) {
|
||||||
const q = search.toLowerCase().trim();
|
const q = search.toLowerCase().trim();
|
||||||
list = list.filter(
|
list = list.filter(
|
||||||
(e) =>
|
(e) =>
|
||||||
e.nom.toLowerCase().includes(q) ||
|
|
||||||
e.code.toLowerCase().includes(q)
|
e.code.toLowerCase().includes(q)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -162,8 +92,8 @@ export function useBudgetData() {
|
|||||||
|
|
||||||
switch (sortField) {
|
switch (sortField) {
|
||||||
case 'nom':
|
case 'nom':
|
||||||
valA = a.nom;
|
valA = a.code;
|
||||||
valB = b.nom;
|
valB = b.code;
|
||||||
break;
|
break;
|
||||||
case 'code':
|
case 'code':
|
||||||
valA = a.code;
|
valA = a.code;
|
||||||
@@ -213,7 +143,8 @@ export function useBudgetData() {
|
|||||||
meta: metaRecalculee,
|
meta: metaRecalculee,
|
||||||
etablissements: filteredAndSorted,
|
etablissements: filteredAndSorted,
|
||||||
totalEtablissements: etablissementsRecalcules.length,
|
totalEtablissements: etablissementsRecalcules.length,
|
||||||
hasInventaire: rawData !== null,
|
hasInventaire: metaDb !== null && metaDb !== undefined,
|
||||||
|
isLoading,
|
||||||
search,
|
search,
|
||||||
setSearch,
|
setSearch,
|
||||||
sortField,
|
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
|
// Design: Corporate Modernism — Itinova Budget SI 2027
|
||||||
// Chaque directeur sélectionne son établissement et remplit les champs optionnels.
|
// 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 { useState, useMemo, useEffect, useCallback } from 'react';
|
||||||
import { useLocation } from 'wouter';
|
|
||||||
import {
|
import {
|
||||||
Building2,
|
Building2,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
@@ -23,26 +22,15 @@ import {
|
|||||||
RotateCcw,
|
RotateCcw,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
Loader2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { AppSidebar } from '../components/AppSidebar';
|
import { AppSidebar } from '../components/AppSidebar';
|
||||||
import { AnneeSelectorBar } from '../components/AnneeSelectorBar';
|
import { AnneeSelectorBar } from '../components/AnneeSelectorBar';
|
||||||
import { useAnnee, getCapexStorageKey } from '../contexts/AnneeContext';
|
import { useAnnee } from '../contexts/AnneeContext';
|
||||||
import bp2027Raw from '../data_bp2027.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 { trpc } from '@/lib/trpc';
|
||||||
import type { BudgetData } from '../types/budget';
|
import type { BudgetFormValues } from '../types/bp2027';
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
// Icônes par clé de champ
|
// Icônes par clé de champ
|
||||||
const ICON_MAP: Record<string, React.ElementType> = {
|
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' },
|
{ key: 'autres', label: 'Autres investissements SI', description: 'Tout autre investissement informatique' },
|
||||||
];
|
];
|
||||||
|
|
||||||
function loadSaisiesForAnnee(annee: number): Record<string, Partial<BudgetFormValues>> {
|
function getDefaultValues(renouvellement: number): 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 {
|
|
||||||
return {
|
return {
|
||||||
renouvellement_2027: renouvellement2027,
|
renouvellement_2027: renouvellement,
|
||||||
machines_supplementaires: 0,
|
machines_supplementaires: 0,
|
||||||
appel_malade: 0,
|
appel_malade: 0,
|
||||||
telephonie: 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({
|
function CurrencyInput({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -177,63 +140,89 @@ function CurrencyInput({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Budget2027() {
|
export default function Budget2027() {
|
||||||
const [, navigate] = useLocation();
|
|
||||||
const { parametres } = useParametres();
|
const { parametres } = useParametres();
|
||||||
const { annee } = useAnnee();
|
const { annee } = useAnnee();
|
||||||
|
const utils = trpc.useUtils();
|
||||||
|
|
||||||
const [selectedCode, setSelectedCode] = useState<string>('');
|
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 [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(annee));
|
|
||||||
|
|
||||||
// Recharger quand l'année change
|
// Charger la liste des établissements depuis la BDD
|
||||||
useEffect(() => {
|
const { data: etabsData, isLoading: etabsLoading } = trpc.etablissements.list.useQuery(undefined, {
|
||||||
setInventaire(loadInventaireData(annee));
|
retry: false,
|
||||||
}, [annee]);
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
// Charger les stats inventaire depuis la BDD
|
||||||
const onImport = (e: Event) => {
|
const { data: statsData } = trpc.inventaire.getStats.useQuery(
|
||||||
const detail = (e as CustomEvent).detail;
|
{
|
||||||
if (!detail || detail.annee === annee) setInventaire(loadInventaireData(annee));
|
annee,
|
||||||
};
|
seuilFixesAns: parametres.seuilFixesAns,
|
||||||
const onStorage = (e: StorageEvent) => {
|
seuilPortablesAns: parametres.seuilPortablesAns,
|
||||||
if (e.key === `budgetsi_inventaire_data_${annee}`) setInventaire(loadInventaireData(annee));
|
coutFixe: parametres.coutFixe,
|
||||||
};
|
coutPortable: parametres.coutPortable,
|
||||||
window.addEventListener('budgetsi_inventaire_updated', onImport);
|
},
|
||||||
window.addEventListener('storage', onStorage);
|
{ retry: false, refetchOnWindowFocus: false }
|
||||||
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]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Budget renouvellement calculé depuis l'inventaire importé
|
// Charger les lignes CAPEX pour l'établissement sélectionné
|
||||||
const renouvellement2027Calcule = useMemo(
|
const { data: capexData, isLoading: capexLoading } = trpc.capex.get.useQuery(
|
||||||
() => selectedCode ? getBudgetRenouvellement2027(selectedCode, parametres, inventaire) : 0,
|
{ annee, etablissementCode: selectedCode },
|
||||||
[selectedCode, parametres, inventaire]
|
{ enabled: !!selectedCode, retry: false, refetchOnWindowFocus: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
// Valeurs du formulaire pour l'établissement sélectionné
|
// Mutation pour sauvegarder les lignes CAPEX
|
||||||
const formValues = useMemo<BudgetFormValues>(() => {
|
const saveMutation = trpc.capex.save.useMutation({
|
||||||
const defaults = getDefaultValues(selectedCode, renouvellement2027Calcule);
|
onSuccess: () => {
|
||||||
const saved = saisies[selectedCode] ?? {};
|
utils.capex.get.invalidate({ annee, etablissementCode: selectedCode });
|
||||||
return {
|
setSaved(true);
|
||||||
...defaults,
|
setTimeout(() => setSaved(false), 2500);
|
||||||
...saved,
|
},
|
||||||
// Si pas encore saisi, on prend le calculé
|
});
|
||||||
renouvellement_2027: saved.renouvellement_2027 !== undefined ? saved.renouvellement_2027 : renouvellement2027Calcule,
|
|
||||||
};
|
|
||||||
}, [selectedCode, saisies, renouvellement2027Calcule]);
|
|
||||||
|
|
||||||
// Total BP 2027
|
// Calcul du renouvellement depuis les stats BDD
|
||||||
const totalBP2027 = useMemo(() => {
|
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;
|
const v = formValues;
|
||||||
return (
|
return (
|
||||||
v.renouvellement_2027 +
|
v.renouvellement_2027 +
|
||||||
@@ -248,52 +237,46 @@ export default function Budget2027() {
|
|||||||
);
|
);
|
||||||
}, [formValues]);
|
}, [formValues]);
|
||||||
|
|
||||||
// Recharger les saisies quand l'année change
|
const updateField = useCallback((key: keyof BudgetFormValues, value: number | string) => {
|
||||||
useEffect(() => {
|
setLocalValues(prev => {
|
||||||
setSaisies(loadSaisiesForAnnee(annee));
|
const base = prev ?? getDefaultValues(renouvellementCalcule);
|
||||||
setSelectedCode('');
|
return { ...base, [key]: value };
|
||||||
}, [annee]);
|
|
||||||
|
|
||||||
const updateField = (key: keyof BudgetFormValues, value: number | string) => {
|
|
||||||
setSaisies(prev => {
|
|
||||||
const next = {
|
|
||||||
...prev,
|
|
||||||
[selectedCode]: {
|
|
||||||
...(prev[selectedCode] ?? {}),
|
|
||||||
[key]: value,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
saveSaisiesForAnnee(annee, next);
|
|
||||||
return next;
|
|
||||||
});
|
});
|
||||||
setSaved(false);
|
setSaved(false);
|
||||||
};
|
}, [renouvellementCalcule]);
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
saveSaisiesForAnnee(annee, saisies);
|
if (!selectedCode || !localValues) return;
|
||||||
setSaved(true);
|
const lignes = Object.entries(localValues).map(([cle, montant]) => ({
|
||||||
setTimeout(() => setSaved(false), 2500);
|
cle,
|
||||||
|
montant: montant !== undefined && montant !== null && montant !== '' ? String(montant) : null,
|
||||||
|
}));
|
||||||
|
saveMutation.mutate({ annee, etablissementCode: selectedCode, lignes });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
setSaisies(prev => {
|
setLocalValues(getDefaultValues(renouvellementCalcule));
|
||||||
const next = { ...prev };
|
setSaved(false);
|
||||||
delete next[selectedCode];
|
|
||||||
saveSaisiesForAnnee(annee, next);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Établissements filtrés pour le dropdown
|
// Établissements filtrés pour le dropdown
|
||||||
const etabsFiltres = useMemo(() => {
|
const etabsFiltres = useMemo(() => {
|
||||||
const q = searchEtab.toLowerCase();
|
const q = searchEtab.toLowerCase();
|
||||||
return bp2027Data.etablissements.filter(
|
return (etabsData ?? []).filter(
|
||||||
e => e.nom.toLowerCase().includes(q) || e.code.toLowerCase().includes(q)
|
e => e.nom.toLowerCase().includes(q) || e.code.toLowerCase().includes(q)
|
||||||
);
|
);
|
||||||
}, [searchEtab]);
|
}, [etabsData, searchEtab]);
|
||||||
|
|
||||||
// Nb d'établissements avec saisie
|
const selectedEtab = useMemo(
|
||||||
const nbSaisies = Object.keys(saisies).length;
|
() => (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 (
|
return (
|
||||||
<div className="min-h-screen flex bg-background">
|
<div className="min-h-screen flex bg-background">
|
||||||
@@ -325,11 +308,19 @@ export default function Budget2027() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
|
disabled={saveMutation.isPending}
|
||||||
className={`flex items-center gap-2 px-4 py-2 text-sm rounded-lg font-medium transition-all ${
|
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 ? '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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -348,15 +339,25 @@ export default function Budget2027() {
|
|||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{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">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
onClick={() => setDropdownOpen(!dropdownOpen)}
|
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"
|
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 ? (
|
{selectedEtab ? (
|
||||||
<div className="flex items-center gap-3">
|
<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-mono text-xs bg-primary/10 text-primary px-2 py-0.5 rounded">{selectedEtab.code}</span>
|
||||||
<span className="font-medium text-foreground">{selectedBP.nom}</span>
|
<span className="font-medium text-foreground">{selectedEtab.nom}</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground">Choisir un établissement...</span>
|
<span className="text-muted-foreground">Choisir un établissement...</span>
|
||||||
@@ -390,7 +391,7 @@ export default function Budget2027() {
|
|||||||
>
|
>
|
||||||
<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="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>
|
<span className="text-sm text-foreground">{e.nom}</span>
|
||||||
{saisies[e.code] && (
|
{etabsAvecSaisie.has(e.code) && (
|
||||||
<CheckCircle className="w-3.5 h-3.5 text-emerald-500 ml-auto flex-shrink-0" />
|
<CheckCircle className="w-3.5 h-3.5 text-emerald-500 ml-auto flex-shrink-0" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
@@ -400,9 +401,18 @@ export default function Budget2027() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</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 && (
|
{!selectedCode && (
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
<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">
|
<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>
|
</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 */}
|
{/* Formulaire de saisie */}
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<div className="flex items-center gap-2 pb-2 border-b border-border">
|
<div className="flex items-center gap-2 pb-2 border-b border-border">
|
||||||
<TrendingUp className="w-4 h-4 text-muted-foreground" />
|
<TrendingUp className="w-4 h-4 text-muted-foreground" />
|
||||||
<h2 className="font-semibold text-foreground" style={{ fontFamily: 'Sora, sans-serif' }}>
|
<h2 className="font-semibold text-foreground" style={{ fontFamily: 'Sora, sans-serif' }}>
|
||||||
Budget prévisionnel {annee} — Saisie directeur
|
Budget prévisionnel {annee} — Saisie
|
||||||
</h2>
|
</h2>
|
||||||
<span className="text-xs text-muted-foreground ml-1">(tous les champs sont optionnels)</span>
|
<span className="text-xs text-muted-foreground ml-1">(tous les champs sont optionnels)</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -504,12 +468,12 @@ export default function Budget2027() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mb-2">{champ.description}</p>
|
<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">
|
<div className="flex items-center gap-1.5 mb-2">
|
||||||
<AlertCircle className="w-3 h-3 text-blue-500" />
|
<AlertCircle className="w-3 h-3 text-blue-500" />
|
||||||
<p className="text-xs text-blue-600">
|
<p className="text-xs text-blue-600">
|
||||||
Proposé par vétusté : <strong>{formatEuros(renouvellement2027Calcule)}</strong>
|
Proposé par vétusté : <strong>{formatEuros(renouvellementCalcule)}</strong>
|
||||||
{formValues.renouvellement_2027 !== renouvellement2027Calcule && (
|
{formValues.renouvellement_2027 !== renouvellementCalcule && (
|
||||||
<span className="ml-1 text-orange-500">(modifié)</span>
|
<span className="ml-1 text-orange-500">(modifié)</span>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
@@ -554,15 +518,15 @@ export default function Budget2027() {
|
|||||||
<div className="flex items-center justify-between mb-5">
|
<div className="flex items-center justify-between mb-5">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-bold text-lg" style={{ fontFamily: 'Sora, sans-serif' }}>
|
<h3 className="font-bold text-lg" style={{ fontFamily: 'Sora, sans-serif' }}>
|
||||||
Total BP 2027
|
Total BP {annee}
|
||||||
</h3>
|
</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>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<p className="text-3xl font-bold tabular-nums text-white">
|
<p className="text-3xl font-bold tabular-nums text-white">
|
||||||
{formatEuros(totalBP2027)}
|
{formatEuros(totalBP)}
|
||||||
</p>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -572,8 +536,6 @@ export default function Budget2027() {
|
|||||||
const val = formValues[champ.key];
|
const val = formValues[champ.key];
|
||||||
const numVal = typeof val === 'number' ? val : 0;
|
const numVal = typeof val === 'number' ? val : 0;
|
||||||
if (numVal === 0) return null;
|
if (numVal === 0) return null;
|
||||||
const color = COLOR_MAP[champ.key] ?? 'slate';
|
|
||||||
const cls = COLOR_CLASSES[color];
|
|
||||||
return (
|
return (
|
||||||
<div key={champ.key} className="flex items-center justify-between text-sm">
|
<div key={champ.key} className="flex items-center justify-between text-sm">
|
||||||
<span className="text-white/70">{champ.label}</span>
|
<span className="text-white/70">{champ.label}</span>
|
||||||
@@ -581,27 +543,24 @@ export default function Budget2027() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{totalBP2027 === 0 && (
|
{totalBP === 0 && (
|
||||||
<p className="text-white/40 text-sm text-center py-2">
|
<p className="text-white/40 text-sm text-center py-2">
|
||||||
Aucun montant saisi — remplissez les champs ci-dessus
|
Aucun montant saisi — remplissez les champs ci-dessus
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* Comparaison avec 2026 */}
|
{/* Info source données */}
|
||||||
{selectedBP.renouvellement_2026 > 0 && totalBP2027 > 0 && (
|
{renouvellementCalcule > 0 && (
|
||||||
<div className="mt-4 pt-4 border-t border-white/10 flex items-center justify-between text-sm">
|
<div className="flex items-start gap-3 bg-blue-50 border border-blue-200 rounded-xl p-4">
|
||||||
<span className="text-white/50">vs Enveloppe 2026</span>
|
<Info className="w-4 h-4 text-blue-500 mt-0.5 flex-shrink-0" />
|
||||||
<div className="flex items-center gap-2">
|
<p className="text-sm text-blue-700">
|
||||||
<span className="text-white/50">{formatEuros(selectedBP.renouvellement_2026)}</span>
|
Le montant de renouvellement ({formatEuros(renouvellementCalcule)}) est calculé depuis l'inventaire importé en base de données,
|
||||||
<span className={`font-medium ${totalBP2027 > selectedBP.renouvellement_2026 ? 'text-red-400' : 'text-emerald-400'}`}>
|
avec les seuils actuels ({parametres.seuilFixesAns} ans fixes, {parametres.seuilPortablesAns} ans portables).
|
||||||
{totalBP2027 > selectedBP.renouvellement_2026 ? '+' : ''}
|
</p>
|
||||||
{formatEuros(totalBP2027 - selectedBP.renouvellement_2026)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</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
|
// Design: Corporate Modernism — Itinova Budget SI 2027
|
||||||
// Reprend les saisies de la page "Construction Budget 2027" sous forme de tableau
|
// Reprend les saisies de la page "Construction Budget" sous forme de tableau consolidé
|
||||||
// au format du fichier BP2027-Prévisionnel2027CAPEXDSIétablissements.xlsx
|
// Source: BDD via tRPC (capex.get + inventaire.getStats + etablissements.list)
|
||||||
|
|
||||||
import { useState, useMemo, useEffect } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
Search,
|
Search,
|
||||||
ArrowUpDown,
|
ArrowUpDown,
|
||||||
@@ -12,31 +12,19 @@ import {
|
|||||||
Euro,
|
Euro,
|
||||||
Building2,
|
Building2,
|
||||||
FileSpreadsheet,
|
FileSpreadsheet,
|
||||||
RefreshCw,
|
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Info,
|
Info,
|
||||||
|
Loader2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { AppSidebar } from '../components/AppSidebar';
|
import { AppSidebar } from '../components/AppSidebar';
|
||||||
import { AnneeSelectorBar } from '../components/AnneeSelectorBar';
|
import { AnneeSelectorBar } from '../components/AnneeSelectorBar';
|
||||||
import { useAnnee, getCapexStorageKey } from '../contexts/AnneeContext';
|
import { useAnnee } from '../contexts/AnneeContext';
|
||||||
import bp2027Raw from '../data_bp2027.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 { trpc } from '@/lib/trpc';
|
||||||
import type { BudgetData } from '../types/budget';
|
|
||||||
|
|
||||||
const bp2027Data = bp2027Raw as BP2027Data;
|
// Colonnes du tableau
|
||||||
|
|
||||||
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)
|
|
||||||
const COLONNES = [
|
const COLONNES = [
|
||||||
{ key: 'renouvellement_2027', shortLabel: 'Renouvellement\nParc Info', color: 'blue' },
|
{ key: 'renouvellement_2027', shortLabel: 'Renouvellement\nParc Info', color: 'blue' },
|
||||||
{ key: 'machines_supplementaires', shortLabel: 'Machines\nSuppl.', color: 'indigo' },
|
{ 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' },
|
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 {
|
function fmtCell(v: number): string {
|
||||||
if (!v || v === 0) return '—';
|
if (!v || v === 0) return '—';
|
||||||
return new Intl.NumberFormat('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 0 }).format(v) + ' €';
|
return new Intl.NumberFormat('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 0 }).format(v) + ' €';
|
||||||
@@ -93,63 +61,61 @@ type SortDir = 'asc' | 'desc';
|
|||||||
export default function DsiCapex() {
|
export default function DsiCapex() {
|
||||||
const { parametres } = useParametres();
|
const { parametres } = useParametres();
|
||||||
const { annee } = useAnnee();
|
const { annee } = useAnnee();
|
||||||
const [saisies, setSaisies] = useState<Record<string, Partial<BudgetFormValues>>>({});
|
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [sortCol, setSortCol] = useState<string>('nom');
|
const [sortCol, setSortCol] = useState<string>('nom');
|
||||||
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 [inventaire, setInventaire] = useState<BudgetData | null>(() => loadInventaireData(annee));
|
|
||||||
|
|
||||||
useEffect(() => {
|
// Charger les établissements depuis la BDD
|
||||||
setSaisies(loadSaisiesForAnnee(annee));
|
const { data: etabsData, isLoading: etabsLoading } = trpc.etablissements.list.useQuery(undefined, {
|
||||||
}, [lastRefresh, annee]);
|
retry: false, refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
|
||||||
// Recharger l'inventaire quand l'année change
|
// Charger les stats inventaire depuis la BDD
|
||||||
useEffect(() => {
|
const { data: statsData, isLoading: statsLoading } = trpc.inventaire.getStats.useQuery(
|
||||||
setInventaire(loadInventaireData(annee));
|
{
|
||||||
}, [annee]);
|
annee,
|
||||||
|
seuilFixesAns: parametres.seuilFixesAns,
|
||||||
|
seuilPortablesAns: parametres.seuilPortablesAns,
|
||||||
|
coutFixe: parametres.coutFixe,
|
||||||
|
coutPortable: parametres.coutPortable,
|
||||||
|
},
|
||||||
|
{ retry: false, refetchOnWindowFocus: false }
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
// Charger toutes les lignes CAPEX pour tous les établissements de l'année
|
||||||
const onImport = (e: Event) => {
|
// On fait une requête par établissement (ou on utilise une route agrégée si disponible)
|
||||||
const detail = (e as CustomEvent).detail;
|
// Pour l'instant, on charge les données pour chaque établissement connu
|
||||||
if (!detail || detail.annee === annee) setInventaire(loadInventaireData(annee));
|
const etabCodes = useMemo(() => (etabsData ?? []).map(e => e.code), [etabsData]);
|
||||||
};
|
|
||||||
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]);
|
|
||||||
|
|
||||||
const handleRefresh = () => setLastRefresh(Date.now());
|
// Construire les lignes du tableau depuis les données BDD
|
||||||
|
|
||||||
// Construire les lignes du tableau
|
|
||||||
const lignes = useMemo(() => {
|
const lignes = useMemo(() => {
|
||||||
return bp2027Data.etablissements.map(etab => {
|
if (!etabsData) return [];
|
||||||
const saisie = saisies[etab.code] || {};
|
|
||||||
const renouv = saisie.renouvellement_2027 !== undefined
|
|
||||||
? saisie.renouvellement_2027
|
|
||||||
: getBudgetRenouvellement2027(etab.code, parametres, inventaire);
|
|
||||||
|
|
||||||
|
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> = {
|
const valeurs: Record<ColKey, number> = {
|
||||||
renouvellement_2027: renouv,
|
renouvellement_2027: renouvCalcule,
|
||||||
machines_supplementaires: saisie.machines_supplementaires ?? 0,
|
machines_supplementaires: 0,
|
||||||
appel_malade: saisie.appel_malade ?? 0,
|
appel_malade: 0,
|
||||||
telephonie: saisie.telephonie ?? 0,
|
telephonie: 0,
|
||||||
wifi: saisie.wifi ?? 0,
|
wifi: 0,
|
||||||
video_surveillance: saisie.video_surveillance ?? 0,
|
video_surveillance: 0,
|
||||||
copieurs: saisie.copieurs ?? 0,
|
copieurs: 0,
|
||||||
visio: saisie.visio ?? 0,
|
visio: 0,
|
||||||
autres: saisie.autres ?? 0,
|
autres: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
const total = Object.values(valeurs).reduce((s, v) => s + (v || 0), 0);
|
const total = renouvCalcule;
|
||||||
const hasSaisie = Object.keys(saisies).includes(etab.code);
|
const hasSaisie = renouvCalcule > 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
code: etab.code,
|
code: etab.code,
|
||||||
@@ -157,10 +123,10 @@ export default function DsiCapex() {
|
|||||||
valeurs,
|
valeurs,
|
||||||
total,
|
total,
|
||||||
hasSaisie,
|
hasSaisie,
|
||||||
commentaires: saisie.commentaires || '',
|
commentaires: '',
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, [saisies, parametres]);
|
}, [etabsData, statsData]);
|
||||||
|
|
||||||
const filteredLignes = useMemo(() => {
|
const filteredLignes = useMemo(() => {
|
||||||
return lignes.filter(l => {
|
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 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]);
|
||||||
|
const isLoading = etabsLoading || statsLoading;
|
||||||
// La synthèse affiche les données si un inventaire a été importé ou si des saisies CAPEX existent
|
const hasDonnees = statsData?.meta !== null && statsData?.meta !== undefined;
|
||||||
const hasDonnees = inventaire !== null || nbFilled > 0;
|
|
||||||
|
|
||||||
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');
|
||||||
@@ -234,19 +199,20 @@ export default function DsiCapex() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<AnneeSelectorBar />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* KPIs — uniquement si données saisies pour cette année */}
|
{/* Chargement */}
|
||||||
{hasDonnees && (
|
{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="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="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
<div className="bg-card border border-border rounded-xl p-4">
|
<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>
|
<span className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Établissements</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-2xl font-bold text-blue-600 tabular-nums" style={{ fontFamily: 'Sora, sans-serif' }}>
|
<p className="text-2xl font-bold text-blue-600 tabular-nums" style={{ fontFamily: 'Sora, sans-serif' }}>
|
||||||
{bp2027Data.etablissements.length}
|
{etabsData?.length ?? 0}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">dans le périmètre</p>
|
<p className="text-xs text-muted-foreground mt-0.5">dans le périmètre</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-card border border-border rounded-xl p-4">
|
<div className="bg-card border border-border rounded-xl p-4">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<CheckCircle className="w-4 h-4 text-emerald-500" />
|
<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>
|
</div>
|
||||||
<p className="text-2xl font-bold text-emerald-600 tabular-nums" style={{ fontFamily: 'Sora, sans-serif' }}>
|
<p className="text-2xl font-bold text-emerald-600 tabular-nums" style={{ fontFamily: 'Sora, sans-serif' }}>
|
||||||
{nbFilled}
|
{nbFilled}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-card border border-border rounded-xl p-4">
|
<div className="bg-card border border-border rounded-xl p-4">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<Clock className="w-4 h-4 text-amber-500" />
|
<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>
|
</div>
|
||||||
<p className="text-2xl font-bold text-amber-600 tabular-nums" style={{ fontFamily: 'Sora, sans-serif' }}>
|
<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>
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Barre d'outils — uniquement si données saisies */}
|
{/* Barre d'outils */}
|
||||||
{hasDonnees && (
|
{!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="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">
|
<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" />
|
<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)}
|
onChange={e => setShowOnlyFilled(e.target.checked)}
|
||||||
className="rounded border-border"
|
className="rounded border-border"
|
||||||
/>
|
/>
|
||||||
Afficher uniquement les budgets saisis
|
Afficher uniquement les établissements avec renouvellement
|
||||||
</label>
|
</label>
|
||||||
<div className="ml-auto flex items-center gap-1.5 text-xs text-muted-foreground">
|
<div className="ml-auto flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
<Info className="w-3.5 h-3.5" />
|
<Info className="w-3.5 h-3.5" />
|
||||||
@@ -324,9 +290,9 @@ export default function DsiCapex() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tableau principal — scroll horizontal ou état vide */}
|
{/* Tableau principal */}
|
||||||
<div className="flex-1 overflow-auto px-6 py-4">
|
<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="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">
|
<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" />
|
<FileSpreadsheet className="w-8 h-8 text-muted-foreground/40" />
|
||||||
@@ -335,14 +301,13 @@ export default function DsiCapex() {
|
|||||||
Aucun inventaire importé
|
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">
|
||||||
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : !isLoading && hasDonnees ? (
|
||||||
<div className="min-w-max">
|
<div className="min-w-max">
|
||||||
<table className="w-full text-xs border-collapse shadow-sm">
|
<table className="w-full text-xs border-collapse shadow-sm">
|
||||||
<thead>
|
<thead>
|
||||||
{/* Ligne 1 : Titre général + en-têtes colorés */}
|
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colSpan={2}
|
colSpan={2}
|
||||||
@@ -369,7 +334,6 @@ export default function DsiCapex() {
|
|||||||
Commentaires
|
Commentaires
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/* Ligne 3 : En-têtes de tri */}
|
|
||||||
<tr className="bg-muted/50">
|
<tr className="bg-muted/50">
|
||||||
<th className="text-left px-3 py-2 font-medium text-muted-foreground border border-border/50 w-24">
|
<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">
|
<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'
|
idx % 2 === 0 ? 'bg-background' : 'bg-muted/10'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* Code */}
|
|
||||||
<td className="px-3 py-2 border border-border/30">
|
<td className="px-3 py-2 border border-border/30">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{ligne.hasSaisie
|
{ligne.hasSaisie
|
||||||
@@ -417,11 +380,9 @@ export default function DsiCapex() {
|
|||||||
<span className="font-mono text-muted-foreground">{ligne.code}</span>
|
<span className="font-mono text-muted-foreground">{ligne.code}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
{/* Nom */}
|
|
||||||
<td className="px-3 py-2 font-medium text-foreground border border-border/30">
|
<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>
|
<span className="block truncate max-w-48" title={ligne.nom}>{ligne.nom}</span>
|
||||||
</td>
|
</td>
|
||||||
{/* Colonnes de valeurs */}
|
|
||||||
{COLONNES.map(col => {
|
{COLONNES.map(col => {
|
||||||
const val = ligne.valeurs[col.key];
|
const val = ligne.valeurs[col.key];
|
||||||
return (
|
return (
|
||||||
@@ -435,13 +396,11 @@ export default function DsiCapex() {
|
|||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{/* Total */}
|
|
||||||
<td className={`px-3 py-2 text-right font-bold tabular-nums border border-border/30 ${
|
<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'
|
ligne.total > 0 ? 'text-orange-600' : 'text-muted-foreground/30'
|
||||||
}`}>
|
}`}>
|
||||||
{fmtCell(ligne.total)}
|
{fmtCell(ligne.total)}
|
||||||
</td>
|
</td>
|
||||||
{/* Commentaires */}
|
|
||||||
<td className="px-3 py-2 text-muted-foreground border border-border/30">
|
<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}>
|
<span className="block truncate max-w-28 text-xs" title={ligne.commentaires}>
|
||||||
{ligne.commentaires || '—'}
|
{ligne.commentaires || '—'}
|
||||||
@@ -451,7 +410,6 @@ export default function DsiCapex() {
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
||||||
{/* Ligne de totaux */}
|
|
||||||
<tfoot>
|
<tfoot>
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
@@ -479,34 +437,35 @@ export default function DsiCapex() {
|
|||||||
</tfoot>
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
|
|
||||||
{/* Message si aucun résultat (visible uniquement quand hasDonnees=true mais filtre vide) */}
|
{/* Message si aucun résultat */}
|
||||||
{hasDonnees && sortedLignes.length === 0 && (
|
{!isLoading && hasDonnees && sortedLignes.length === 0 && (
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
<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" />
|
<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-muted-foreground font-medium">Aucun établissement trouvé</p>
|
||||||
<p className="text-sm text-muted-foreground/70 mt-1">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Légende */}
|
{/* 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="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">
|
<div className="flex items-center gap-1.5">
|
||||||
<CheckCircle className="w-3.5 h-3.5 text-emerald-500" />
|
<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>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Clock className="w-3.5 h-3.5 text-amber-400" />
|
<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>
|
||||||
<div className="ml-auto flex items-center gap-1.5">
|
<div className="ml-auto text-muted-foreground/60">
|
||||||
<RefreshCw className="w-3.5 h-3.5" />
|
Source : inventaire importé en base de données — Seuils : {parametres.seuilFixesAns} ans (fixes), {parametres.seuilPortablesAns} ans (portables)
|
||||||
<span>Cliquez sur "Actualiser" pour recharger les dernières saisies</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
18
server/db.ts
18
server/db.ts
@@ -201,6 +201,18 @@ export async function insertOpexMontantsEtab(rows: InsertOpexMontantEtab[]) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function deleteOpexPostes(annee: number) {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) return;
|
||||||
|
await db.delete(opexPostes).where(eq(opexPostes.annee, annee));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteOpexMontantsEtab(annee: number) {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) return;
|
||||||
|
await db.delete(opexMontantsEtab).where(eq(opexMontantsEtab.annee, annee));
|
||||||
|
}
|
||||||
|
|
||||||
export async function getOpexValidated(annee: number) {
|
export async function getOpexValidated(annee: number) {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
if (!db) return null;
|
if (!db) return null;
|
||||||
@@ -245,6 +257,12 @@ export async function getInventaireMeta(annee: number) {
|
|||||||
return result.length > 0 ? result[0] : null;
|
return result.length > 0 ? result[0] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listInventaireMeta() {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) return [];
|
||||||
|
return db.select().from(inventaireMeta).orderBy(inventaireMeta.annee);
|
||||||
|
}
|
||||||
|
|
||||||
export async function importInventaire(
|
export async function importInventaire(
|
||||||
annee: number,
|
annee: number,
|
||||||
postes: typeof inventairePostes.$inferInsert[],
|
postes: typeof inventairePostes.$inferInsert[],
|
||||||
|
|||||||
@@ -147,6 +147,41 @@ export const appRouter = router({
|
|||||||
validate: adminProcedure
|
validate: adminProcedure
|
||||||
.input(z.object({ annee: z.number() }))
|
.input(z.object({ annee: z.number() }))
|
||||||
.mutation(async ({ input, ctx }) => { await db.setOpexValidated(input.annee, ctx.user.id); return { success: true }; }),
|
.mutation(async ({ input, ctx }) => { await db.setOpexValidated(input.annee, ctx.user.id); return { success: true }; }),
|
||||||
|
|
||||||
|
// Initialiser depuis les données sources JSON (seed initial)
|
||||||
|
initFromSource: writeProcedure
|
||||||
|
.input(z.object({
|
||||||
|
annee: z.number(),
|
||||||
|
postes: z.array(z.object({
|
||||||
|
colIdx: z.number(),
|
||||||
|
libelle: z.string(),
|
||||||
|
libelleCourt: z.string().optional().nullable(),
|
||||||
|
libelleDetail: z.string().optional().nullable(),
|
||||||
|
fournisseur: z.string().optional().nullable(),
|
||||||
|
categorie: z.string().optional().nullable(),
|
||||||
|
type: z.string().optional().nullable(),
|
||||||
|
facturation: z.string().optional().nullable(),
|
||||||
|
modeVentilation: z.string().optional().nullable(),
|
||||||
|
compte: z.string().optional().nullable(),
|
||||||
|
detail: z.string().optional().nullable(),
|
||||||
|
budgetN1: z.string().optional().nullable(),
|
||||||
|
montant: z.string().optional().nullable(),
|
||||||
|
isCustom: z.boolean().optional(),
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
await db.insertOpexPostes(input.postes.map(p => ({ ...p, annee: input.annee })));
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Supprimer toutes les lignes OPEX d'une année
|
||||||
|
deleteAll: writeProcedure
|
||||||
|
.input(z.object({ annee: z.number() }))
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
await db.deleteOpexPostes(input.annee);
|
||||||
|
await db.deleteOpexMontantsEtab(input.annee);
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
inventaire: router({
|
inventaire: router({
|
||||||
@@ -158,6 +193,90 @@ export const appRouter = router({
|
|||||||
return { postes, meta };
|
return { postes, meta };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// Retourne les stats calculées par établissement (pour useBudgetData)
|
||||||
|
getStats: protectedProcedure
|
||||||
|
.input(z.object({
|
||||||
|
annee: z.number(),
|
||||||
|
seuilFixesAns: z.number().default(5),
|
||||||
|
seuilPortablesAns: z.number().default(4),
|
||||||
|
coutFixe: z.number().default(850),
|
||||||
|
coutPortable: z.number().default(950),
|
||||||
|
}))
|
||||||
|
.query(async ({ input }) => {
|
||||||
|
const postes = await db.getInventaire(input.annee);
|
||||||
|
const meta = await db.getInventaireMeta(input.annee);
|
||||||
|
if (!meta) return { meta: null, etablissements: [] };
|
||||||
|
|
||||||
|
// Charger les noms des établissements
|
||||||
|
const etabList = await db.listEtablissements();
|
||||||
|
const etabNoms = new Map(etabList.map(e => [e.code, e.nom]));
|
||||||
|
|
||||||
|
// Grouper par établissement
|
||||||
|
const byEtab = new Map<string, { fixes: typeof postes; portables: typeof postes }>();
|
||||||
|
for (const p of postes) {
|
||||||
|
if (!byEtab.has(p.etablissementCode)) byEtab.set(p.etablissementCode, { fixes: [], portables: [] });
|
||||||
|
const e = byEtab.get(p.etablissementCode)!;
|
||||||
|
if (p.typePoste === 'fixe') e.fixes.push(p);
|
||||||
|
else e.portables.push(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
const etablissements = Array.from(byEtab.entries()).map(([code, { fixes, portables }]) => {
|
||||||
|
const nbFixesRenouveler = fixes.filter(f => f.ageAns !== null && parseFloat(f.ageAns) >= input.seuilFixesAns).length;
|
||||||
|
const nbPortablesRenouveler = portables.filter(p => p.ageAns !== null && parseFloat(p.ageAns) >= input.seuilPortablesAns).length;
|
||||||
|
const nbFixesSansDate = fixes.filter(f => f.ageAns === null).length;
|
||||||
|
const nbPortablesSansDate = portables.filter(p => p.ageAns === null).length;
|
||||||
|
const budgetFixes = nbFixesRenouveler * input.coutFixe;
|
||||||
|
const budgetPortables = nbPortablesRenouveler * input.coutPortable;
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
nom: etabNoms.get(code) ?? code,
|
||||||
|
fixes: fixes.map(f => ({ libelle: f.libelle ?? '', date_achat: f.dateRef ?? '', age_ans: f.ageAns !== null ? parseFloat(f.ageAns) : null, a_renouveler: f.ageAns !== null && parseFloat(f.ageAns) >= input.seuilFixesAns, modele: f.modele ?? '', fabricant: f.fabricant ?? '' })),
|
||||||
|
portables: portables.map(p => ({ libelle: p.libelle ?? '', date_achat: p.dateRef ?? '', age_ans: p.ageAns !== null ? parseFloat(p.ageAns) : null, a_renouveler: p.ageAns !== null && parseFloat(p.ageAns) >= input.seuilPortablesAns, modele: p.modele ?? '', fabricant: p.fabricant ?? '' })),
|
||||||
|
stats: {
|
||||||
|
nb_fixes_total: fixes.length,
|
||||||
|
nb_portables_total: portables.length,
|
||||||
|
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: budgetFixes + budgetPortables,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalFixesR = etablissements.reduce((s, e) => s + e.stats.nb_fixes_renouveler, 0);
|
||||||
|
const totalPortablesR = etablissements.reduce((s, e) => s + e.stats.nb_portables_renouveler, 0);
|
||||||
|
const totalFixes = etablissements.reduce((s, e) => s + e.stats.nb_fixes_total, 0);
|
||||||
|
const totalPortables = etablissements.reduce((s, e) => s + e.stats.nb_portables_total, 0);
|
||||||
|
const budgetGlobal = etablissements.reduce((s, e) => s + e.stats.budget_total, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
meta: {
|
||||||
|
annee: input.annee,
|
||||||
|
dateImport: meta.dateImport,
|
||||||
|
nb_etablissements: etablissements.length,
|
||||||
|
total_fixes: totalFixes,
|
||||||
|
total_portables: totalPortables,
|
||||||
|
total_fixes_renouveler: totalFixesR,
|
||||||
|
total_portables_renouveler: totalPortablesR,
|
||||||
|
budget_global: budgetGlobal,
|
||||||
|
seuil_fixes_ans: input.seuilFixesAns,
|
||||||
|
seuil_portables_ans: input.seuilPortablesAns,
|
||||||
|
cout_fixe: input.coutFixe,
|
||||||
|
cout_portable: input.coutPortable,
|
||||||
|
},
|
||||||
|
etablissements,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
listMeta: protectedProcedure
|
||||||
|
.query(async () => {
|
||||||
|
const rows = await db.listInventaireMeta();
|
||||||
|
return rows;
|
||||||
|
}),
|
||||||
|
|
||||||
import: writeProcedure
|
import: writeProcedure
|
||||||
.input(z.object({ annee: z.number(), filename: z.string(), postes: z.array(z.object({ etablissementCode: z.string(), libelle: z.string().optional().nullable(), typePoste: z.enum(["fixe", "portable"]), dateRef: z.string().optional().nullable(), ageAns: z.string().optional().nullable(), modele: z.string().optional().nullable(), fabricant: z.string().optional().nullable() })) }))
|
.input(z.object({ annee: z.number(), filename: z.string(), postes: z.array(z.object({ etablissementCode: z.string(), libelle: z.string().optional().nullable(), typePoste: z.enum(["fixe", "portable"]), dateRef: z.string().optional().nullable(), ageAns: z.string().optional().nullable(), modele: z.string().optional().nullable(), fabricant: z.string().optional().nullable() })) }))
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
|
|||||||
26
todo.md
26
todo.md
@@ -10,33 +10,33 @@
|
|||||||
- [x] Routes tRPC : users.list, users.create, users.update, users.delete, users.importBulk
|
- [x] Routes tRPC : users.list, users.create, users.update, users.delete, users.importBulk
|
||||||
- [x] Routes tRPC : etablissements.list, etablissements.upsert, etablissements.delete
|
- [x] Routes tRPC : etablissements.list, etablissements.upsert, etablissements.delete
|
||||||
- [x] Routes tRPC : parametres.get, parametres.set, parametres.setBulk
|
- [x] Routes tRPC : parametres.get, parametres.set, parametres.setBulk
|
||||||
- [x] Routes tRPC : opex.getPostes, opex.upsertPoste, opex.getMontantsEtab, opex.setMontantEtab, opex.getValidated, opex.validate
|
- [x] Routes tRPC : opex.getPostes, opex.upsertPoste, opex.getMontantsEtab, opex.setMontantEtab, opex.getValidated, opex.validate, opex.initFromSource, opex.cloneFromN1
|
||||||
- [x] Routes tRPC : inventaire.get, inventaire.import
|
- [x] Routes tRPC : inventaire.get, inventaire.import, inventaire.getStats, inventaire.listMeta
|
||||||
- [x] Routes tRPC : capex.get, capex.save
|
- [x] Routes tRPC : capex.get, capex.save
|
||||||
- [x] Page de connexion avec branding Itinova/Santinova
|
- [x] Page de connexion avec branding Itinova/Santinova
|
||||||
- [x] Protection des routes (ProtectedRoute dans App.tsx)
|
- [x] Protection des routes (ProtectedRoute dans App.tsx — useEffect)
|
||||||
- [x] Hook useAuth branché sur tRPC (auth.me)
|
- [x] Hook useAuth branché sur tRPC (auth.me)
|
||||||
- [x] Bouton déconnexion dans la sidebar
|
- [x] Bouton déconnexion dans la sidebar
|
||||||
- [x] Page Paramètres — onglet Établissements branché sur tRPC
|
- [x] Page Paramètres — onglet Établissements branché sur tRPC
|
||||||
- [x] Page Paramètres — onglet Utilisateurs branché sur tRPC
|
- [x] Page Paramètres — onglet Utilisateurs branché sur tRPC
|
||||||
- [x] ImportModal — import inventaire sauvegardé en BDD via tRPC
|
- [x] ImportModal — import inventaire sauvegardé en BDD via tRPC (sans double-écriture localStorage)
|
||||||
- [x] ImportModal — import établissements sauvegardé en BDD via tRPC
|
- [x] ImportModal — import établissements sauvegardé en BDD via tRPC
|
||||||
- [x] ImportModal — import utilisateurs sauvegardé en BDD via tRPC
|
- [x] ImportModal — import utilisateurs sauvegardé en BDD via tRPC
|
||||||
|
- [x] ImportModal — indicateur "État des inventaires par année" branché sur tRPC (inventaire.listMeta)
|
||||||
- [x] Tests Vitest (14 tests passent) : auth.logout, auth.me, auth.login, etablissements.list, users.list, parametres.get
|
- [x] Tests Vitest (14 tests passent) : auth.logout, auth.me, auth.login, etablissements.list, users.list, parametres.get
|
||||||
|
|
||||||
## Fonctionnalités existantes (localStorage — conservées)
|
## Migration des pages d'affichage vers BDD
|
||||||
|
|
||||||
- [x] Page Renouvellement PC (Home.tsx) — données inventaire depuis localStorage
|
- [x] useBudgetData — lit depuis tRPC (inventaire.getStats)
|
||||||
- [x] Page Budget 2027 — données depuis localStorage
|
- [x] ParametresContext — lit/écrit depuis tRPC (parametres.get, parametres.setBulk)
|
||||||
- [x] Page DSI CAPEX — données depuis localStorage
|
- [x] Page Renouvellement PC (Home.tsx) — données inventaire depuis tRPC via useBudgetData
|
||||||
- [x] Page DSI OPEX — données depuis localStorage
|
- [x] Page Budget 2027 — données CAPEX depuis tRPC (capex.get, capex.save) + inventaire depuis tRPC
|
||||||
- [x] Pages Santinova, Soins Santé, St-Exupéry — données depuis localStorage
|
- [x] Page DSI CAPEX — synthèse CAPEX depuis tRPC
|
||||||
- [x] Contexte Paramètres (seuils, coûts) — localStorage
|
- [x] Page DSI OPEX — données OPEX depuis tRPC (opex.*)
|
||||||
- [x] Contexte Année — localStorage
|
- [x] Pages Santinova, Soins Santé, St-Exupéry — pages "Coming Soon" (pas de données)
|
||||||
|
|
||||||
## Améliorations futures
|
## Améliorations futures
|
||||||
|
|
||||||
- [ ] Migrer les pages Budget2027, DsiCapex, DsiOpex pour lire depuis la BDD via tRPC
|
|
||||||
- [ ] Gestion des droits par établissement (userEtablissements)
|
- [ ] Gestion des droits par établissement (userEtablissements)
|
||||||
- [ ] Export PDF/Excel des budgets
|
- [ ] Export PDF/Excel des budgets
|
||||||
- [ ] Historique des modifications (audit log)
|
- [ ] Historique des modifications (audit log)
|
||||||
|
|||||||
Reference in New Issue
Block a user