feat: réorganise la navigation en barre latérale repliable
All checks were successful
Build and Publish Docker Image / Build App Image (push) Successful in 21s
Build and Publish Docker Image / Build Summary (push) Successful in 3s

Les sept onglets alignés dans le header ne disaient pas que trois natures de
travail s'y mêlaient. Ils passent en colonne, sous deux titres : Saisie
alimente la base (Documents, Fiches logements), Analyse en lit le contenu
(Recettes, Dépenses, Par lot). Config quitte la rangée pour un pied de menu,
c'est un réglage et non une destination de travail.

« Logements » et « Lots » se ressemblaient trop pour deux pages qui parlent du
même objet sans faire la même chose. Elles deviennent « Fiches logements » —
ce qu'on y saisit — et « Par lot » — ce qu'on y lit. Les URL ne bougent pas,
elles circulent dans des liens déjà partagés.

Le bouton « Importer un PDF » disparaît du menu : la page Documents porte déjà
le sien en tête de liste et l'accueil garde sa zone de dépôt. Ce troisième
exemplaire n'ajoutait rien.

La barre se replie sur ses seules icônes, le libellé passant en infobulle et un
filet prenant la place des titres de groupe. Le repli est gardé en
`localStorage` : c'est un réglage d'espace de travail, le retrouver déplié à
chaque rechargement serait une corvée.

Les flèches de Recettes et Dépenses sont celles des actions rapides de
l'accueil. Replié, le menu ne montre plus que ces icônes : elles doivent
désigner la même chose d'un écran à l'autre.

Le calcul de l'état actif suit l'entrée de menu dans `NavLien`, qui la rend
dépliée ou non. Une fiche de lot (`/lots/26`) garde ainsi son entrée allumée,
comme avant le déménagement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-29 11:24:32 +02:00
parent 37bddae6ca
commit 8d869a3dde
3 changed files with 188 additions and 60 deletions

View File

