feat: ouvre la saisie des logements dans un tableau

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 17:40:11 +02:00
parent fdffe27f06
commit dc5e8b995f
7 changed files with 908 additions and 0 deletions

View File

@@ -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 quaucune valeur nest 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 dusage', () => {
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 sil 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'])
})
})