feat: ouvre la fiche d'un lot dans l'interface
All checks were successful
Build and Publish Docker Image / Build App Image (push) Successful in 1m24s
Build and Publish Docker Image / Build Summary (push) Successful in 3s

Nouvel onglet « Lots » : identité, chiffres, chronologie et intervenants
d'un lot, choisi dans un sélecteur. Le lot vit dans l'URL (`/lots/26`) pour
qu'une fiche se partage et survive au rechargement.

La chronologie mêle recettes et dépenses dans l'ordre des comptes rendus :
c'est là que se lit la vie du lot, y compris ce que la base ne porte pas
ailleurs — les dates d'entrée et de sortie des locataires sont vides, mais
les honoraires de mise en location et les états des lieux les racontent.

Chaque entreprise du tableau des intervenants se déplie sur ses
interventions. Le détail reprend les lignes déjà chargées plutôt que d'en
redemander : un second calcul pourrait diverger du total affiché.

Le solde n'étant pas un résultat net, la page le dit sous les tuiles, à
l'endroit où le chiffre se lit, et rappelle le montant des charges
d'immeuble laissées hors des lots.

Le taux de recouvrement s'efface quand rien n'est facturé : les 100 %
renvoyés dans ce cas afficheraient un lot sain à côté de 49 000 € d'impayé.

Couleurs reprises des tuiles existantes — `.card` et `.badge` ne fixant
aucune couleur de texte, une valeur sans classe héritait du noir et
disparaissait sur le fond sombre.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-29 10:42:14 +02:00
parent 6a014cdb71
commit 37bddae6ca
3 changed files with 403 additions and 2 deletions

View File