@@ -1,39 +1,69 @@
<template>
<div class="h-screen flex flex-col bg-gray-950">
<!-- Header compact -->
<header class="flex-shrink-0 bg-gray-900 border-b border-gray-700 px-6 py-2">
<div class="flex items-center justify-between gap-6">
<router-link to="/" class="text-lg font-semibold text-white hover:text-blue-400 transition-colors whitespace-nowrap">
Plesna Gerance
<span class="text-gray-400 font-normal text-sm ml-2">Extracteur de comptes rendus</span>
</router-link>
<div class="h-screen flex bg-gray-950">
<!-- Navigation laterale -->
<aside
class="flex-shrink-0 bg-gray-900 border-r border-gray-700 flex flex-col transition-all duration-200"
:class="replie ? 'w-16' : 'w-52'"
>
<router-link
to="/"
class="block border-b border-gray-800 hover:bg-gray-800 transition-colors"
:class="replie ? 'px-2 py-3 text-center' : 'px-4 py-3'"
:title="replie ? 'Plesna Gerance' : null"
>
<template v-if="replie">
<div class="text-base font-semibold text-white">PG</div>
</template>
<template v-else>
<div class="text-base font-semibold text-white leading-tight">Plesna Gerance</div>
<div class="text-xs text-gray-500 mt-0.5">Extracteur de comptes rendus</div>
</template>
</router-link>
<!-- Navigation -->
<nav class="flex items-center gap-1">
<router-link
v-for="link in navLinks"
:key="link.to"
:to="link.to"
class="px-3 py-1.5 text-sm rounded-lg transition-colors"
:class="estActif(link.to) ? 'bg-gray-800 text-white' : 'text-gray-400 hover:text-white hover:bg-gray-800'"
<nav class="flex-1 overflow-y-auto px-2 py-3 space-y-4">
<NavLien v-bind="accueil" :replie="replie" />
<div v-for="groupe in groupes" :key="groupe.titre">
<!-- Replie, le titre de groupe n'a plus la place de s'ecrire : un
filet garde la separation entre saisie et analyse. -->
<div
v-if="replie"
class="mx-3 mb-2 border-t border-gray-800"
:aria-label="groupe.titre"
></div>
<div
v-else
class="px-3 pb-1 text-[11px] font-semibold uppercase tracking-wider text-gray-600"
>
{{ link.label }}
</router-link>
{{ groupe.titre }}
</div>
<NavLien v-for="lien in groupe.liens" :key="lien.to" v-bind="lien" :replie="replie" />
</div>
</nav>
<!-- Import button with file input -->
<label class="btn btn-primary ml-2 cursor-pointer">
Importer un PDF
<input
ref="fileInput"
type="file"
accept="application/pdf,.pdf"
class="hidden"
@change="onFileSelect"
<!-- Config n'est pas une destination de travail : elle vit a l'ecart, en bas -->
<div class="px-2 py-3 border-t border-gray-800 space-y-1">
<NavLien v-bind="config" :replie="replie" />
<button
type="button"
class="nav-link w-full text-gray-500 hover:text-white hover:bg-gray-800"
:class="replie ? 'justify-center px-0' : ''"
:title="replie ? 'Déplier le menu' : 'Replier le menu'"
@click="replie = !replie"
>
<svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
:d="replie ? 'M13 5l7 7-7 7M5 5l7 7-7 7' : 'M11 19l-7-7 7-7m8 14l-7-7 7-7'"
/>
</label>
</nav>
</svg>
<span v-if="!replie" class="truncate">Replier</span>
</button>
</div>
</header>
</aside>
<!-- Main content -->
<main class="flex-1 flex overflow-hidden">
@@ -43,42 +73,88 @@
</template>
<script setup>
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { pendingFile } from './store'
import { ref, watch } from 'vue'
import NavLien from './components/NavLien.vue'
import { FEATURE_IA } from './features'
const route = useRoute()
const router = useRouter()
const fileInput = ref(null)
// Le repli survit au rechargement : c'est un reglage d'espace de travail, pas
// un etat de page — le retrouver deplie a chaque F5 serait une corvee.
const replie = ref(localStorage.getItem('nav-repliee') === '1')
watch(replie, (valeur) => localStorage.setItem('nav-repliee', valeur ? '1' : '0'))
// 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}/`)
const accueil = {
to: '/',
label: 'Accueil',
paths: [
'M3 12l9-9 9 9M5 10v10a1 1 0 001 1h3a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1h3a1 1 0 001-1V10'
]
}
// Les libelles suivent le vocabulaire de l'accueil : recettes et depenses. Les
const config = {
to: '/config',
label: 'Config',
paths: [
'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z',
'M15 12a3 3 0 11-6 0 3 3 0 016 0z'
]
}
// Deux natures de travail, deux groupes : on alimente la base (Saisie) ou on
// lit ce qu'elle contient (Analyse). « Fiches logements » et « Par lot »
// portent des noms distincts parce que les deux pages parlent du même objet :
// l'une décrit le logement, l'autre analyse ce qui s'y est passé.
//
// Les libellés suivent le vocabulaire de l'accueil : recettes et depenses. Les
// URL gardent leurs noms d'origine (/revenus, /analytics), qui circulent dans
// des liens deja partages.
const navLinks = [
{ to: '/', label: 'Accueil' },
{ to: '/documents', label: 'Documents' },
{ to: '/logements', label: 'Logements' },
{ to: '/lots', label: 'Lots' },
{ to: '/revenus', label: 'Recettes' },
{ to: '/analytics', label: 'Dépenses' },
...(FEATURE_IA ? [{ to: '/ia', label: 'IA' }] : []),
{ to: '/config', label: 'Config' }
]
function onFileSelect(e) {
const file = e.target.files[0]
if (file && file.name.toLowerCase().endsWith('.pdf')) {
pendingFile.value = file
router.push('/extract')
//
// Les fleches de Recettes et Depenses sont celles des actions rapides de
// l'accueil : replie, le menu ne montre plus que ces icones, elles doivent
// designer la meme chose d'un ecran a l'autre.
const groupes = [
{
titre: 'Saisie',
liens: [
{
to: '/documents',
label: 'Documents',
paths: [
'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z'
]
},
{
to: '/logements',
label: 'Fiches logements',
paths: [
'M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4'
]
}
]
},
{
titre: 'Analyse',
liens: [
{ to: '/revenus', label: 'Recettes', paths: ['M12 19V5m0 0l-6 6m6-6l6 6'] },
{ to: '/analytics', label: 'Dépenses', paths: ['M12 5v14m0 0l6-6m-6 6l-6-6'] },
{
to: '/lots',
label: 'Par lot',
paths: [
'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z'
]
},
...(FEATURE_IA
? [
{
to: '/ia',
label: 'IA',
paths: [
'M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z'
]
}
]
: [])
]
}
e.target.value = '' // Reset input
}
]
</script>

View File

@@ -0,0 +1,45 @@
<template>
<router-link
:to="to"
class="nav-link"
:class="[
actif ? 'bg-gray-800 text-white' : 'text-gray-400 hover:text-white hover:bg-gray-800',
replie ? 'justify-center px-0' : ''
]"
:title="replie ? label : null"
:aria-label="label"
>
<svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
v-for="d in paths"
:key="d"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
:d="d"
/>
</svg>
<span v-if="!replie" class="truncate">{{ label }}</span>
</router-link>
</template>
<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
const props = defineProps({
to: { type: String, required: true },
label: { type: String, required: true },
paths: { type: Array, required: true },
replie: { type: Boolean, default: false }
})
const route = useRoute()
// Une fiche de lot (/lots/26) doit garder son entrée allumée : comparer les
// chemins à l'identique éteindrait la navigation dès qu'une page a des
// sous-routes.
const actif = computed(
() => route.path === props.to || route.path.startsWith(`${props.to}/`)
)
</script>

View File

@@ -40,6 +40,13 @@
@apply text-sm text-gray-400 mt-1;
}
/* --- Navigation laterale ---------------------------------------------- */
/* Entree de menu : la couleur (actif / inactif) est posee par App.vue. */
.nav-link {
@apply flex items-center gap-2 px-3 py-1.5 text-sm rounded-lg transition-colors;
}
/* --- Cartes ----------------------------------------------------------- */
.card {