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:
247
frontend/src/pages/LogementsPage.vue
Normal file
247
frontend/src/pages/LogementsPage.vue
Normal 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>
|
||||
Reference in New Issue
Block a user