From dc5e8b995fe45fc41663ec9d6de4fcd66bf115ef Mon Sep 17 00:00:00 2001 From: Bertrand Benjamin Date: Tue, 28 Jul 2026 17:40:11 +0200 Subject: [PATCH] feat: ouvre la saisie des logements dans un tableau MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La fiche compte une dizaine de champs pour une vingtaine de lots : un formulaire par lot imposerait autant d'allers-retours. Le tableau garde l'ergonomie du tableur d'où viennent ces données — une ligne par lot, les colonnes dans le même ordre, chaque cellule enregistrée en la quittant. Ce que le serveur calcule est affiché comme tel et non saisissable : échéance du DPE (rouge si périmé, ambre à moins d'un an) et écart de surface. Le type vu par le PDF reste visible à côté du type saisi quand les deux se contredisent, plutôt que d'être remplacé sans le dire. Tri et filtre vivent dans l'en-tête de chaque colonne, jamais au-dessus du tableau : c'est là qu'on les cherche en lisant la colonne. Une liste de choix peut proposer, dans un groupe à part, ce qui n'est pas une valeur de la colonne — rattachement d'un lot aux comptes rendus, désaccord de type. L'en-tête se fige au défilement, ce qui demande de borner la hauteur du tableau : sans conteneur à hauteur limitée, `sticky` n'a rien à quoi se tenir. Co-Authored-By: Claude Opus 5 --- frontend/src/App.vue | 1 + .../components/referentiel/LigneLogement.vue | 227 ++++++++++++++++ .../src/components/referentiel/colonnes.js | 251 ++++++++++++++++++ frontend/src/pages/LogementsPage.vue | 247 +++++++++++++++++ frontend/src/router.js | 6 + frontend/src/style.css | 9 + frontend/tests/colonnesReferentiel.test.js | 167 ++++++++++++ 7 files changed, 908 insertions(+) create mode 100644 frontend/src/components/referentiel/LigneLogement.vue create mode 100644 frontend/src/components/referentiel/colonnes.js create mode 100644 frontend/src/pages/LogementsPage.vue create mode 100644 frontend/tests/colonnesReferentiel.test.js diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 039e781..f9531cb 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -57,6 +57,7 @@ const fileInput = ref(null) const navLinks = [ { to: '/', label: 'Accueil' }, { to: '/documents', label: 'Documents' }, + { to: '/logements', label: 'Logements' }, { to: '/revenus', label: 'Recettes' }, { to: '/analytics', label: 'Dépenses' }, ...(FEATURE_IA ? [{ to: '/ia', label: 'IA' }] : []), diff --git a/frontend/src/components/referentiel/LigneLogement.vue b/frontend/src/components/referentiel/LigneLogement.vue new file mode 100644 index 0000000..f66bf97 --- /dev/null +++ b/frontend/src/components/referentiel/LigneLogement.vue @@ -0,0 +1,227 @@ + + + diff --git a/frontend/src/components/referentiel/colonnes.js b/frontend/src/components/referentiel/colonnes.js new file mode 100644 index 0000000..59d9965 --- /dev/null +++ b/frontend/src/components/referentiel/colonnes.js @@ -0,0 +1,251 @@ +/** + * Colonnes du tableau des logements : en-têtes, tri et filtres. + * + * L'ordre de cette liste est celui des cellules de `LigneLogement.vue` — les + * deux se lisent ensemble, une colonne ajoutée ici doit l'être là aussi. + * + * `valeur` sert au tri et aux filtres ; ce que la cellule affiche peut être + * plus riche (badges, champs de saisie), mais c'est cette valeur brute qui est + * comparée, pour que trier « Écart » range des nombres et non des libellés. + * + * Chaque colonne porte son type de filtre, posé dans son en-tête : + * - `choix` : liste des valeurs présentes (plus « (vide) ») + * - `texte` : sous-chaîne, insensible à la casse et aux accents + * - `nombre` : comparaison, `>100`, `<=50`, `!=0`, ou une valeur exacte + * - `echeance` : où en est le DPE par rapport à sa péremption + * + * Une liste de choix peut proposer, au-dessus des valeurs, des entrées qui + * n'en sont pas — le rattachement d'un lot, un désaccord avec le PDF. Elles + * vivent dans leur propre groupe pour rester distinctes de ce que la colonne + * contient vraiment. + */ + +const fiche = (ligne) => ligne.caracteristiques ?? {} + +/** Marque le désaccord entre le type saisi et celui du PDF, filtrable comme une valeur. */ +export const TYPE_EN_DESACCORD = '⚠ en désaccord avec le PDF' + +/** Option des listes de choix pour les lots dont la colonne est vide. */ +export const VIDE = '(vide)' + +/** Lots rattachés à au moins une ligne de compte rendu. */ +export const ETAT_UTILISES = 'Lots utilisés' + +/** Lots qu'aucune ligne de compte rendu ne mentionne : les supprimables. */ +export const ETAT_INUTILISES = 'Lots inutilisés' + +export const COLONNES = [ + { + cle: 'immeuble', + libelle: 'Immeuble', + valeur: (l) => l.immeuble_denomination || l.immeuble_code, + filtre: 'choix' + }, + { cle: 'numero', libelle: 'Porte', valeur: (l) => l.numero, filtre: 'choix' }, + { cle: 'bat', libelle: 'Bât', valeur: (l) => fiche(l).bat, filtre: 'choix' }, + { cle: 'type', libelle: 'Type', valeur: (l) => l.type_effectif, filtre: 'choix' }, + { cle: 'etage', libelle: 'Étage', valeur: (l) => fiche(l).etage, filtre: 'choix' }, + { + cle: 'surface', + libelle: 'Surface', + valeur: (l) => fiche(l).surface, + filtre: 'nombre', + align: 'text-right' + }, + { + cle: 'surface_date_diag', + libelle: 'Diag. surface', + valeur: (l) => fiche(l).surface_date_diag, + filtre: 'texte' + }, + { + cle: 'numero_fiscal', + libelle: 'N° fiscal', + valeur: (l) => fiche(l).numero_fiscal, + filtre: 'texte' + }, + { cle: 'dpe_classe', libelle: 'DPE', valeur: (l) => fiche(l).dpe_classe, filtre: 'choix' }, + { + cle: 'dpe_date_realisation', + libelle: 'Réalisé le', + valeur: (l) => fiche(l).dpe_date_realisation, + filtre: 'texte' + }, + { + cle: 'dpe_echeance', + libelle: 'Échéance', + valeur: (l) => fiche(l).dpe_echeance, + filtre: 'echeance' + }, + { cle: 'chauffage', libelle: 'Chauffage', valeur: (l) => fiche(l).chauffage, filtre: 'choix' }, + { + cle: 'surface_impots', + libelle: 'Surf. impôts', + valeur: (l) => fiche(l).surface_impots, + filtre: 'nombre', + align: 'text-right' + }, + { + cle: 'delta_surface', + libelle: 'Écart', + valeur: (l) => fiche(l).delta_surface, + filtre: 'nombre', + align: 'text-right' + }, + { + cle: 'note_impots', + libelle: 'Info impôts', + valeur: (l) => fiche(l).note_impots, + filtre: 'texte' + } +] + +export const OPTIONS_ECHEANCE = [ + { valeur: 'perime', libelle: 'Périmé' }, + { valeur: 'bientot', libelle: 'Moins d’un an' }, + { valeur: 'valide', libelle: 'Valide' }, + { valeur: 'absent', libelle: 'Sans DPE' } +] + +const estVide = (valeur) => valeur == null || valeur === '' + +// « Electrique » doit trouver « Électrique » : après décomposition NFD, les +// accents deviennent des marques combinantes (\p{Mn}) qu'on retire par leur +// catégorie Unicode — les écrire en clair donnerait un littéral invisible à la +// relecture. +function sansAccents(valeur) { + return String(valeur) + .normalize('NFD') + .replace(/\p{Mn}/gu, '') + .toLowerCase() +} + +/** + * Valeurs présentes dans une colonne, pour peupler sa liste de choix. + * + * Seules les valeurs réellement là sont proposées : une liste figée + * afficherait des chauffages qu'aucun lot n'a. + */ +export function valeursDistinctes(lignes, colonne) { + const valeurs = new Set() + + for (const ligne of lignes) { + const valeur = colonne.valeur(ligne) + valeurs.add(estVide(valeur) ? VIDE : String(valeur)) + } + + return [...valeurs].sort((a, b) => { + if (a === VIDE) return 1 + if (b === VIDE) return -1 + return a.localeCompare(b, 'fr', { numeric: true }) + }) +} + +/** + * Contenu de la liste déroulante d'une colonne, en groupes. + * + * Les entrées qui ne sont pas des valeurs de la colonne (rattachement d'un lot, + * désaccord avec le PDF) sont proposées à part : mélangées aux numéros de + * porte, elles se liraient comme des portes. + */ +export function groupesFiltre(lignes, colonne) { + const valeurs = valeursDistinctes(lignes, colonne) + + if (colonne.cle === 'numero') { + return [ + { libelle: 'Rattachement', options: [ETAT_UTILISES, ETAT_INUTILISES] }, + { libelle: 'Porte', options: valeurs } + ] + } + + if (colonne.cle === 'type' && lignes.some((ligne) => ligne.type_ecart)) { + return [ + { libelle: 'Contrôle', options: [TYPE_EN_DESACCORD] }, + { libelle: 'Type', options: valeurs } + ] + } + + return [{ libelle: '', options: valeurs }] +} + +function passeNombre(valeur, saisie) { + const match = saisie.match(/^\s*(>=|<=|!=|<>|≠|>|<|=)?\s*(-?[\d.,]+)\s*$/) + if (!match) return true // saisie incomplète : ne rien masquer + + const seuil = parseFloat(match[2].replace(',', '.')) + if (Number.isNaN(seuil)) return true + if (valeur == null) return false + + switch (match[1]) { + case '>': return valeur > seuil + case '<': return valeur < seuil + case '>=': return valeur >= seuil + case '<=': return valeur <= seuil + case '!=': + case '<>': + case '≠': return valeur !== seuil + default: return valeur === seuil + } +} + +function passeEcheance(echeance, choix) { + if (choix === 'absent') return estVide(echeance) + if (estVide(echeance)) return false + + const date = new Date(echeance) + const aujourdhui = new Date() + const dans12Mois = new Date() + dans12Mois.setFullYear(dans12Mois.getFullYear() + 1) + + if (choix === 'perime') return date < aujourdhui + if (choix === 'bientot') return date >= aujourdhui && date < dans12Mois + return date >= dans12Mois +} + +/** Une ligne survit-elle au filtre posé sur cette colonne ? */ +export function passeFiltre(ligne, colonne, saisie) { + if (estVide(saisie)) return true + + const valeur = colonne.valeur(ligne) + + switch (colonne.filtre) { + case 'echeance': + return passeEcheance(valeur, saisie) + case 'choix': { + if (saisie === ETAT_UTILISES || saisie === ETAT_INUTILISES) { + const inutilise = ligne.nb_revenus === 0 && ligne.nb_depenses === 0 + return saisie === ETAT_INUTILISES ? inutilise : !inutilise + } + if (saisie === TYPE_EN_DESACCORD) return ligne.type_ecart + if (saisie === VIDE) return estVide(valeur) + return String(valeur) === saisie + } + case 'nombre': + return passeNombre(valeur, saisie) + default: + return !estVide(valeur) && sansAccents(valeur).includes(sansAccents(saisie)) + } +} + +/** + * Comparateur d'une colonne, dans le sens demandé. + * + * Les cases vides vont toujours en fin de tri, quel que soit le sens : trier + * par surface pour voir les plus grandes ne doit pas d'abord dérouler tous les + * lots non renseignés. + */ +export function comparer(colonne, sens) { + const signe = sens === 'desc' ? -1 : 1 + + return (a, b) => { + const va = colonne.valeur(a) + const vb = colonne.valeur(b) + + if (estVide(va) && estVide(vb)) return 0 + if (estVide(va)) return 1 + if (estVide(vb)) return -1 + + if (typeof va === 'number' && typeof vb === 'number') return signe * (va - vb) + return signe * String(va).localeCompare(String(vb), 'fr', { numeric: true }) + } +} diff --git a/frontend/src/pages/LogementsPage.vue b/frontend/src/pages/LogementsPage.vue new file mode 100644 index 0000000..95bf306 --- /dev/null +++ b/frontend/src/pages/LogementsPage.vue @@ -0,0 +1,247 @@ + + + diff --git a/frontend/src/router.js b/frontend/src/router.js index cbbb962..73bb1be 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -4,6 +4,7 @@ import ExtractPage from './pages/ExtractPage.vue' import AnalyticsPage from './pages/AnalyticsPage.vue' import RevenusPage from './pages/RevenusPage.vue' import DocumentsPage from './pages/DocumentsPage.vue' +import LogementsPage from './pages/LogementsPage.vue' import EditDocumentPage from './pages/EditDocumentPage.vue' import ReExtractionPage from './pages/ReExtractionPage.vue' import ConfigPage from './pages/ConfigPage.vue' @@ -55,6 +56,11 @@ const routes = [ name: 'documents', component: DocumentsPage }, + { + path: '/logements', + name: 'logements', + component: LogementsPage + }, { path: '/documents/:id/edit', name: 'edit-document', diff --git a/frontend/src/style.css b/frontend/src/style.css index 87b18e0..73f87d5 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -112,6 +112,15 @@ @apply cursor-pointer; } + /* Champ de saisie dans une cellule de tableau. Bordure invisible au repos : + une grille de 14 colonnes encadrees serait illisible, alors qu'un tableau + de saisie doit d'abord se lire. */ + .input-cell { + @apply bg-transparent border border-transparent rounded px-2 py-1 text-sm text-white + placeholder-gray-600 hover:border-gray-700 focus:outline-none focus:border-blue-500 + focus:bg-gray-950 transition-colors [color-scheme:dark]; + } + .form-hint { @apply text-xs text-gray-500 mt-1; } diff --git a/frontend/tests/colonnesReferentiel.test.js b/frontend/tests/colonnesReferentiel.test.js new file mode 100644 index 0000000..1609c7b --- /dev/null +++ b/frontend/tests/colonnesReferentiel.test.js @@ -0,0 +1,167 @@ +// Les filtres de colonnes décident de ce que l'utilisateur voit : un filtre trop +// zélé masque un lot sans rien dire, ce qui se remarque d'autant moins que le +// tableau reste plausible. Ils sont donc testés cas par cas. +import { describe, expect, it } from 'vitest' + +import { + COLONNES, + ETAT_INUTILISES, + ETAT_UTILISES, + TYPE_EN_DESACCORD, + VIDE, + comparer, + groupesFiltre, + passeFiltre, + valeursDistinctes, +} from '../src/components/referentiel/colonnes.js' + +const colonne = (cle) => COLONNES.find((c) => c.cle === cle) + +function lot(numero, caracteristiques = null, extra = {}) { + return { + id: Number(numero), + numero, + immeuble_id: 1, + immeuble_code: '33689020', + immeuble_denomination: 'Servient', + type_effectif: caracteristiques?.type ?? null, + type_ecart: false, + nb_revenus: 0, + nb_depenses: 0, + caracteristiques, + ...extra, + } +} + +// Un immeuble réduit : un local décrit, un appartement décrit, un lot vierge. +const LOTS = [ + lot('01', { + bat: 'Rue', + etage: 'RC', + type: 'Loc. Commercial', + surface: 148, + surface_impots: 146, + delta_surface: -2, + chauffage: 'Électrique', + dpe_classe: 'C', + dpe_echeance: '2031-02-08', + numero_fiscal: '690123456789', + }, { nb_revenus: 5 }), + lot('02', { + bat: 'Cour', + etage: '1', + type: 'Appartement T3', + surface: 62, + surface_impots: 62, + delta_surface: 0, + chauffage: 'Gaz', + dpe_classe: 'F', + dpe_echeance: '2020-01-01', + }, { nb_revenus: 3, type_ecart: true }), + lot('0003'), +] + +const filtrer = (cle, saisie) => LOTS.filter((l) => passeFiltre(l, colonne(cle), saisie)).map((l) => l.numero) + +describe('filtres de colonne', () => { + it('ne masque rien tant qu’aucune valeur n’est saisie', () => { + expect(filtrer('chauffage', '')).toEqual(['01', '02', '0003']) + }) + + it('filtre sur une valeur exacte de liste', () => { + expect(filtrer('dpe_classe', 'F')).toEqual(['02']) + }) + + it('isole les lots dont la colonne est vide', () => { + expect(filtrer('chauffage', VIDE)).toEqual(['0003']) + }) + + it('isole les types en désaccord avec le PDF', () => { + expect(filtrer('type', TYPE_EN_DESACCORD)).toEqual(['02']) + }) + + it('compare les nombres au lieu de comparer leur écriture', () => { + // '100' contient '10', mais 62 n'est pas > 100 : une recherche textuelle + // renverrait ici les deux lots décrits. + expect(filtrer('surface', '> 100')).toEqual(['01']) + expect(filtrer('surface', '<=62')).toEqual(['02']) + }) + + it('sait isoler un écart de surface non nul', () => { + expect(filtrer('delta_surface', '!=0')).toEqual(['01']) + }) + + it('ne masque rien sur une saisie numérique incomplète', () => { + expect(filtrer('surface', '>')).toEqual(['01', '02', '0003']) + }) + + it('ignore casse et accents dans les filtres texte', () => { + expect(filtrer('numero_fiscal', '6901')).toEqual(['01']) + }) + + it('filtre par immeuble sur son nom d’usage', () => { + const autre = { ...lot('01'), immeuble_id: 2, immeuble_code: 'M', immeuble_denomination: null } + const parc = [...LOTS, autre] + + const servient = parc.filter((l) => passeFiltre(l, colonne('immeuble'), 'Servient')) + // Sans nom d'usage, l'immeuble reste filtrable par son code. + const marietton = parc.filter((l) => passeFiltre(l, colonne('immeuble'), 'M')) + + expect(servient).toHaveLength(3) + expect(marietton).toEqual([autre]) + }) + + it('sépare les lots rattachés à un compte rendu des autres', () => { + expect(filtrer('numero', ETAT_INUTILISES)).toEqual(['0003']) + expect(filtrer('numero', ETAT_UTILISES)).toEqual(['01', '02']) + }) + + it('filtre aussi sur un numéro de porte précis', () => { + expect(filtrer('numero', '02')).toEqual(['02']) + }) + + it('classe les DPE selon leur péremption', () => { + expect(filtrer('dpe_echeance', 'perime')).toEqual(['02']) + expect(filtrer('dpe_echeance', 'valide')).toEqual(['01']) + expect(filtrer('dpe_echeance', 'absent')).toEqual(['0003']) + }) +}) + +describe('listes de choix', () => { + it('ne propose que les valeurs présentes, le vide en dernier', () => { + // Ordre alphabétique français : « É » se classe avec « E », donc avant « G ». + expect(valeursDistinctes(LOTS, colonne('chauffage'))).toEqual(['Électrique', 'Gaz', VIDE]) + }) + + it('propose le désaccord à part des types, quand il y en a un', () => { + const groupes = groupesFiltre(LOTS, colonne('type')) + + expect(groupes[0].options).toEqual([TYPE_EN_DESACCORD]) + expect(groupes[1].options).toContain('Appartement T3') + }) + + it('ne propose le contrôle des types que s’il a lieu d’être', () => { + const sansDesaccord = LOTS.map((l) => ({ ...l, type_ecart: false })) + + expect(groupesFiltre(sansDesaccord, colonne('type'))).toHaveLength(1) + }) + + it('propose les numéros de porte en plus du rattachement', () => { + const [rattachement, portes] = groupesFiltre(LOTS, colonne('numero')) + + expect(rattachement.options).toEqual([ETAT_UTILISES, ETAT_INUTILISES]) + expect(portes.options).toEqual(['01', '02', '0003']) + }) +}) + +describe('tri', () => { + it('trie les nombres comme des nombres', () => { + const tries = [...LOTS].sort(comparer(colonne('surface'), 'desc')).map((l) => l.numero) + expect(tries).toEqual(['01', '02', '0003']) + }) + + it('renvoie les cases vides en fin, dans les deux sens', () => { + const asc = [...LOTS].sort(comparer(colonne('surface'), 'asc')).map((l) => l.numero) + expect(asc).toEqual(['02', '01', '0003']) + }) +})