From 2304724ba6a8750dcfb649a6aca1ec98066fe47c Mon Sep 17 00:00:00 2001 From: Manus Date: Wed, 3 Jun 2026 16:12:34 +0000 Subject: [PATCH] =?UTF-8?q?Checkpoint:=20Refonte=20compl=C3=A8te=20de=20la?= =?UTF-8?q?=20page=20DSI=20OPEX=20:=20grille=20de=20vignettes=20dynamique?= =?UTF-8?q?=20pour=20toutes=20les=20cat=C3=A9gories=20(App=20global,=20Inf?= =?UTF-8?q?og=C3=A9rance,=20S=C3=A9curit=C3=A9,=20T=C3=A9l=C3=A9phonie,=20?= =?UTF-8?q?App=20HEP,=20App=20SMR,=20Autre),=20montants=20=C3=A9ditables?= =?UTF-8?q?=20inline,=20boutons=20Modifier/Supprimer=20par=20ligne,=20moda?= =?UTF-8?q?le=20d'ajout/=C3=A9dition=20compl=C3=A8te,=20confirmation=20de?= =?UTF-8?q?=20suppression,=20persistance=20par=20ann=C3=A9e=20en=20localSt?= =?UTF-8?q?orage,=20validation=20d=C3=A9finitive=20avec=20verrouillage.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/pages/DsiOpex.tsx | 721 ++++++++++++++++++++++++----------- 1 file changed, 501 insertions(+), 220 deletions(-) diff --git a/client/src/pages/DsiOpex.tsx b/client/src/pages/DsiOpex.tsx index aa968b2..43b8a76 100644 --- a/client/src/pages/DsiOpex.tsx +++ b/client/src/pages/DsiOpex.tsx @@ -1,6 +1,5 @@ -// DsiOpex.tsx — OPEX DSI : Charges du Système d'Information // Design: Corporate Modernism — Itinova Budget SI -// Montants prévisionnels éditables par poste, persistance par année, validation définitive (verrouillage) +// OPEX DSI : vignettes par catégorie, CRUD lignes, persistance par année, validation définitive import { useState, useMemo, useEffect, useCallback } from 'react'; import { @@ -21,6 +20,10 @@ import { CheckCircle2, PencilLine, RotateCcw, + Plus, + Pencil, + Trash2, + X, } from 'lucide-react'; import { AppSidebar } from '../components/AppSidebar'; import { AnneeSelectorBar } from '../components/AnneeSelectorBar'; @@ -38,6 +41,13 @@ import { AlertDialogTitle, AlertDialogTrigger, } from '@/components/ui/alert-dialog'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog'; import { toast } from 'sonner'; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -73,27 +83,48 @@ interface OpexData { meta: { nb_postes: number; nb_etablissements: number }; } +// Ligne persistée (peut être source ou ajoutée par l'utilisateur) +interface LigneOpex { + id: string; // identifiant stable + libelle: string; + fournisseur: string; + categorie: string; + type: string; + facturation: string; + compte: string; + detail: string; + budget_n1: number; + montant: number; // montant prévisionnel éditable + isCustom: boolean; // true = ajoutée par l'utilisateur +} + interface OpexAnneeState { - montants: Record; // clé = libelle poste, valeur = montant saisi - validated: boolean; // true = verrouillé définitivement - savedAt?: string; // ISO date de la dernière sauvegarde - validatedAt?: string; // ISO date de validation + lignes: LigneOpex[]; + validated: boolean; + savedAt?: string; + validatedAt?: string; } // ─── Constantes ─────────────────────────────────────────────────────────────── const opexData = opexRaw as OpexData; -const CATEGORIES = ['Toutes', 'App global', 'Infogérance', 'Sécurité', 'Téléphonie', 'App HEP', 'App SMR']; -const CATEGORIE_COLORS: Record = { - 'App global': 'bg-blue-100 text-blue-700', - 'Infogérance': 'bg-purple-100 text-purple-700', - 'Sécurité': 'bg-red-100 text-red-700', - 'Téléphonie': 'bg-green-100 text-green-700', - 'App HEP': 'bg-orange-100 text-orange-700', - 'App SMR': 'bg-teal-100 text-teal-700', +const CATEGORIES_FIXES = ['App global', 'Infogérance', 'Sécurité', 'Téléphonie', 'App HEP', 'App SMR']; + +const CATEGORIE_COLORS: Record = { + 'App global': { badge: 'bg-blue-100 text-blue-700', bar: 'bg-blue-500', kpi: 'text-blue-600', icon: 'text-blue-500' }, + 'Infogérance': { badge: 'bg-purple-100 text-purple-700', bar: 'bg-purple-500', kpi: 'text-purple-600', icon: 'text-purple-500' }, + 'Sécurité': { badge: 'bg-red-100 text-red-700', bar: 'bg-red-500', kpi: 'text-red-600', icon: 'text-red-500' }, + 'Téléphonie': { badge: 'bg-green-100 text-green-700', bar: 'bg-green-500', kpi: 'text-green-600', icon: 'text-green-500' }, + 'App HEP': { badge: 'bg-orange-100 text-orange-700', bar: 'bg-orange-500', kpi: 'text-orange-600', icon: 'text-orange-500' }, + 'App SMR': { badge: 'bg-teal-100 text-teal-700', bar: 'bg-teal-500', kpi: 'text-teal-600', icon: 'text-teal-500' }, + 'Autre': { badge: 'bg-gray-100 text-gray-600', bar: 'bg-gray-400', kpi: 'text-gray-600', icon: 'text-gray-400' }, }; +function getCatColors(cat: string) { + return CATEGORIE_COLORS[cat] ?? CATEGORIE_COLORS['Autre']; +} + // ─── Helpers ────────────────────────────────────────────────────────────────── function formatNum(v: number | null | undefined): string { @@ -101,38 +132,44 @@ function formatNum(v: number | null | undefined): string { return new Intl.NumberFormat('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 0 }).format(v) + ' €'; } -function formatNumShort(v: number): string { - if (v === 0) return '—'; - if (v >= 1000) return new Intl.NumberFormat('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 0 }).format(Math.round(v)) + ' €'; - return Math.round(v) + ' €'; +function buildLignesFromSource(): LigneOpex[] { + return opexData.postes.map(p => ({ + id: `src-${p.col_idx}`, + libelle: p.libelle, + fournisseur: p.fournisseur ?? '', + categorie: p.categorie ?? 'Autre', + type: p.type ?? '', + facturation: p.facturation ?? '', + compte: p.compte ?? '', + detail: p.detail ?? '', + budget_n1: p.budget_n1 ?? 0, + montant: p.montant_previsionnel_2026 ?? 0, + isCustom: false, + })); } -// Montant de base (depuis le JSON source) pour un poste -function getMontantBase(poste: Poste): number { - return poste.montant_previsionnel_2026 ?? 0; -} - -// Charger l'état OPEX depuis localStorage pour une année function loadOpexState(annee: number): OpexAnneeState { try { const raw = localStorage.getItem(getOpexStorageKey(annee)); - if (raw) return JSON.parse(raw) as OpexAnneeState; + if (raw) { + const parsed = JSON.parse(raw) as OpexAnneeState; + // Compatibilité ascendante : si l'ancien format (montants dict) est détecté + if (parsed.lignes && Array.isArray(parsed.lignes)) return parsed; + } } catch { /* ignore */ } - // Valeur par défaut : montants initialisés depuis le JSON - const montants: Record = {}; - for (const p of opexData.postes) { - montants[p.libelle] = getMontantBase(p); - } - return { montants, validated: false }; + return { lignes: buildLignesFromSource(), validated: false }; } -// Sauvegarder l'état OPEX dans localStorage function saveOpexState(annee: number, state: OpexAnneeState): void { try { localStorage.setItem(getOpexStorageKey(annee), JSON.stringify(state)); } catch { /* ignore */ } } +function genId(): string { + return `custom-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; +} + // ─── Composant champ numérique ───────────────────────────────────────────── function MoneyInput({ @@ -165,7 +202,7 @@ function MoneyInput({ onChange(isNaN(v) || v < 0 ? 0 : Math.round(v)); }} onBlur={() => setRaw(value === 0 ? '' : String(value))} - className={`w-full pl-6 pr-8 py-1.5 text-xs border rounded-md focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary tabular-nums transition-colors text-right ${ + className={`w-full pl-6 pr-2 py-1.5 text-xs border rounded-md focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary tabular-nums transition-colors text-right ${ disabled ? 'bg-muted/30 text-muted-foreground cursor-not-allowed border-border/40' : 'bg-background border-border hover:border-primary/50' @@ -176,6 +213,152 @@ function MoneyInput({ ); } +// ─── Formulaire ajout/édition ────────────────────────────────────────────── + +const EMPTY_LIGNE: Omit = { + libelle: '', + fournisseur: '', + categorie: 'App global', + type: '', + facturation: '', + compte: '', + detail: '', + budget_n1: 0, + montant: 0, +}; + +function LigneForm({ + initial, + onSave, + onClose, +}: { + initial: Omit; + onSave: (data: Omit) => void; + onClose: () => void; +}) { + const [form, setForm] = useState(initial); + const set = (k: keyof typeof form, v: string | number) => + setForm(prev => ({ ...prev, [k]: v })); + + const allCats = [...CATEGORIES_FIXES, 'Autre']; + + return ( +
+
+
+ + set('libelle', e.target.value)} + placeholder="Ex : Maintenance serveurs" + className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30" + /> +
+
+ + set('fournisseur', e.target.value)} + placeholder="Ex : Microsoft" + className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30" + /> +
+
+ + +
+
+ + set('type', e.target.value)} + placeholder="Ex : Licence, Maintenance…" + className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30" + /> +
+
+ + set('facturation', e.target.value)} + placeholder="Ex : Annuelle, Mensuelle…" + className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30" + /> +
+
+ + set('compte', e.target.value)} + placeholder="Ex : 6156" + className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30" + /> +
+
+ + set('budget_n1', parseFloat(e.target.value) || 0)} + placeholder="0" + className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30" + /> +
+
+ + set('montant', parseFloat(e.target.value) || 0)} + placeholder="0" + className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30" + /> +
+
+ +