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

@@ -57,6 +57,7 @@ const fileInput = ref(null)
const navLinks = [ const navLinks = [
{ to: '/', label: 'Accueil' }, { to: '/', label: 'Accueil' },
{ to: '/documents', label: 'Documents' }, { to: '/documents', label: 'Documents' },
{ to: '/logements', label: 'Logements' },
{ to: '/revenus', label: 'Recettes' }, { to: '/revenus', label: 'Recettes' },
{ to: '/analytics', label: 'Dépenses' }, { to: '/analytics', label: 'Dépenses' },
...(FEATURE_IA ? [{ to: '/ia', label: 'IA' }] : []), ...(FEATURE_IA ? [{ to: '/ia', label: 'IA' }] : []),

View File

@@ -0,0 +1,227 @@
<template>
<tr :class="{ 'bg-gray-800/30': etat === 'enregistrement' }">
<!-- Nom d'usage de l'immeuble : saisi ici faute d'un écran à lui, et
répercuté sur toutes ses lignes puisqu'il ne décrit pas le lot. -->
<td class="whitespace-nowrap">
<input
v-model="nomImmeuble"
class="input-cell w-32 text-white"
:placeholder="ligne.immeuble_code"
:title="`Nom d'usage de l'immeuble ${ligne.immeuble_code} : le modifier vaut pour tous ses lots`"
@change="renommerImmeuble"
/>
</td>
<!-- Porte : identifiant du lot, non modifiable ici -->
<td class="font-mono text-white whitespace-nowrap">
{{ ligne.numero }}
<!-- Un lot que rien ne rattache à un compte rendu ne décrit rien : il
s'efface d'ici, sinon il encombre la saisie à chaque ligne. -->
<button v-if="inutilise"
class="badge badge-neutral ml-2 hover:bg-red-500/20 hover:text-red-400 transition-colors"
:title="`Aucune ligne de compte rendu ne renvoie au lot ${ligne.numero}.\nCliquer pour le supprimer du référentiel.`"
@click="demanderSuppression">
inutilisé
</button>
<span v-else class="badge badge-info ml-2 whitespace-nowrap" :title="detailRattachements">
{{ resumeRattachements }}
</span>
</td>
<td><input v-model="fiche.bat" class="input-cell w-20" placeholder="—" @change="enregistrer" /></td>
<td>
<div class="flex items-center gap-1">
<input v-model="fiche.type" class="input-cell w-40" :placeholder="ligne.type_extrait || '—'" @change="enregistrer" />
<span v-if="ligne.type_ecart"
class="badge badge-warning whitespace-nowrap"
:title="`Le PDF annonce « ${ligne.type_extrait} »`">
PDF : {{ ligne.type_extrait }}
</span>
</div>
</td>
<td><input v-model="fiche.etage" class="input-cell w-16" placeholder="—" @change="enregistrer" /></td>
<td>
<input v-model.number="fiche.surface" type="number" min="0" step="0.01"
class="input-cell w-24 text-right" placeholder="—" @change="enregistrer" />
</td>
<td>
<input v-model="fiche.surface_date_diag" type="date" class="input-cell w-36" @change="enregistrer" />
</td>
<td>
<input v-model="fiche.numero_fiscal" class="input-cell w-36" placeholder="—" @change="enregistrer" />
</td>
<td>
<select v-model="fiche.dpe_classe" class="input-cell w-16" @change="enregistrer">
<option :value="null"></option>
<option v-for="classe in DPE_CLASSES" :key="classe" :value="classe">{{ classe }}</option>
</select>
</td>
<td>
<input v-model="fiche.dpe_date_realisation" type="date" class="input-cell w-36" @change="enregistrer" />
</td>
<!-- Échéance : déduite de la date de réalisation, jamais saisie -->
<td class="whitespace-nowrap">
<span v-if="!derives.dpe_echeance" class="text-gray-600"></span>
<span v-else class="badge" :class="badgeEcheance">{{ formatDate(derives.dpe_echeance) }}</span>
</td>
<td><input v-model="fiche.chauffage" class="input-cell w-32" placeholder="—" @change="enregistrer" /></td>
<td>
<input v-model.number="fiche.surface_impots" type="number" min="0" step="0.01"
class="input-cell w-24 text-right" placeholder="—" @change="enregistrer" />
</td>
<!-- Écart de surface : calculé, affiché même nul pour dire que le rapprochement est fait -->
<td class="text-right whitespace-nowrap">
<span v-if="derives.delta_surface == null" class="text-gray-600"></span>
<span v-else class="badge" :class="derives.delta_surface === 0 ? 'badge-success' : 'badge-warning'">
{{ formatDelta(derives.delta_surface) }}
</span>
</td>
<td>
<input v-model="fiche.note_impots" class="input-cell w-56" placeholder="—" @change="enregistrer" />
</td>
<td class="w-8 text-center">
<span v-if="etat === 'enregistrement'" class="spinner w-3 h-3 align-middle" title="Enregistrement"></span>
<span v-else-if="etat === 'ok'" class="text-green-400" title="Enregistré"></span>
<span v-else-if="etat === 'erreur'" class="text-red-400 cursor-help" :title="erreur">!</span>
</td>
</tr>
</template>
<script setup>
import { reactive, ref, computed, watch } from 'vue'
import { formatDate } from '../../utils/format'
const API = import.meta.env.VITE_API_URL || ''
const DPE_CLASSES = ['A', 'B', 'C', 'D', 'E', 'F', 'G']
//: Champs envoyés au serveur. L'ordre suit celui du tableau de saisie.
const CHAMPS = [
'bat', 'etage', 'type', 'surface', 'surface_date_diag', 'chauffage',
'dpe_classe', 'dpe_date_realisation', 'numero_fiscal', 'surface_impots', 'note_impots'
]
const props = defineProps({
ligne: { type: Object, required: true }
})
const emit = defineEmits(['enregistree', 'supprimer', 'renommer-immeuble'])
const nomImmeuble = ref(props.ligne.immeuble_denomination || '')
watch(
() => props.ligne.immeuble_denomination,
(denomination) => { nomImmeuble.value = denomination || '' }
)
// Le renommage remonte : il touche toutes les lignes du même immeuble, donc il
// appartient au tableau et non à celle qui l'a déclenché.
function renommerImmeuble() {
emit('renommer-immeuble', { immeubleId: props.ligne.immeuble_id, denomination: nomImmeuble.value })
}
const inutilise = computed(() => props.ligne.nb_revenus === 0 && props.ligne.nb_depenses === 0)
function accorder(nombre, singulier, pluriel = `${singulier}s`) {
return `${nombre} ${nombre > 1 ? pluriel : singulier}`
}
// Le badge dit ce que les comptes rendus rattachent à ce lot. Abrégé, il ne
// voulait rien dire : « 11 rev. » se lit aussi bien comme onze euros.
const resumeRattachements = computed(() => {
const parties = [accorder(props.ligne.nb_revenus, 'revenu')]
if (props.ligne.nb_depenses) parties.push(accorder(props.ligne.nb_depenses, 'dépense'))
return parties.join(' · ')
})
const detailRattachements = computed(() => {
const revenus = accorder(props.ligne.nb_revenus, 'ligne de revenus', 'lignes de revenus')
const depenses = accorder(props.ligne.nb_depenses, 'dépense')
return (
`Les comptes rendus rattachent ${revenus} et ${depenses} au lot ${props.ligne.numero}.\n` +
'Un lot rattaché ne peut pas être supprimé : ses montants partiraient avec lui.'
)
})
// La suppression remonte : la confirmation et l'appel appartiennent au tableau,
// qui seul peut retirer la ligne de la liste.
function demanderSuppression() {
emit('supprimer', props.ligne)
}
const fiche = reactive(Object.fromEntries(CHAMPS.map((champ) => [champ, null])))
const etat = ref('')
const erreur = ref('')
// Valeurs calculées par le serveur : elles ne se devinent pas côté client, on
// affiche celles de la dernière réponse.
const derives = computed(() => ({
dpe_echeance: props.ligne.caracteristiques?.dpe_echeance ?? null,
delta_surface: props.ligne.caracteristiques?.delta_surface ?? null
}))
watch(
() => props.ligne.caracteristiques,
(caracteristiques) => {
for (const champ of CHAMPS) fiche[champ] = caracteristiques?.[champ] ?? null
},
{ immediate: true }
)
// Un DPE périmé interdit de relouer sans le refaire : il doit sauter aux yeux
// avant l'échéance, pas le jour où le locataire part.
const badgeEcheance = computed(() => {
const echeance = new Date(derives.value.dpe_echeance)
const dans12Mois = new Date()
dans12Mois.setFullYear(dans12Mois.getFullYear() + 1)
if (echeance < new Date()) return 'badge-danger'
if (echeance < dans12Mois) return 'badge-warning'
return 'badge-neutral'
})
function formatDelta(valeur) {
const signe = valeur > 0 ? '+' : ''
return `${signe}${valeur}`
}
async function enregistrer() {
etat.value = 'enregistrement'
erreur.value = ''
try {
const response = await fetch(`${API}/api/lots/${props.ligne.id}/caracteristiques`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(Object.fromEntries(CHAMPS.map((champ) => [champ, fiche[champ] === '' ? null : fiche[champ]])))
})
if (!response.ok) {
const detail = await response.json().catch(() => null)
throw new Error(detail?.detail?.[0]?.msg || detail?.detail || 'Enregistrement refusé')
}
emit('enregistree', await response.json())
etat.value = 'ok'
setTimeout(() => { if (etat.value === 'ok') etat.value = '' }, 2000)
} catch (e) {
// La saisie reste à l'écran : l'effacer ferait perdre la valeur tapée.
etat.value = 'erreur'
erreur.value = e.message
}
}
</script>

View File

@@ -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 dun 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 })
}
}