@@ -15,7 +15,7 @@
:key="link.to" :key="link.to"
:to="link.to" :to="link.to"
class="px-3 py-1.5 text-sm rounded-lg transition-colors" class="px-3 py-1.5 text-sm rounded-lg transition-colors"
:class="$route.path === link.to ? 'bg-gray-800 text-white' : 'text-gray-400 hover:text-white hover:bg-gray-800'" :class="estActif(link.to) ? 'bg-gray-800 text-white' : 'text-gray-400 hover:text-white hover:bg-gray-800'"
> >
{{ link.label }} {{ link.label }}
</router-link> </router-link>
@@ -44,13 +44,21 @@
<script setup> <script setup>
import { ref } from 'vue' import { ref } from 'vue'
import { useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { pendingFile } from './store' import { pendingFile } from './store'
import { FEATURE_IA } from './features' import { FEATURE_IA } from './features'
const route = useRoute()
const router = useRouter() const router = useRouter()
const fileInput = ref(null) const fileInput = ref(null)
// Une fiche de lot (/lots/26) doit garder son onglet allumé : comparer les
// chemins à l'identique éteindrait la navigation dès qu'une page a des
// sous-routes.
function estActif(chemin) {
return route.path === chemin || route.path.startsWith(`${chemin}/`)
}
// Les libelles suivent le vocabulaire de l'accueil : recettes et depenses. Les // Les libelles suivent le vocabulaire de l'accueil : recettes et depenses. Les
// URL gardent leurs noms d'origine (/revenus, /analytics), qui circulent dans // URL gardent leurs noms d'origine (/revenus, /analytics), qui circulent dans
// des liens deja partages. // des liens deja partages.
@@ -58,6 +66,7 @@ const navLinks = [
{ to: '/', label: 'Accueil' }, { to: '/', label: 'Accueil' },
{ to: '/documents', label: 'Documents' }, { to: '/documents', label: 'Documents' },
{ to: '/logements', label: 'Logements' }, { to: '/logements', label: 'Logements' },
{ to: '/lots', label: 'Lots' },
{ 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,384 @@
<template>
<div class="page">
<div class="page-content">
<div class="page-header">
<div>
<h1 class="page-title">Lot</h1>
<p class="page-subtitle">
Ce que les comptes rendus portent sur un lot, dans l'ordre où ils l'ont porté.
</p>
</div>
<select v-model="lotChoisi" class="input w-64">
<option v-for="lot in lots" :key="lot.id" :value="lot.id">
{{ lot.numero }} {{ lot.immeuble_denomination || lot.immeuble_code }}
<template v-if="lot.type_effectif"> · {{ lot.type_effectif }}</template>
</option>
</select>
</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-if="analyse">
<!-- Identité : les cases vides de la fiche restent vides et renvoient
vers la saisie, plutôt que d'être comblées ou masquées. -->
<div class="card">
<div class="card-header">
<h2 class="card-title">
Lot {{ analyse.identite.numero }}
<span class="text-gray-400 font-normal">
· {{ analyse.identite.immeuble_denomination || analyse.identite.immeuble_code }}
</span>
</h2>
<router-link to="/logements" class="btn btn-secondary btn-sm">
Compléter la fiche
</router-link>
</div>
<div class="card-body grid grid-cols-2 md:grid-cols-6 gap-4 text-sm">
<div v-for="champ in CHAMPS_IDENTITE" :key="champ.cle">
<div class="text-xs text-gray-400 uppercase tracking-wide">{{ champ.libelle }}</div>
<div :class="valeurIdentite(champ) === '—' ? 'text-gray-600' : 'text-white'">
{{ valeurIdentite(champ) }}
</div>
</div>
</div>
<div class="card-body pt-0 text-sm">
<span class="text-xs text-gray-400 uppercase tracking-wide">Locataires</span>
<span v-if="!analyse.identite.locataires.length" class="ml-2 text-gray-600"></span>
<span
v-for="nom in analyse.identite.locataires"
:key="nom"
class="badge badge-neutral ml-2"
>{{ nom }}</span>
</div>
</div>
<!-- Chiffres -->
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
<div v-for="tuile in tuiles" :key="tuile.libelle" class="card card-body">
<div class="text-xs text-gray-400 uppercase tracking-wide">{{ tuile.libelle }}</div>
<div class="text-2xl font-bold" :class="tuile.classe">{{ tuile.valeur }}</div>
<div v-if="tuile.detail" class="text-xs text-gray-500 mt-1">{{ tuile.detail }}</div>
</div>
</div>
<!-- Le solde n'est pas un résultat : le dire il s'affiche, pas
dans une documentation que personne n'ouvre. -->
<p class="form-hint">
Le solde ne retient que les dépenses imputées à ce lot par le compte rendu.
Les charges de l'immeuble ({{ formatMontant(analyse.chiffres.depenses_immeuble_non_reparties) }}
sur la période, tous lots confondus) n'y sont pas réparties : aucun document ne dit
quelle part revient à quel lot.
</p>
<!-- Chronologie -->
<div class="card">
<div class="card-header">
<h2 class="card-title">Chronologie · {{ analyse.chronologie.length }} lignes</h2>
<label class="flex items-center gap-2 cursor-pointer">
<input
v-model="depensesSeules"
type="checkbox"
class="w-4 h-4 rounded border-gray-700 accent-blue-500 [color-scheme:dark]"
/>
<span class="text-sm text-gray-300">Dépenses seulement</span>
</label>
</div>
<div class="table-wrap custom-scrollbar">
<table class="table">
<thead>
<tr>
<th>Date</th>
<th>Nature</th>
<th>Libellé</th>
<th>Fournisseur</th>
<th class="text-right">Montant</th>
<th class="text-right">Réglé</th>
<th class="text-right">Impayé</th>
</tr>
</thead>
<tbody>
<tr v-for="(ligne, index) in chronologieAffichee" :key="index">
<td class="whitespace-nowrap">{{ formatDate(ligne.date) }}</td>
<td>
<span class="badge" :class="badgeNature(ligne)">{{ libelleNature(ligne) }}</span>
</td>
<td>
{{ ligne.libelle || '—' }}
<div v-if="ligne.categorie" class="text-xs text-gray-500">{{ ligne.categorie }}</div>
<div v-else-if="ligne.periode_debut" class="text-xs text-gray-500">
{{ formatDate(ligne.periode_debut) }} {{ formatDate(ligne.periode_fin) }}
</div>
</td>
<td class="text-gray-400">{{ ligne.fournisseur || '—' }}</td>
<td class="text-right" :class="ligne.nature === 'depense' ? 'text-red-400' : 'text-gray-300'">
{{ formatMontantPrecis(ligne.montant) }}
</td>
<td class="text-right" :class="ligne.regle ? 'text-blue-400' : 'text-gray-500'">
{{ ligne.regle == null ? '—' : formatMontantPrecis(ligne.regle) }}
</td>
<td class="text-right" :class="ligne.impaye ? 'text-amber-400' : 'text-gray-500'">
{{ ligne.impaye == null ? '—' : formatMontantPrecis(ligne.impaye) }}
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="!chronologieAffichee.length" class="empty-state">
Aucune ligne pour ce lot.
</div>
</div>
<!-- Intervenants -->
<div class="card">
<div class="card-header">
<h2 class="card-title">Intervenants · {{ analyse.intervenants.length }}</h2>
</div>
<div class="table-wrap custom-scrollbar">
<table class="table">
<thead>
<tr>
<th>Entreprise</th>
<th class="text-right">Interventions</th>
<th class="text-right">Montant</th>
<th>Dernière</th>
<th></th>
</tr>
</thead>
<tbody>
<template
v-for="intervenant in analyse.intervenants"
:key="intervenant.fournisseur"
>
<tr
class="cursor-pointer"
@click="basculerDetail(intervenant.fournisseur)"
>
<td class="text-white">{{ intervenant.fournisseur }}</td>
<td class="text-right">{{ intervenant.nb_interventions }}</td>
<td class="text-right text-red-400">{{ formatMontantPrecis(intervenant.montant) }}</td>
<td>{{ formatDate(intervenant.derniere_date) }}</td>
<td class="text-right">
<button class="btn btn-sm btn-secondary">
{{ detailsOuverts[intervenant.fournisseur] ? 'Masquer' : 'Détail' }}
</button>
</td>
</tr>
<!-- Le détail reprend les lignes déjà chargées : ce que la
chronologie montre pour cette entreprise, sans nouvel
appel ni second calcul qui pourrait diverger du total. -->
<tr v-if="detailsOuverts[intervenant.fournisseur]" class="hover:bg-transparent">
<td colspan="5" class="bg-gray-950 p-0">
<table class="table">
<tbody>
<tr
v-for="(ligne, index) in interventions(intervenant.fournisseur)"
:key="index"
>
<td class="whitespace-nowrap w-32">{{ formatDate(ligne.date) }}</td>
<td>
{{ ligne.libelle || '—' }}
<div v-if="ligne.categorie" class="text-xs text-gray-500">
{{ ligne.categorie }}
</div>
</td>
<td class="text-right text-red-400 w-32">
{{ formatMontantPrecis(ligne.montant) }}
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<div v-if="!analyse.intervenants.length" class="empty-state">
Aucune entreprise n'est intervenue sur ce lot.
</div>
</div>
</template>
<!-- Changer de lot recharge la fiche : sans cet état, la page se viderait
en silence entre deux lots. -->
<div v-else class="empty-state">
<span class="spinner w-6 h-6"></span>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed, watch, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { formatDate, formatMontant, formatMontantPrecis } from '../utils/format'
const API = import.meta.env.VITE_API_URL || ''
const route = useRoute()
const router = useRouter()
const lots = ref([])
const lotChoisi = ref(null)
const analyse = ref(null)
const chargement = ref(true)
const depensesSeules = ref(false)
const detailsOuverts = reactive({})
const CHAMPS_IDENTITE = [
{ cle: 'type_effectif', libelle: 'Type' },
{ cle: 'surface', libelle: 'Surface', unite: ' m²' },
{ cle: 'etage', libelle: 'Étage' },
{ cle: 'bat', libelle: 'Bâtiment' },
{ cle: 'chauffage', libelle: 'Chauffage' },
{ cle: 'dpe_classe', libelle: 'DPE' }
]
function valeurIdentite(champ) {
const valeur = analyse.value?.identite?.[champ.cle]
if (valeur == null || valeur === '') return '—'
return `${valeur}${champ.unite || ''}`
}
// Palette reprise des autres tuiles de l'application : le facturé au vert des
// recettes, l'encaissé au bleu des règlements, l'impayé à l'ambre des alertes,
// la dépense au rouge du débit. Chaque valeur porte toujours une couleur : sans
// classe, elle hériterait du noir et disparaîtrait sur le fond sombre.
function couleurTaux(taux) {
if (taux >= 95) return 'text-green-400'
if (taux >= 80) return 'text-yellow-400'
return 'text-red-400'
}
const tuiles = computed(() => {
const c = analyse.value.chiffres
const rienFacture = c.facture <= 0
return [
{
libelle: 'Facturé',
valeur: formatMontant(c.facture),
classe: 'text-green-400',
detail: 'reports exclus'
},
{
libelle: 'Encaissé',
valeur: formatMontant(c.encaisse),
classe: 'text-blue-400'
},
{
libelle: 'Restant dû',
valeur: formatMontant(c.restant_du),
classe: c.restant_du > 0 ? 'text-amber-400' : 'text-gray-400',
detail: 'au dernier compte rendu'
},
{
libelle: 'Recouvrement',
// Sans rien de facturé, un taux de 100 % ferait passer pour sain un lot
// qui ne facture plus rien tout en devant 49 000 € : mieux vaut ne pas
// afficher de taux que d'en afficher un qui rassure à tort.
valeur: rienFacture ? '—' : `${c.taux_recouvrement} %`,
classe: rienFacture ? 'text-gray-500' : couleurTaux(c.taux_recouvrement),
detail: rienFacture ? 'rien de facturé' : null
},
{
libelle: 'Dépenses',
valeur: formatMontant(c.depenses_debit - c.depenses_credit),
classe: 'text-red-400',
detail: `${c.nb_operations} opération${c.nb_operations > 1 ? 's' : ''}`
},
{
libelle: 'Solde',
valeur: formatMontant(c.solde),
classe: c.solde < 0 ? 'text-red-400' : 'text-green-400',
detail: 'hors charges communes'
}
]
})
/** Nature d'une ligne : dépense, report de solde, ou recette de la période. */
function libelleNature(ligne) {
if (ligne.nature === 'depense') return ligne.tag || 'Dépense'
return ligne.est_report ? 'Report' : 'Recette'
}
function badgeNature(ligne) {
if (ligne.nature === 'depense') return 'badge-danger'
return ligne.est_report ? 'badge-neutral' : 'badge-success'
}
// Interventions regroupées par entreprise, dans l'ordre de la chronologie dont
// elles sortent. Le détail d'une entreprise est donc exactement le sous-ensemble
// de lignes que le tableau du dessus montre déjà.
const interventionsParFournisseur = computed(() => {
const groupes = {}
for (const ligne of analyse.value.chronologie) {
if (ligne.nature !== 'depense' || !ligne.fournisseur) continue
;(groupes[ligne.fournisseur] ??= []).push(ligne)
}
return groupes
})
function interventions(fournisseur) {
return interventionsParFournisseur.value[fournisseur] ?? []
}
function basculerDetail(fournisseur) {
detailsOuverts[fournisseur] = !detailsOuverts[fournisseur]
}
const chronologieAffichee = computed(() =>
depensesSeules.value
? analyse.value.chronologie.filter((ligne) => ligne.nature === 'depense')
: analyse.value.chronologie
)
async function chargerLots() {
const response = await fetch(`${API}/api/lots/referentiel`)
lots.value = await response.json()
}
async function chargerAnalyse(lotId) {
analyse.value = null
// Les dépliages appartiennent au lot affiché : gardés, une entreprise
// présente sur deux lots (PPR par exemple) arriverait déjà ouverte.
for (const fournisseur of Object.keys(detailsOuverts)) delete detailsOuverts[fournisseur]
const response = await fetch(`${API}/api/lots/${lotId}/analyse`)
if (response.ok) analyse.value = await response.json()
}
// L'URL porte le lot : une fiche se partage et se recharge sans repasser par
// le sélecteur.
watch(lotChoisi, (lotId) => {
if (lotId == null) return
if (String(lotId) !== route.params.id) router.replace(`/lots/${lotId}`)
chargerAnalyse(lotId)
})
onMounted(async () => {
try {
await chargerLots()
const demande = Number(route.params.id)
lotChoisi.value = lots.value.some((lot) => lot.id === demande)
? demande
: (lots.value[0]?.id ?? null)
} finally {
chargement.value = false
}
})
</script>

View File

@@ -5,6 +5,7 @@ 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 LogementsPage from './pages/LogementsPage.vue'
import LotPage from './pages/LotPage.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'
@@ -61,6 +62,13 @@ const routes = [
name: 'logements', name: 'logements',
component: LogementsPage component: LogementsPage
}, },
// Le lot vit dans l'URL : une fiche se partage et survit au rechargement.
// `/lots` sans identifiant ouvre le premier lot plutôt qu'une page vide.
{
path: '/lots/:id?',
name: 'lot',
component: LotPage
},
{ {
path: '/documents/:id/edit', path: '/documents/:id/edit',
name: 'edit-document', name: 'edit-document',