Checkpoint: Bug corrigé : ajout contrainte UNIQUE (annee, etablissementCode) sur opex_bases_repartition + déduplication des lignes existantes — le clic ne crée plus de doublon. Nouvelle fonctionnalité : colonne "Tout manuel" (toggle) dans l'onglet Clés de répartition — quand activé pour un établissement, le calcul prorata est ignoré et seuls les montants saisis manuellement dans la vue Établissements sont utilisés. Badge ambre visible dans les deux vues.

This commit is contained in:
Manus
2026-06-11 07:57:02 +00:00
parent cfeb5196db
commit 0e1cab9f6c
8 changed files with 992 additions and 11 deletions

View File

@@ -384,6 +384,7 @@ interface BaseRow {
etablissementNom: string | null; etablissementNom: string | null;
baseRepartition: string; baseRepartition: string;
baseRepartitionHep: string | null; baseRepartitionHep: string | null;
modeManuel: boolean;
} }
interface ClesRepartitionViewProps { interface ClesRepartitionViewProps {
@@ -432,10 +433,22 @@ function ClesRepartitionView({ annee, basesRepartitionRaw, isLoadingBases, isErr
etablissementNom: row.etablissementNom, etablissementNom: row.etablissementNom,
baseRepartition, baseRepartition,
baseRepartitionHep, baseRepartitionHep,
modeManuel: row.modeManuel,
}); });
setEditingCell(null); setEditingCell(null);
} }
function toggleModeManuel(row: BaseRow) {
setBaseRepartition.mutate({
annee,
etablissementCode: row.etablissementCode,
etablissementNom: row.etablissementNom,
baseRepartition: parseFloat(row.baseRepartition ?? '0'),
baseRepartitionHep: parseFloat(row.baseRepartitionHep ?? '0'),
modeManuel: !row.modeManuel,
});
}
async function handleFileImport(file: File) { async function handleFileImport(file: File) {
try { try {
const XLSX = await import('xlsx'); const XLSX = await import('xlsx');
@@ -629,12 +642,15 @@ function ClesRepartitionView({ annee, basesRepartitionRaw, isLoadingBases, isErr
<th className="px-4 py-3 text-right font-semibold text-foreground text-xs uppercase tracking-wide w-48"> <th className="px-4 py-3 text-right font-semibold text-foreground text-xs uppercase tracking-wide w-48">
Base répartition {anneeBase} / HEP Base répartition {anneeBase} / HEP
</th> </th>
<th className="px-4 py-3 text-center font-semibold text-foreground text-xs uppercase tracking-wide w-36" title="Si activé, tous les montants OPEX de cet établissement sont saisis manuellement (pas de calcul prorata)">
Tout manuel
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{rows.length === 0 && ( {rows.length === 0 && (
<tr> <tr>
<td colSpan={4} className="px-4 py-12 text-center text-muted-foreground"> <td colSpan={5} className="px-4 py-12 text-center text-muted-foreground">
{basesRepartitionRaw.length === 0 {basesRepartitionRaw.length === 0
? `Aucune base de répartition pour ${annee}. Importez un fichier ou saisissez les valeurs manuellement.` ? `Aucune base de répartition pour ${annee}. Importez un fichier ou saisissez les valeurs manuellement.`
: 'Aucun résultat pour cette recherche.'} : 'Aucun résultat pour cette recherche.'}
@@ -642,9 +658,16 @@ function ClesRepartitionView({ annee, basesRepartitionRaw, isLoadingBases, isErr
</tr> </tr>
)} )}
{rows.map((row, idx) => ( {rows.map((row, idx) => (
<tr key={row.etablissementCode} className={`border-b border-border/50 hover:bg-muted/20 transition-colors ${idx % 2 === 0 ? '' : 'bg-muted/10'}`}> <tr key={row.etablissementCode} className={`border-b border-border/50 hover:bg-muted/20 transition-colors ${row.modeManuel ? 'bg-amber-50/60' : idx % 2 === 0 ? '' : 'bg-muted/10'}`}>
<td className="px-4 py-2.5 font-mono text-xs text-muted-foreground">{row.etablissementCode}</td> <td className="px-4 py-2.5 font-mono text-xs text-muted-foreground">{row.etablissementCode}</td>
<td className="px-4 py-2.5 font-medium text-foreground">{row.etablissementNom ?? row.etablissementCode}</td> <td className="px-4 py-2.5 font-medium text-foreground">
{row.etablissementNom ?? row.etablissementCode}
{row.modeManuel && (
<span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-xs font-semibold bg-amber-100 text-amber-700 border border-amber-200">
Manuel
</span>
)}
</td>
{/* Colonne Base de répartition */} {/* Colonne Base de répartition */}
<td className="px-4 py-2.5 text-right"> <td className="px-4 py-2.5 text-right">
{editingCell?.code === row.etablissementCode && editingCell.col === 'base' ? ( {editingCell?.code === row.etablissementCode && editingCell.col === 'base' ? (
@@ -691,18 +714,48 @@ function ClesRepartitionView({ annee, basesRepartitionRaw, isLoadingBases, isErr
</button> </button>
)} )}
</td> </td>
{/* Colonne Mode Manuel */}
<td className="px-4 py-2.5 text-center">
<button
onClick={() => toggleModeManuel(row)}
disabled={setBaseRepartition.isPending}
title={row.modeManuel
? 'Mode manuel activé — cliquer pour revenir au calcul prorata automatique'
: 'Cliquer pour activer le mode tout manuel (montants OPEX saisis manuellement pour cet établissement)'}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary/30 disabled:opacity-50 ${
row.modeManuel ? 'bg-amber-500' : 'bg-muted-foreground/30'
}`}
>
<span
className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform ${
row.modeManuel ? 'translate-x-4.5' : 'translate-x-0.5'
}`}
/>
</button>
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
<tfoot> <tfoot>
<tr className="bg-muted/40 border-t-2 border-border font-bold"> <tr className="bg-muted/40 border-t-2 border-border font-bold">
<td colSpan={2} className="px-4 py-3 text-sm font-semibold text-foreground">TOTAL {basesRepartitionRaw.length} établissements</td> <td colSpan={2} className="px-4 py-3 text-sm font-semibold text-foreground">TOTAL {basesRepartitionRaw.length} établissements
{basesRepartitionRaw.filter(r => r.modeManuel).length > 0 && (
<span className="ml-2 text-xs font-normal text-amber-600">
({basesRepartitionRaw.filter(r => r.modeManuel).length} en mode manuel)
</span>
)}
</td>
<td className="px-4 py-3 text-right text-sm font-bold text-foreground tabular-nums"> <td className="px-4 py-3 text-right text-sm font-bold text-foreground tabular-nums">
{new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 0 }).format(totalBase)} {new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 0 }).format(totalBase)}
</td> </td>
<td className="px-4 py-3 text-right text-sm font-bold text-orange-600 tabular-nums"> <td className="px-4 py-3 text-right text-sm font-bold text-orange-600 tabular-nums">
{totalHep > 0 ? new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 0 }).format(totalHep) + ' €' : '—'} {totalHep > 0 ? new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 0 }).format(totalHep) + ' €' : '—'}
</td> </td>
<td className="px-4 py-3 text-center text-xs text-muted-foreground">
{basesRepartitionRaw.filter(r => r.modeManuel).length > 0
? `${basesRepartitionRaw.filter(r => r.modeManuel).length} actif${basesRepartitionRaw.filter(r => r.modeManuel).length > 1 ? 's' : ''}`
: '—'}
</td>
</tr> </tr>
</tfoot> </tfoot>
</table> </table>
@@ -1043,12 +1096,14 @@ export default function DsiOpex() {
nom: b.etablissementNom ?? b.etablissementCode, nom: b.etablissementNom ?? b.etablissementCode,
base_repartition: parseFloat(b.baseRepartition ?? '0') || 0, base_repartition: parseFloat(b.baseRepartition ?? '0') || 0,
base_repartition_hep: parseFloat(b.baseRepartitionHep ?? '0') || 0, base_repartition_hep: parseFloat(b.baseRepartitionHep ?? '0') || 0,
modeManuel: b.modeManuel ?? false,
})) }))
: getOpexDataForAnnee(annee).etablissements.map(e => ({ : getOpexDataForAnnee(annee).etablissements.map(e => ({
code: e.code, code: e.code,
nom: e.nom, nom: e.nom,
base_repartition: e.base_repartition, base_repartition: e.base_repartition,
base_repartition_hep: 0, // JSON source n'a pas de base HEP base_repartition_hep: 0, // JSON source n'a pas de base HEP
modeManuel: false,
})); }));
const totalBase = basesFromBdd.reduce((s, e) => s + e.base_repartition, 0); const totalBase = basesFromBdd.reduce((s, e) => s + e.base_repartition, 0);
@@ -1071,7 +1126,11 @@ export default function DsiOpex() {
(ligne.libelleDetail ?? '').toLowerCase().includes('hep')); (ligne.libelleDetail ?? '').toLowerCase().includes('hep'));
const ratioApplique = isHepPoste ? ratioHep : ratio; const ratioApplique = isHepPoste ? ratioHep : ratio;
let montant: number; let montant: number;
if (isManuel && etabOverrides[ligne.libelle] !== undefined) { if (etab.modeManuel) {
// Mode tout manuel : utiliser uniquement les montants saisis manuellement,
// sans aucun calcul prorata — si pas de saisie, le montant est 0
montant = etabOverrides[ligne.libelle] ?? 0;
} else if (isManuel && etabOverrides[ligne.libelle] !== undefined) {
montant = etabOverrides[ligne.libelle]; montant = etabOverrides[ligne.libelle];
} else if (!isManuel && ligne.montant > 0) { } else if (!isManuel && ligne.montant > 0) {
montant = Math.round(ligne.montant * ratioApplique); montant = Math.round(ligne.montant * ratioApplique);
@@ -1707,12 +1766,21 @@ export default function DsiOpex() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{sortedEtabs.map((etab, idx) => ( {sortedEtabs.map((etab, idx) => {
<tr key={etab.code} className={`border-b border-border/50 hover:bg-muted/20 transition-colors ${idx % 2 === 0 ? '' : 'bg-muted/5'}`}> const rowBg = etab.modeManuel ? 'oklch(0.98 0.02 80)' : (idx % 2 === 0 ? 'oklch(1 0 0)' : 'oklch(0.97 0.003 80)');
<td className="px-3 py-2 whitespace-nowrap" style={{ position: 'sticky', left: 0, zIndex: 20, background: idx % 2 === 0 ? 'oklch(1 0 0)' : 'oklch(0.97 0.003 80)', boxShadow: '2px 0 4px -1px rgba(0,0,0,0.12)', width: '110px', minWidth: '110px', maxWidth: '110px' }}> return (
<tr key={etab.code} className={`border-b border-border/50 hover:bg-muted/20 transition-colors ${etab.modeManuel ? 'bg-amber-50/40' : idx % 2 === 0 ? '' : 'bg-muted/5'}`}>
<td className="px-3 py-2 whitespace-nowrap" style={{ position: 'sticky', left: 0, zIndex: 20, background: rowBg, boxShadow: '2px 0 4px -1px rgba(0,0,0,0.12)', width: '110px', minWidth: '110px', maxWidth: '110px' }}>
<span className="font-mono bg-muted px-1.5 py-0.5 rounded text-muted-foreground">{etab.code}</span> <span className="font-mono bg-muted px-1.5 py-0.5 rounded text-muted-foreground">{etab.code}</span>
</td> </td>
<td className="px-3 py-2 font-medium text-foreground whitespace-nowrap overflow-hidden text-ellipsis" style={{ position: 'sticky', left: '110px', zIndex: 20, background: idx % 2 === 0 ? 'oklch(1 0 0)' : 'oklch(0.97 0.003 80)', boxShadow: '2px 0 8px -2px rgba(0,0,0,0.2)', width: '220px', minWidth: '220px', maxWidth: '220px' }} title={etab.nom}>{etab.nom}</td> <td className="px-3 py-2 font-medium text-foreground whitespace-nowrap overflow-hidden text-ellipsis" style={{ position: 'sticky', left: '110px', zIndex: 20, background: rowBg, boxShadow: '2px 0 8px -2px rgba(0,0,0,0.2)', width: '220px', minWidth: '220px', maxWidth: '220px' }} title={etab.nom}>
{etab.nom}
{etab.modeManuel && (
<span className="ml-1.5 inline-flex items-center px-1 py-0.5 rounded text-[10px] font-semibold bg-amber-100 text-amber-700 border border-amber-200" title="Mode tout manuel activé — montants saisis manuellement, pas de calcul prorata">
Manuel
</span>
)}
</td>
{lignes.map(ligne => { {lignes.map(ligne => {
const isManuel = ligne.mode_ventilation === 'Manuel' || ligne.isCustom; const isManuel = ligne.mode_ventilation === 'Manuel' || ligne.isCustom;
const montant = etab.montants[ligne.libelle] ?? 0; const montant = etab.montants[ligne.libelle] ?? 0;
@@ -1770,7 +1838,8 @@ export default function DsiOpex() {
</span> </span>
</td> </td>
</tr> </tr>
))} );
})}
</tbody> </tbody>
<tfoot> <tfoot>
<tr className="bg-muted/40 border-t-2 border-border"> <tr className="bg-muted/40 border-t-2 border-border">

View File

@@ -0,0 +1,2 @@
ALTER TABLE `opex_bases_repartition` ADD `modeManuel` boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE `opex_bases_repartition` ADD CONSTRAINT `opex_bases_repartition_annee_etab_idx` UNIQUE(`annee`,`etablissementCode`);

View File

@@ -0,0 +1,889 @@
{
"version": "5",
"dialect": "mysql",
"id": "44abeed3-e7bf-47cd-a29c-59a27bf57f1a",
"prevId": "baff50d1-531e-4ba7-a82a-6f9c64aa1186",
"tables": {
"capex_lignes": {
"name": "capex_lignes",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"annee": {
"name": "annee",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"etablissementCode": {
"name": "etablissementCode",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"cle": {
"name": "cle",
"type": "varchar(100)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"montant": {
"name": "montant",
"type": "decimal(12,2)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"capex_lignes_id": {
"name": "capex_lignes_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"etablissements": {
"name": "etablissements",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"code": {
"name": "code",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"nom": {
"name": "nom",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"groupe": {
"name": "groupe",
"type": "varchar(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"ville": {
"name": "ville",
"type": "varchar(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"actif": {
"name": "actif",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"etablissements_id": {
"name": "etablissements_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {
"etablissements_code_unique": {
"name": "etablissements_code_unique",
"columns": [
"code"
]
}
},
"checkConstraint": {}
},
"inventaire_meta": {
"name": "inventaire_meta",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"annee": {
"name": "annee",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"filename": {
"name": "filename",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"dateImport": {
"name": "dateImport",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"nbEtablissements": {
"name": "nbEtablissements",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"nbFixes": {
"name": "nbFixes",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"nbPortables": {
"name": "nbPortables",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"inventaire_meta_id": {
"name": "inventaire_meta_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {
"inventaire_meta_annee_unique": {
"name": "inventaire_meta_annee_unique",
"columns": [
"annee"
]
}
},
"checkConstraint": {}
},
"inventaire_postes": {
"name": "inventaire_postes",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"annee": {
"name": "annee",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"etablissementCode": {
"name": "etablissementCode",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"libelle": {
"name": "libelle",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"typePoste": {
"name": "typePoste",
"type": "enum('fixe','portable')",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"dateRef": {
"name": "dateRef",
"type": "varchar(20)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"ageAns": {
"name": "ageAns",
"type": "decimal(5,2)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"modele": {
"name": "modele",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"fabricant": {
"name": "fabricant",
"type": "varchar(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"inventaire_postes_id": {
"name": "inventaire_postes_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"opex_bases_repartition": {
"name": "opex_bases_repartition",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"annee": {
"name": "annee",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"etablissementCode": {
"name": "etablissementCode",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"etablissementNom": {
"name": "etablissementNom",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"baseRepartition": {
"name": "baseRepartition",
"type": "decimal(20,6)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"baseRepartitionHep": {
"name": "baseRepartitionHep",
"type": "decimal(20,6)",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'0'"
},
"modeManuel": {
"name": "modeManuel",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {
"opex_bases_repartition_annee_etab_idx": {
"name": "opex_bases_repartition_annee_etab_idx",
"columns": [
"annee",
"etablissementCode"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"opex_bases_repartition_id": {
"name": "opex_bases_repartition_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"opex_montants_etab": {
"name": "opex_montants_etab",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"annee": {
"name": "annee",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"etablissementCode": {
"name": "etablissementCode",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"libellePoste": {
"name": "libellePoste",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"montant": {
"name": "montant",
"type": "decimal(12,2)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"opex_montants_etab_id": {
"name": "opex_montants_etab_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"opex_postes": {
"name": "opex_postes",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"annee": {
"name": "annee",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"colIdx": {
"name": "colIdx",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"libelle": {
"name": "libelle",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"libelleCourt": {
"name": "libelleCourt",
"type": "varchar(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"libelleDetail": {
"name": "libelleDetail",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"fournisseur": {
"name": "fournisseur",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"categorie": {
"name": "categorie",
"type": "varchar(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"type": {
"name": "type",
"type": "varchar(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"facturation": {
"name": "facturation",
"type": "varchar(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"modeVentilation": {
"name": "modeVentilation",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'Prorata C 6'"
},
"compte": {
"name": "compte",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"detail": {
"name": "detail",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"budgetN1": {
"name": "budgetN1",
"type": "decimal(12,2)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"montant": {
"name": "montant",
"type": "decimal(12,2)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"isCustom": {
"name": "isCustom",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": false
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"opex_postes_id": {
"name": "opex_postes_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"opex_validated": {
"name": "opex_validated",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"annee": {
"name": "annee",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"validatedAt": {
"name": "validatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"validatedBy": {
"name": "validatedBy",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"opex_validated_id": {
"name": "opex_validated_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {
"opex_validated_annee_unique": {
"name": "opex_validated_annee_unique",
"columns": [
"annee"
]
}
},
"checkConstraint": {}
},
"parametres_app": {
"name": "parametres_app",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"cle": {
"name": "cle",
"type": "varchar(100)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"valeur": {
"name": "valeur",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"parametres_app_id": {
"name": "parametres_app_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {
"parametres_app_cle_unique": {
"name": "parametres_app_cle_unique",
"columns": [
"cle"
]
}
},
"checkConstraint": {}
},
"user_etablissements": {
"name": "user_etablissements",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"userId": {
"name": "userId",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"etablissementCode": {
"name": "etablissementCode",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"user_etablissements_id": {
"name": "user_etablissements_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"login": {
"name": "login",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "varchar(320)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"passwordHash": {
"name": "passwordHash",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"firstName": {
"name": "firstName",
"type": "varchar(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"lastName": {
"name": "lastName",
"type": "varchar(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"role": {
"name": "role",
"type": "enum('admin','standard','readonly')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'standard'"
},
"isActive": {
"name": "isActive",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
},
"lastSignedIn": {
"name": "lastSignedIn",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"users_id": {
"name": "users_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {
"users_login_unique": {
"name": "users_login_unique",
"columns": [
"login"
]
}
},
"checkConstraint": {}
}
},
"views": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"tables": {},
"indexes": {}
}
}

View File

@@ -22,6 +22,13 @@
"when": 1781161698458, "when": 1781161698458,
"tag": "0002_faithful_famine", "tag": "0002_faithful_famine",
"breakpoints": true "breakpoints": true
},
{
"idx": 3,
"version": "5",
"when": 1781163262493,
"tag": "0003_tense_havok",
"breakpoints": true
} }
] ]
} }

View File

@@ -6,6 +6,7 @@ import {
mysqlTable, mysqlTable,
text, text,
timestamp, timestamp,
uniqueIndex,
varchar, varchar,
} from "drizzle-orm/mysql-core"; } from "drizzle-orm/mysql-core";
@@ -174,8 +175,12 @@ export const opexBasesRepartition = mysqlTable("opex_bases_repartition", {
baseRepartition: decimal("baseRepartition", { precision: 20, scale: 6 }).notNull(), baseRepartition: decimal("baseRepartition", { precision: 20, scale: 6 }).notNull(),
/** Charges classe 6 — base de répartition pôle HEP uniquement (0 pour les étblissements hors HEP) */ /** Charges classe 6 — base de répartition pôle HEP uniquement (0 pour les étblissements hors HEP) */
baseRepartitionHep: decimal("baseRepartitionHep", { precision: 20, scale: 6 }).default("0"), baseRepartitionHep: decimal("baseRepartitionHep", { precision: 20, scale: 6 }).default("0"),
/** Si true, tous les montants OPEX de cet établissement sont saisis manuellement (pas de calcul prorata) */
modeManuel: boolean("modeManuel").default(false).notNull(),
createdAt: timestamp("createdAt").defaultNow().notNull(), createdAt: timestamp("createdAt").defaultNow().notNull(),
}); }, (t) => ({
uniqAnneeEtab: uniqueIndex("opex_bases_repartition_annee_etab_idx").on(t.annee, t.etablissementCode),
}));
export type OpexBaseRepartition = typeof opexBasesRepartition.$inferSelect; export type OpexBaseRepartition = typeof opexBasesRepartition.$inferSelect;
export type InsertOpexBaseRepartition = typeof opexBasesRepartition.$inferInsert; export type InsertOpexBaseRepartition = typeof opexBasesRepartition.$inferInsert;

View File

@@ -344,6 +344,7 @@ export async function upsertOpexBaseRepartition(input: {
etablissementNom?: string | null; etablissementNom?: string | null;
baseRepartition: number; baseRepartition: number;
baseRepartitionHep?: number; baseRepartitionHep?: number;
modeManuel?: boolean;
}) { }) {
const db = await getDb(); const db = await getDb();
if (!db) throw new Error("Database not available"); if (!db) throw new Error("Database not available");
@@ -355,12 +356,14 @@ export async function upsertOpexBaseRepartition(input: {
etablissementNom: input.etablissementNom ?? null, etablissementNom: input.etablissementNom ?? null,
baseRepartition: String(input.baseRepartition), baseRepartition: String(input.baseRepartition),
baseRepartitionHep: String(input.baseRepartitionHep ?? 0), baseRepartitionHep: String(input.baseRepartitionHep ?? 0),
modeManuel: input.modeManuel ?? false,
}) })
.onDuplicateKeyUpdate({ .onDuplicateKeyUpdate({
set: { set: {
baseRepartition: String(input.baseRepartition), baseRepartition: String(input.baseRepartition),
baseRepartitionHep: String(input.baseRepartitionHep ?? 0), baseRepartitionHep: String(input.baseRepartitionHep ?? 0),
etablissementNom: input.etablissementNom ?? null, etablissementNom: input.etablissementNom ?? null,
modeManuel: input.modeManuel ?? false,
}, },
}); });
} }

View File

@@ -187,6 +187,7 @@ export const appRouter = router({
etablissementNom: z.string().optional().nullable(), etablissementNom: z.string().optional().nullable(),
baseRepartition: z.number(), baseRepartition: z.number(),
baseRepartitionHep: z.number().optional().default(0), baseRepartitionHep: z.number().optional().default(0),
modeManuel: z.boolean().optional().default(false),
})) }))
.mutation(async ({ input }) => { await db.upsertOpexBaseRepartition(input); return { success: true }; }), .mutation(async ({ input }) => { await db.upsertOpexBaseRepartition(input); return { success: true }; }),

View File

@@ -56,3 +56,8 @@
- [x] Ajouter un vrai loading/error state pour l'onglet « Clés de répartition » basé sur la requête `opex.getBasesRepartition` - [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é - [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é
## Bugs et nouvelles fonctionnalités (post-checkpoint cfeb5196)
- [x] Bug : doublon de ligne dans le tableau Clés de répartition au clic (mutation setBaseRepartition provoque un double rendu)
- [x] Fonctionnalité : mode "tout manuel" par établissement dans l'onglet Clés de répartition (toutes les colonnes OPEX de cet établissement saisies manuellement, ignorant le calcul prorata)