View File

@@ -0,0 +1,247 @@
<template>
<div class="page">
<div class="page-content">
<div class="page-header">
<div>
<h1 class="page-title">Logements</h1>
<p class="page-subtitle">
Ce que les comptes rendus ne disent pas d'un lot : surface, étage, DPE, chauffage.
</p>
</div>
</div>
<div v-if="chargement" class="empty-state">
<span class="spinner w-6 h-6"></span>
</div>
<div v-else-if="!lots.length" class="empty-state">
Aucun lot en base : importez d'abord un compte rendu.
</div>
<template v-else>
<div class="card">
<div class="card-header flex-wrap">
<div class="flex items-center gap-3">
<h2 class="card-title">{{ lignesAffichees.length }} / {{ lots.length }} lots · {{ nbFiches }} décrits</h2>
<span v-if="nbEcartsType" class="badge badge-warning">
{{ nbEcartsType }} type{{ nbEcartsType > 1 ? 's' : '' }} en désaccord avec le PDF
</span>
<span v-if="nbEcartsSurface" class="badge badge-warning">
{{ nbEcartsSurface }} écart{{ nbEcartsSurface > 1 ? 's' : '' }} de surface
</span>
</div>
<button
v-if="filtresActifs"
class="btn btn-secondary btn-sm"
@click="reinitialiserFiltres"
>Effacer les filtres</button>
</div>
<!-- Hauteur bornée : sans elle, le defilement appartiendrait à la
page et l'en-tête collant n'aurait rien à quoi se coller. -->
<div class="table-wrap custom-scrollbar overflow-y-auto max-h-[calc(100vh-14rem)]">
<table class="table">
<thead>
<!-- Libellé et filtre dans la même cellule : le filtre se
cherche dans sa colonne, et l'en-tête reste d'un bloc quand
il se fige en haut du tableau. -->
<tr>
<th
v-for="colonne in COLONNES"
:key="colonne.cle"
class="sticky top-0 z-20 bg-gray-800 align-bottom"
>
<button
class="select-none hover:text-white uppercase"
:class="colonne.align === 'text-right' ? 'w-full text-right' : ''"
:title="`Trier par ${colonne.libelle}`"
@click="trierPar(colonne.cle)"
>
{{ colonne.libelle }}
<span class="text-blue-400">{{ triCle === colonne.cle ? (triSens === 'asc' ? '▲' : '▼') : '' }}</span>
</button>
<div class="mt-1 font-normal normal-case tracking-normal">
<select
v-if="colonne.filtre === 'choix'"
v-model="filtres[colonne.cle]"
class="input-cell w-full border-gray-700 text-xs"
>
<option value="">Tous</option>
<template v-for="groupe in choix[colonne.cle]" :key="groupe.libelle">
<optgroup v-if="groupe.libelle" :label="groupe.libelle">
<option v-for="valeur in groupe.options" :key="valeur" :value="valeur">{{ valeur }}</option>
</optgroup>
<template v-else>
<option v-for="valeur in groupe.options" :key="valeur" :value="valeur">{{ valeur }}</option>
</template>
</template>
</select>
<select
v-else-if="colonne.filtre === 'echeance'"
v-model="filtres[colonne.cle]"
class="input-cell w-full border-gray-700 text-xs"
>
<option value="">Tous</option>
<option v-for="option in OPTIONS_ECHEANCE" :key="option.valeur" :value="option.valeur">{{ option.libelle }}</option>
</select>
<input
v-else
v-model="filtres[colonne.cle]"
class="input-cell w-full border-gray-700 text-xs"
:class="colonne.align"
:placeholder="colonne.filtre === 'nombre' ? '> 100' : 'filtrer…'"
:title="colonne.filtre === 'nombre' ? 'Comparaison acceptée : > 100, <= 50, != 0, ou une valeur exacte' : ''"
/>
</div>
</th>
<th class="sticky top-0 z-20 bg-gray-800"></th>
</tr>
</thead>
<tbody>
<LigneLogement
v-for="ligne in lignesAffichees"
:key="ligne.id"
:ligne="ligne"
@enregistree="majLigne"
@supprimer="supprimerLot"
@renommer-immeuble="renommerImmeuble"
/>
</tbody>
</table>
</div>
<div v-if="!lignesAffichees.length" class="empty-state">
Aucun lot ne correspond aux filtres.
</div>
</div>
<p class="form-hint">
Chaque cellule s'enregistre dès qu'elle est quittée. L'échéance du DPE et l'écart
de surface sont calculés, jamais saisis.
</p>
</template>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import LigneLogement from '../components/referentiel/LigneLogement.vue'
import {
COLONNES,
OPTIONS_ECHEANCE,
comparer,
groupesFiltre,
passeFiltre
} from '../components/referentiel/colonnes'
const API = import.meta.env.VITE_API_URL || ''
const lots = ref([])
const chargement = ref(true)
const filtres = ref(Object.fromEntries(COLONNES.map((colonne) => [colonne.cle, ''])))
const triCle = ref('numero')
const triSens = ref('asc')
const nbFiches = computed(() => lots.value.filter((ligne) => ligne.caracteristiques).length)
const nbEcartsType = computed(() => lots.value.filter((ligne) => ligne.type_ecart).length)
const nbEcartsSurface = computed(
() => lots.value.filter((ligne) => (ligne.caracteristiques?.delta_surface ?? 0) !== 0).length
)
// Listes de choix construites sur les lots chargés : proposer un chauffage
// qu'aucun lot n'a donnerait un filtre qui ne renvoie jamais rien.
const choix = computed(() =>
Object.fromEntries(
COLONNES.filter((colonne) => colonne.filtre === 'choix').map((colonne) => [
colonne.cle,
groupesFiltre(lots.value, colonne)
])
)
)
const filtresActifs = computed(() => Object.values(filtres.value).some((valeur) => valeur !== ''))
const lignesAffichees = computed(() => {
const colonneTri = COLONNES.find((colonne) => colonne.cle === triCle.value) ?? COLONNES[0]
return lots.value
.filter((ligne) =>
COLONNES.every((colonne) => passeFiltre(ligne, colonne, filtres.value[colonne.cle]))
)
.sort(comparer(colonneTri, triSens.value))
})
function reinitialiserFiltres() {
filtres.value = Object.fromEntries(COLONNES.map((colonne) => [colonne.cle, '']))
}
function trierPar(cle) {
if (triCle.value === cle) {
triSens.value = triSens.value === 'asc' ? 'desc' : 'asc'
} else {
triCle.value = cle
triSens.value = 'asc'
}
}
async function chargerLots() {
const response = await fetch(`${API}/api/lots/referentiel`)
lots.value = await response.json()
}
// Le nom d'usage appartient à l'immeuble : toutes ses lignes le portent, elles
// changent donc ensemble. Ne rafraîchir que la ligne éditée afficherait deux
// noms pour un même immeuble jusqu'au rechargement.
async function renommerImmeuble({ immeubleId, denomination }) {
const response = await fetch(`${API}/api/immeubles/${immeubleId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ denomination })
})
if (!response.ok) return
const immeuble = await response.json()
lots.value = lots.value.map((ligne) =>
ligne.immeuble_id === immeuble.id
? { ...ligne, immeuble_denomination: immeuble.denomination }
: ligne
)
}
// Le serveur renvoie la ligne telle qu'il vient de l'enregistrer, dérivés
// compris : la remplacer évite de recharger tout le tableau à chaque frappe.
function majLigne(ligne) {
const index = lots.value.findIndex((existante) => existante.id === ligne.id)
if (index !== -1) lots.value[index] = ligne
}
async function supprimerLot(ligne) {
const decrit = ligne.caracteristiques ? ' Sa fiche sera perdue.' : ''
if (!confirm(`Supprimer le lot ${ligne.numero} ?${decrit}`)) return
const response = await fetch(`${API}/api/lots/${ligne.id}`, { method: 'DELETE' })
if (response.ok) {
lots.value = lots.value.filter((existante) => existante.id !== ligne.id)
return
}
// Le refus vient du serveur (lot rattaché à un compte rendu) : le montrer tel
// quel plutôt que de faire disparaître la ligne à tort.
const detail = await response.json().catch(() => null)
alert(detail?.detail || 'Suppression refusée.')
}
onMounted(async () => {
try {
await chargerLots()
} finally {
chargement.value = false
}
})
</script>

View File

@@ -4,6 +4,7 @@ import ExtractPage from './pages/ExtractPage.vue'
import AnalyticsPage from './pages/AnalyticsPage.vue' import AnalyticsPage from './pages/AnalyticsPage.vue'
import RevenusPage from './pages/RevenusPage.vue' import RevenusPage from './pages/RevenusPage.vue'
import DocumentsPage from './pages/DocumentsPage.vue' import DocumentsPage from './pages/DocumentsPage.vue'
import LogementsPage from './pages/LogementsPage.vue'
import EditDocumentPage from './pages/EditDocumentPage.vue' import EditDocumentPage from './pages/EditDocumentPage.vue'
import ReExtractionPage from './pages/ReExtractionPage.vue' import ReExtractionPage from './pages/ReExtractionPage.vue'
import ConfigPage from './pages/ConfigPage.vue' import ConfigPage from './pages/ConfigPage.vue'
@@ -55,6 +56,11 @@ const routes = [
name: 'documents', name: 'documents',
component: DocumentsPage component: DocumentsPage
}, },
{
path: '/logements',
name: 'logements',
component: LogementsPage
},
{ {
path: '/documents/:id/edit', path: '/documents/:id/edit',
name: 'edit-document', name: 'edit-document',

View File

@@ -112,6 +112,15 @@
@apply cursor-pointer; @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 { .form-hint {
@apply text-xs text-gray-500 mt-1; @apply text-xs text-gray-500 mt-1;
} }

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'])
})
})