diff --git a/client/src/pages/DsiOpex.tsx b/client/src/pages/DsiOpex.tsx index 784213c..159d307 100644 --- a/client/src/pages/DsiOpex.tsx +++ b/client/src/pages/DsiOpex.tsx @@ -394,9 +394,11 @@ interface ClesRepartitionViewProps { isErrorBases: boolean; setBaseRepartition: ReturnType; importBasesRepartition: ReturnType; + batchSetMontantsEtab: ReturnType; + etablissementsRecalcules: Array<{ code: string; nom: string; montants: Record; total: number }>; } -function ClesRepartitionView({ annee, basesRepartitionRaw, isLoadingBases, isErrorBases, setBaseRepartition, importBasesRepartition }: ClesRepartitionViewProps) { +function ClesRepartitionView({ annee, basesRepartitionRaw, isLoadingBases, isErrorBases, setBaseRepartition, importBasesRepartition, batchSetMontantsEtab, etablissementsRecalcules }: 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,13 +441,44 @@ function ClesRepartitionView({ annee, basesRepartitionRaw, isLoadingBases, isErr } function toggleModeManuel(row: BaseRow) { + const activating = !row.modeManuel; + if (activating) { + // Avant d'activer le mode manuel, on pré-remplit les montants avec les valeurs + // calculées automatiquement (prorata) pour cet établissement + const etabCalc = etablissementsRecalcules.find(e => e.code === row.etablissementCode); + if (etabCalc && Object.keys(etabCalc.montants).length > 0) { + const montantsASeeder = Object.entries(etabCalc.montants) + .filter(([, v]) => v > 0) + .map(([libellePoste, v]) => ({ libellePoste, montant: String(v) })); + if (montantsASeeder.length > 0) { + batchSetMontantsEtab.mutate( + { annee, etablissementCode: row.etablissementCode, montants: montantsASeeder }, + { + onSettled: () => { + // Activer le flag modeManuel après la sauvegarde des montants + setBaseRepartition.mutate({ + annee, + etablissementCode: row.etablissementCode, + etablissementNom: row.etablissementNom, + baseRepartition: parseFloat(row.baseRepartition ?? '0'), + baseRepartitionHep: parseFloat(row.baseRepartitionHep ?? '0'), + modeManuel: true, + }); + }, + } + ); + return; + } + } + } + // Cas normal : désactivation ou activation sans montants à seeder setBaseRepartition.mutate({ annee, etablissementCode: row.etablissementCode, etablissementNom: row.etablissementNom, baseRepartition: parseFloat(row.baseRepartition ?? '0'), baseRepartitionHep: parseFloat(row.baseRepartitionHep ?? '0'), - modeManuel: !row.modeManuel, + modeManuel: activating, }); } @@ -818,6 +851,10 @@ export default function DsiOpex() { onSuccess: (data) => { utils.opex.getBasesRepartition.invalidate({ annee }); toast.success(`${data.count} bases de répartition importées`); }, onError: (err) => toast.error('Erreur import', { description: err.message }), }); + const batchSetMontantsEtab = trpc.opex.batchSetMontantsEtab.useMutation({ + onSuccess: () => utils.opex.getMontantsEtab.invalidate({ annee }), + onError: (err) => toast.error('Erreur sauvegarde montants', { description: err.message }), + }); // ── Mapper les données BDD vers le format interne ────────────────────────── const lignes: LigneOpex[] = useMemo(() => { @@ -1904,6 +1941,8 @@ export default function DsiOpex() { isErrorBases={isErrorBases} setBaseRepartition={setBaseRepartition} importBasesRepartition={importBasesRepartition} + batchSetMontantsEtab={batchSetMontantsEtab} + etablissementsRecalcules={etablissementsRecalcules} /> )} diff --git a/scripts/list-non-standard-codes.mjs b/scripts/list-non-standard-codes.mjs new file mode 100644 index 0000000..e18474a --- /dev/null +++ b/scripts/list-non-standard-codes.mjs @@ -0,0 +1,25 @@ +import { createConnection } from 'mysql2/promise'; +import { config } from 'dotenv'; +config(); + +const conn = await createConnection(process.env.DATABASE_URL); + +// Lister toutes les lignes dont le code n'est pas un code établissement standard (7 chiffres + 2 lettres) +const [rows] = await conn.execute( + `SELECT id, annee, etablissementCode, etablissementNom, baseRepartition + FROM opex_bases_repartition + ORDER BY etablissementCode` +); + +console.log('=== Codes non-standard (parasites potentiels) ==='); +for (const r of rows) { + const code = r.etablissementCode ?? ''; + // Code standard = 7 chiffres + 2 lettres majuscules (ex: 1001BPT) + const isStandard = /^\d{4,7}[A-Z]{2,3}$/.test(code); + if (!isStandard) { + console.log(`id=${r.id} annee=${r.annee} code="${code}" nom="${r.etablissementNom}" base=${r.baseRepartition}`); + } +} + +console.log(`\nTotal lignes: ${rows.length}`); +await conn.end(); diff --git a/server/routers.ts b/server/routers.ts index 396207a..9cbdbc0 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -140,6 +140,28 @@ export const appRouter = router({ .input(z.object({ annee: z.number(), etablissementCode: z.string(), libellePoste: z.string(), montant: z.string().nullable() })) .mutation(async ({ input }) => { await db.setOpexMontantEtab(input as Parameters[0]); return { success: true }; }), + // Sauvegarder en masse les montants par établissement (utilisé lors du passage en mode tout manuel) + batchSetMontantsEtab: writeProcedure + .input(z.object({ + annee: z.number(), + etablissementCode: z.string(), + montants: z.array(z.object({ + libellePoste: z.string(), + montant: z.string(), + })) + })) + .mutation(async ({ input }) => { + for (const m of input.montants) { + await db.setOpexMontantEtab({ + annee: input.annee, + etablissementCode: input.etablissementCode, + libellePoste: m.libellePoste, + montant: m.montant, + }); + } + return { success: true, count: input.montants.length }; + }), + getValidated: protectedProcedure .input(z.object({ annee: z.number() })) .query(async ({ input }) => db.getOpexValidated(input.annee)),