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 {
|
||||
annee: number;
|
||||
basesRepartitionRaw: BaseRow[];
|
||||
isLoadingBases: boolean;
|
||||
isErrorBases: boolean;
|
||||
setBaseRepartition: ReturnType<typeof trpc.opex.setBaseRepartition.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 [editingCell, setEditingCell] = useState<{ code: string; col: 'base' | 'hep' } | null>(null);
|
||||
const [editValue, setEditValue] = useState('');
|
||||
@@ -439,33 +441,91 @@ function ClesRepartitionView({ annee, basesRepartitionRaw, setBaseRepartition, i
|
||||
const XLSX = await import('xlsx');
|
||||
const buf = await file.arrayBuffer();
|
||||
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[][];
|
||||
// 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;
|
||||
for (let i = 0; i < Math.min(data.length, 10); i++) {
|
||||
const row = data[i].map(c => String(c).toLowerCase());
|
||||
if (row.some(c => c.includes('code') || c.includes('établissement') || c.includes('etab'))) {
|
||||
for (let i = 0; i < Math.min(data.length, 15); i++) {
|
||||
const row = data[i].map(c => String(c).toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, ''));
|
||||
if (row.some(c =>
|
||||
c.includes('code') ||
|
||||
c.includes('etablissement') ||
|
||||
c.includes('etab') ||
|
||||
c.includes('structure')
|
||||
)) {
|
||||
headerIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (headerIdx === -1) { toast.error('Format non reconnu', { description: 'Impossible de trouver la ligne d\'en-tête (code établissement)' }); return; }
|
||||
const headers = data[headerIdx].map(c => String(c).toLowerCase());
|
||||
if (headerIdx === -1) {
|
||||
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 nomIdx = headers.findIndex(h => h.includes('nom') || h.includes('établissement') || h.includes('etab'));
|
||||
const baseIdx = headers.findIndex(h => (h.includes('base') || h.includes('répartition') || h.includes('repartition')) && !h.includes('hep'));
|
||||
const nomIdx = headers.findIndex(h =>
|
||||
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'));
|
||||
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)
|
||||
.filter(row => row[codeIdx] && String(row[codeIdx]).trim())
|
||||
.map(row => ({
|
||||
etablissementCode: String(row[codeIdx]).trim(),
|
||||
etablissementNom: nomIdx >= 0 ? String(row[nomIdx]).trim() || null : null,
|
||||
baseRepartition: parseFloat(String(row[baseIdx]).replace(/[^0-9.]/g, '')) || 0,
|
||||
baseRepartitionHep: hepIdx >= 0 ? parseFloat(String(row[hepIdx]).replace(/[^0-9.]/g, '')) || 0 : 0,
|
||||
baseRepartition: parseFloat(String(row[baseIdx]).replace(/[^0-9.,]/g, '').replace(',', '.')) || 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 });
|
||||
} catch (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) + ' €';
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
{/* Bandeau d'information */}
|
||||
@@ -646,7 +725,7 @@ export default function DsiOpex() {
|
||||
const { data: montantsEtabRaw, isLoading: loadingMontants } = trpc.opex.getMontantsEtab.useQuery({ annee });
|
||||
const { data: validatedRow } = trpc.opex.getValidated.useQuery({ annee });
|
||||
// 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 ─────────────────────────────────────────────────────────
|
||||
const upsertPoste = trpc.opex.upsertPoste.useMutation({
|
||||
@@ -1731,6 +1810,8 @@ export default function DsiOpex() {
|
||||
<ClesRepartitionView
|
||||
annee={annee}
|
||||
basesRepartitionRaw={basesRepartitionRaw ?? []}
|
||||
isLoadingBases={isLoadingBases}
|
||||
isErrorBases={isErrorBases}
|
||||
setBaseRepartition={setBaseRepartition}
|
||||
importBasesRepartition={importBasesRepartition}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user