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,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>