Checkpoint: Corrections des deux lacunes identifiées dans l'onglet "Clés de répartition" de DSI OPEX :
1. Loading/error state : ClesRepartitionView reçoit désormais isLoadingBases et isErrorBases depuis la requête trpc.opex.getBasesRepartition.useQuery(). Affiche un spinner pendant le chargement et un message d'erreur si la requête échoue (au lieu d'un tableau vide trompeur). 2. Import fichier amélioré : détection intelligente de la feuille (mots-clés OPEX/DSI/répartition/base, insensible aux accents), recherche de l'en-tête dans les 15 premières lignes, normalisation des accents pour la détection des colonnes, message d'erreur détaillé avec les colonnes détectées, toast informatif si plusieurs feuilles présentes. 14 tests Vitest passants. 0 erreur TypeScript.
This commit is contained in:
@@ -389,11 +389,13 @@ interface BaseRow {
|
|||||||
interface ClesRepartitionViewProps {
|
interface ClesRepartitionViewProps {
|
||||||
annee: number;
|
annee: number;
|
||||||
basesRepartitionRaw: BaseRow[];
|
basesRepartitionRaw: BaseRow[];
|
||||||
|
isLoadingBases: boolean;
|
||||||
|
isErrorBases: boolean;
|
||||||
setBaseRepartition: ReturnType<typeof trpc.opex.setBaseRepartition.useMutation>;
|
setBaseRepartition: ReturnType<typeof trpc.opex.setBaseRepartition.useMutation>;
|
||||||
importBasesRepartition: ReturnType<typeof trpc.opex.importBasesRepartition.useMutation>;
|
importBasesRepartition: ReturnType<typeof trpc.opex.importBasesRepartition.useMutation>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ClesRepartitionView({ annee, basesRepartitionRaw, setBaseRepartition, importBasesRepartition }: ClesRepartitionViewProps) {
|
function ClesRepartitionView({ annee, basesRepartitionRaw, isLoadingBases, isErrorBases, setBaseRepartition, importBasesRepartition }: ClesRepartitionViewProps) {
|
||||||
const anneeBase = annee - 2; // La base de répartition correspond à l'année OPEX - 2
|
const anneeBase = annee - 2; // La base de répartition correspond à l'année OPEX - 2
|
||||||
const [editingCell, setEditingCell] = useState<{ code: string; col: 'base' | 'hep' } | null>(null);
|
const [editingCell, setEditingCell] = useState<{ code: string; col: 'base' | 'hep' } | null>(null);
|
||||||
const [editValue, setEditValue] = useState('');
|
const [editValue, setEditValue] = useState('');
|
||||||
@@ -439,33 +441,91 @@ function ClesRepartitionView({ annee, basesRepartitionRaw, setBaseRepartition, i
|
|||||||
const XLSX = await import('xlsx');
|
const XLSX = await import('xlsx');
|
||||||
const buf = await file.arrayBuffer();
|
const buf = await file.arrayBuffer();
|
||||||
const wb = XLSX.read(buf, { type: 'array' });
|
const wb = XLSX.read(buf, { type: 'array' });
|
||||||
const ws = wb.Sheets[wb.SheetNames[0]];
|
|
||||||
|
// ── Sélection intelligente de la feuille ──────────────────────────────
|
||||||
|
// Priorité : feuille dont le nom contient OPEX, DSI, répartition ou base
|
||||||
|
// (insensible à la casse et aux accents). Sinon, première feuille.
|
||||||
|
const SHEET_KEYWORDS = ['opex', 'dsi', 'repartition', 'répartition', 'base'];
|
||||||
|
const selectedSheetName =
|
||||||
|
wb.SheetNames.find(name => {
|
||||||
|
const lower = name.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '');
|
||||||
|
return SHEET_KEYWORDS.some(kw => lower.includes(kw));
|
||||||
|
}) ?? wb.SheetNames[0];
|
||||||
|
|
||||||
|
const ws = wb.Sheets[selectedSheetName];
|
||||||
|
|
||||||
|
// Informer l'utilisateur de la feuille utilisée si le classeur en contient plusieurs
|
||||||
|
if (wb.SheetNames.length > 1) {
|
||||||
|
toast.info(`Feuille utilisée : « ${selectedSheetName} »`, {
|
||||||
|
description: `${wb.SheetNames.length} feuilles disponibles dans le classeur.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const data: string[][] = XLSX.utils.sheet_to_json(ws, { header: 1, defval: '' }) as string[][];
|
const data: string[][] = XLSX.utils.sheet_to_json(ws, { header: 1, defval: '' }) as string[][];
|
||||||
// Trouver la ligne d'en-tête
|
|
||||||
|
// ── Détection de la ligne d'en-tête ──────────────────────────────────
|
||||||
|
// Cherche dans les 15 premières lignes une ligne contenant un code établissement
|
||||||
let headerIdx = -1;
|
let headerIdx = -1;
|
||||||
for (let i = 0; i < Math.min(data.length, 10); i++) {
|
for (let i = 0; i < Math.min(data.length, 15); i++) {
|
||||||
const row = data[i].map(c => String(c).toLowerCase());
|
const row = data[i].map(c => String(c).toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, ''));
|
||||||
if (row.some(c => c.includes('code') || c.includes('établissement') || c.includes('etab'))) {
|
if (row.some(c =>
|
||||||
|
c.includes('code') ||
|
||||||
|
c.includes('etablissement') ||
|
||||||
|
c.includes('etab') ||
|
||||||
|
c.includes('structure')
|
||||||
|
)) {
|
||||||
headerIdx = i;
|
headerIdx = i;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (headerIdx === -1) { toast.error('Format non reconnu', { description: 'Impossible de trouver la ligne d\'en-tête (code établissement)' }); return; }
|
if (headerIdx === -1) {
|
||||||
const headers = data[headerIdx].map(c => String(c).toLowerCase());
|
toast.error('Format non reconnu', {
|
||||||
|
description: `Impossible de trouver la ligne d'en-tête dans la feuille « ${selectedSheetName} ». Colonnes attendues : code établissement, base de répartition.`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Détection des colonnes ────────────────────────────────────────────
|
||||||
|
const headers = data[headerIdx].map(c =>
|
||||||
|
String(c).toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '')
|
||||||
|
);
|
||||||
const codeIdx = headers.findIndex(h => h.includes('code'));
|
const codeIdx = headers.findIndex(h => h.includes('code'));
|
||||||
const nomIdx = headers.findIndex(h => h.includes('nom') || h.includes('établissement') || h.includes('etab'));
|
const nomIdx = headers.findIndex(h =>
|
||||||
const baseIdx = headers.findIndex(h => (h.includes('base') || h.includes('répartition') || h.includes('repartition')) && !h.includes('hep'));
|
h.includes('nom') || h.includes('etablissement') || h.includes('etab') || h.includes('libelle')
|
||||||
|
);
|
||||||
|
// Colonne base standard : contient 'base' ou 'repartition' mais PAS 'hep'
|
||||||
|
const baseIdx = headers.findIndex(h =>
|
||||||
|
(h.includes('base') || h.includes('repartition')) && !h.includes('hep')
|
||||||
|
);
|
||||||
|
// Colonne base HEP : contient 'hep'
|
||||||
const hepIdx = headers.findIndex(h => h.includes('hep'));
|
const hepIdx = headers.findIndex(h => h.includes('hep'));
|
||||||
if (codeIdx === -1 || baseIdx === -1) { toast.error('Colonnes manquantes', { description: 'Le fichier doit contenir au minimum les colonnes : code établissement, base de répartition' }); return; }
|
|
||||||
|
if (codeIdx === -1 || baseIdx === -1) {
|
||||||
|
toast.error('Colonnes manquantes', {
|
||||||
|
description: `Le fichier doit contenir au minimum les colonnes : code établissement, base de répartition. Colonnes détectées : ${headers.filter(Boolean).join(', ') || '(aucune)'}`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Construction des lignes à importer ───────────────────────────────
|
||||||
const importRows = data.slice(headerIdx + 1)
|
const importRows = data.slice(headerIdx + 1)
|
||||||
.filter(row => row[codeIdx] && String(row[codeIdx]).trim())
|
.filter(row => row[codeIdx] && String(row[codeIdx]).trim())
|
||||||
.map(row => ({
|
.map(row => ({
|
||||||
etablissementCode: String(row[codeIdx]).trim(),
|
etablissementCode: String(row[codeIdx]).trim(),
|
||||||
etablissementNom: nomIdx >= 0 ? String(row[nomIdx]).trim() || null : null,
|
etablissementNom: nomIdx >= 0 ? String(row[nomIdx]).trim() || null : null,
|
||||||
baseRepartition: parseFloat(String(row[baseIdx]).replace(/[^0-9.]/g, '')) || 0,
|
baseRepartition: parseFloat(String(row[baseIdx]).replace(/[^0-9.,]/g, '').replace(',', '.')) || 0,
|
||||||
baseRepartitionHep: hepIdx >= 0 ? parseFloat(String(row[hepIdx]).replace(/[^0-9.]/g, '')) || 0 : 0,
|
baseRepartitionHep: hepIdx >= 0
|
||||||
|
? parseFloat(String(row[hepIdx]).replace(/[^0-9.,]/g, '').replace(',', '.')) || 0
|
||||||
|
: 0,
|
||||||
}));
|
}));
|
||||||
if (importRows.length === 0) { toast.error('Aucune ligne importée', { description: 'Le fichier ne contient aucune ligne de données valide' }); return; }
|
|
||||||
|
if (importRows.length === 0) {
|
||||||
|
toast.error('Aucune ligne importée', {
|
||||||
|
description: 'Le fichier ne contient aucune ligne de données valide après la ligne d\'en-tête.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
importBasesRepartition.mutate({ annee, rows: importRows });
|
importBasesRepartition.mutate({ annee, rows: importRows });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error('Erreur lecture fichier', { description: String(e) });
|
toast.error('Erreur lecture fichier', { description: String(e) });
|
||||||
@@ -478,6 +538,25 @@ function ClesRepartitionView({ annee, basesRepartitionRaw, setBaseRepartition, i
|
|||||||
return new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 0 }).format(n) + ' €';
|
return new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 0 }).format(n) + ' €';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isLoadingBases) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 gap-3 text-muted-foreground">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||||
|
<p className="text-sm">Chargement des bases de répartition…</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isErrorBases) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 gap-3">
|
||||||
|
<AlertCircle className="w-8 h-8 text-destructive" />
|
||||||
|
<p className="text-sm font-semibold text-destructive">Erreur lors du chargement des bases de répartition</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Vérifiez votre connexion et rechargez la page.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full overflow-hidden">
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
{/* Bandeau d'information */}
|
{/* Bandeau d'information */}
|
||||||
@@ -646,7 +725,7 @@ export default function DsiOpex() {
|
|||||||
const { data: montantsEtabRaw, isLoading: loadingMontants } = trpc.opex.getMontantsEtab.useQuery({ annee });
|
const { data: montantsEtabRaw, isLoading: loadingMontants } = trpc.opex.getMontantsEtab.useQuery({ annee });
|
||||||
const { data: validatedRow } = trpc.opex.getValidated.useQuery({ annee });
|
const { data: validatedRow } = trpc.opex.getValidated.useQuery({ annee });
|
||||||
// Bases de répartition depuis la BDD (charges classe 6 par établissement)
|
// Bases de répartition depuis la BDD (charges classe 6 par établissement)
|
||||||
const { data: basesRepartitionRaw } = trpc.opex.getBasesRepartition.useQuery({ annee });
|
const { data: basesRepartitionRaw, isLoading: isLoadingBases, isError: isErrorBases } = trpc.opex.getBasesRepartition.useQuery({ annee });
|
||||||
|
|
||||||
// ── Mutations tRPC ─────────────────────────────────────────────────────────
|
// ── Mutations tRPC ─────────────────────────────────────────────────────────
|
||||||
const upsertPoste = trpc.opex.upsertPoste.useMutation({
|
const upsertPoste = trpc.opex.upsertPoste.useMutation({
|
||||||
@@ -1731,6 +1810,8 @@ export default function DsiOpex() {
|
|||||||
<ClesRepartitionView
|
<ClesRepartitionView
|
||||||
annee={annee}
|
annee={annee}
|
||||||
basesRepartitionRaw={basesRepartitionRaw ?? []}
|
basesRepartitionRaw={basesRepartitionRaw ?? []}
|
||||||
|
isLoadingBases={isLoadingBases}
|
||||||
|
isErrorBases={isErrorBases}
|
||||||
setBaseRepartition={setBaseRepartition}
|
setBaseRepartition={setBaseRepartition}
|
||||||
importBasesRepartition={importBasesRepartition}
|
importBasesRepartition={importBasesRepartition}
|
||||||
/>
|
/>
|
||||||
|
|||||||
21
todo.md
21
todo.md
@@ -43,11 +43,16 @@
|
|||||||
|
|
||||||
## Onglet Clés de répartition dans DSI OPEX
|
## Onglet Clés de répartition dans DSI OPEX
|
||||||
|
|
||||||
- [ ] Analyser le fichier source pour extraire les colonnes base_repartition et base_repartition_hep
|
- [x] Analyser le fichier source pour extraire les colonnes base_repartition et base_repartition_hep
|
||||||
- [ ] Ajouter colonne base_repartition_hep dans la table opex_bases_repartition + migration
|
- [x] Ajouter colonne base_repartition_hep dans la table opex_bases_repartition + migration
|
||||||
- [ ] Routes tRPC : opex.getBasesRepartition (mise à jour), opex.setBaseRepartition (mise à jour avec hep), opex.importBasesRepartition
|
- [x] Routes tRPC : opex.getBasesRepartition (mise à jour), opex.setBaseRepartition (mise à jour avec hep), opex.importBasesRepartition
|
||||||
- [ ] Seeder les données base_repartition_hep 2026 depuis le fichier source
|
- [x] Seeder les données base_repartition_hep 2026 depuis le fichier source
|
||||||
- [ ] Créer l'onglet "Clés de répartition" dans DsiOpex avec tableau éditable (base standard + /HEP)
|
- [x] Créer l'onglet "Clés de répartition" dans DsiOpex avec tableau éditable (base standard + /HEP)
|
||||||
- [ ] Import fichier Excel/CSV dans l'onglet Clés de répartition
|
- [x] Import fichier Excel/CSV dans l'onglet Clés de répartition
|
||||||
- [ ] Afficher l'info "Année de la base = année OPEX - 2" dans l'onglet
|
- [x] Afficher l'info "Année de la base = année OPEX - 2" dans l'onglet
|
||||||
- [ ] Brancher le calcul du poste "DUI pôle HEP" sur base_repartition_hep
|
- [x] Brancher le calcul du poste "DUI pôle HEP" sur base_repartition_hep
|
||||||
|
|
||||||
|
## Corrections de lacunes (post-checkpoint 841ff1bd)
|
||||||
|
|
||||||
|
- [x] Ajouter un vrai loading/error state pour l'onglet « Clés de répartition » basé sur la requête `opex.getBasesRepartition`
|
||||||
|
- [x] Rendre l'import des clés de répartition compatible avec le classeur source réel : détection intelligente de la bonne feuille (mots-clés OPEX/DSI/répartition/base), normalisation des accents, validation des colonnes attendues avec message d'erreur détaillé
|
||||||
|
|||||||
Reference in New Issue
Block a user