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:
@@ -384,6 +384,7 @@ interface BaseRow {
|
||||
etablissementNom: string | null;
|
||||
baseRepartition: string;
|
||||
baseRepartitionHep: string | null;
|
||||
modeManuel: boolean;
|
||||
}
|
||||
|
||||
interface ClesRepartitionViewProps {
|
||||
@@ -432,10 +433,22 @@ function ClesRepartitionView({ annee, basesRepartitionRaw, isLoadingBases, isErr
|
||||
etablissementNom: row.etablissementNom,
|
||||
baseRepartition,
|
||||
baseRepartitionHep,
|
||||
modeManuel: row.modeManuel,
|
||||
});
|
||||
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) {
|
||||
try {
|
||||
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">
|
||||
Base répartition {anneeBase} / HEP
|
||||
</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>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 && (
|
||||
<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
|
||||
? `Aucune base de répartition pour ${annee}. Importez un fichier ou saisissez les valeurs manuellement.`
|
||||
: 'Aucun résultat pour cette recherche.'}
|
||||
@@ -642,9 +658,16 @@ function ClesRepartitionView({ annee, basesRepartitionRaw, isLoadingBases, isErr
|
||||
</tr>
|
||||
)}
|
||||
{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-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 */}
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
{editingCell?.code === row.etablissementCode && editingCell.col === 'base' ? (
|
||||
@@ -691,18 +714,48 @@ function ClesRepartitionView({ annee, basesRepartitionRaw, isLoadingBases, isErr
|
||||
</button>
|
||||
)}
|
||||
</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>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<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">
|
||||
{new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 0 }).format(totalBase)} €
|
||||
</td>
|
||||
<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) + ' €' : '—'}
|
||||
</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>
|
||||
</tfoot>
|
||||
</table>
|
||||
@@ -1043,12 +1096,14 @@ export default function DsiOpex() {
|
||||
nom: b.etablissementNom ?? b.etablissementCode,
|
||||
base_repartition: parseFloat(b.baseRepartition ?? '0') || 0,
|
||||
base_repartition_hep: parseFloat(b.baseRepartitionHep ?? '0') || 0,
|
||||
modeManuel: b.modeManuel ?? false,
|
||||
}))
|
||||
: getOpexDataForAnnee(annee).etablissements.map(e => ({
|
||||
code: e.code,
|
||||
nom: e.nom,
|
||||
base_repartition: e.base_repartition,
|
||||
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);
|
||||
@@ -1071,7 +1126,11 @@ export default function DsiOpex() {
|
||||
(ligne.libelleDetail ?? '').toLowerCase().includes('hep'));
|
||||
const ratioApplique = isHepPoste ? ratioHep : ratio;
|
||||
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];
|
||||
} else if (!isManuel && ligne.montant > 0) {
|
||||
montant = Math.round(ligne.montant * ratioApplique);
|
||||
@@ -1707,12 +1766,21 @@ export default function DsiOpex() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{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'}`}>
|
||||
<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' }}>
|
||||
{sortedEtabs.map((etab, idx) => {
|
||||
const rowBg = etab.modeManuel ? 'oklch(0.98 0.02 80)' : (idx % 2 === 0 ? 'oklch(1 0 0)' : 'oklch(0.97 0.003 80)');
|
||||
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>
|
||||
</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 => {
|
||||
const isManuel = ligne.mode_ventilation === 'Manuel' || ligne.isCustom;
|
||||
const montant = etab.montants[ligne.libelle] ?? 0;
|
||||
@@ -1770,7 +1838,8 @@ export default function DsiOpex() {
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="bg-muted/40 border-t-2 border-border">
|
||||
|
||||
2
drizzle/0003_tense_havok.sql
Normal file
2
drizzle/0003_tense_havok.sql
Normal 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`);
|
||||
889
drizzle/meta/0003_snapshot.json
Normal file
889
drizzle/meta/0003_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,13 @@
|
||||
"when": 1781161698458,
|
||||
"tag": "0002_faithful_famine",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "5",
|
||||
"when": 1781163262493,
|
||||
"tag": "0003_tense_havok",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
mysqlTable,
|
||||
text,
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
varchar,
|
||||
} from "drizzle-orm/mysql-core";
|
||||
|
||||
@@ -174,8 +175,12 @@ export const opexBasesRepartition = mysqlTable("opex_bases_repartition", {
|
||||
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) */
|
||||
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(),
|
||||
});
|
||||
}, (t) => ({
|
||||
uniqAnneeEtab: uniqueIndex("opex_bases_repartition_annee_etab_idx").on(t.annee, t.etablissementCode),
|
||||
}));
|
||||
|
||||
export type OpexBaseRepartition = typeof opexBasesRepartition.$inferSelect;
|
||||
export type InsertOpexBaseRepartition = typeof opexBasesRepartition.$inferInsert;
|
||||
|
||||
@@ -344,6 +344,7 @@ export async function upsertOpexBaseRepartition(input: {
|
||||
etablissementNom?: string | null;
|
||||
baseRepartition: number;
|
||||
baseRepartitionHep?: number;
|
||||
modeManuel?: boolean;
|
||||
}) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
@@ -355,12 +356,14 @@ export async function upsertOpexBaseRepartition(input: {
|
||||
etablissementNom: input.etablissementNom ?? null,
|
||||
baseRepartition: String(input.baseRepartition),
|
||||
baseRepartitionHep: String(input.baseRepartitionHep ?? 0),
|
||||
modeManuel: input.modeManuel ?? false,
|
||||
})
|
||||
.onDuplicateKeyUpdate({
|
||||
set: {
|
||||
baseRepartition: String(input.baseRepartition),
|
||||
baseRepartitionHep: String(input.baseRepartitionHep ?? 0),
|
||||
etablissementNom: input.etablissementNom ?? null,
|
||||
modeManuel: input.modeManuel ?? false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -187,6 +187,7 @@ export const appRouter = router({
|
||||
etablissementNom: z.string().optional().nullable(),
|
||||
baseRepartition: z.number(),
|
||||
baseRepartitionHep: z.number().optional().default(0),
|
||||
modeManuel: z.boolean().optional().default(false),
|
||||
}))
|
||||
.mutation(async ({ input }) => { await db.upsertOpexBaseRepartition(input); return { success: true }; }),
|
||||
|
||||
|
||||
5
todo.md
5
todo.md
@@ -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] 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)
|
||||
|
||||
Reference in New Issue
Block a user