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:
@@ -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' }] : []),
|
||||
|
||||
227
frontend/src/components/referentiel/LigneLogement.vue
Normal file
227
frontend/src/components/referentiel/LigneLogement.vue
Normal 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) }} m²
|
||||
</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>
|
||||
251
frontend/src/components/referentiel/colonnes.js
Normal file
251
frontend/src/components/referentiel/colonnes.js
Normal 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 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 })
|
||||
}
|
||||
}
|
||||
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>
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
167
frontend/tests/colonnesReferentiel.test.js
Normal file
167
frontend/tests/colonnesReferentiel.test.js
Normal 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 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'])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user