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> <template>
<div class="h-screen flex flex-col bg-gray-950"> <div class="h-screen flex bg-gray-950">
<!-- Header compact --> <!-- Navigation laterale -->
<header class="flex-shrink-0 bg-gray-900 border-b border-gray-700 px-6 py-2"> <aside
<div class="flex items-center justify-between gap-6"> class="flex-shrink-0 bg-gray-900 border-r border-gray-700 flex flex-col transition-all duration-200"
<router-link to="/" class="text-lg font-semibold text-white hover:text-blue-400 transition-colors whitespace-nowrap"> :class="replie ? 'w-16' : 'w-52'"
Plesna Gerance
<span class="text-gray-400 font-normal text-sm ml-2">Extracteur de comptes rendus</span>
</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'"
> >
{{ link.label }} <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> </router-link>
<!-- Import button with file input --> <nav class="flex-1 overflow-y-auto px-2 py-3 space-y-4">
<label class="btn btn-primary ml-2 cursor-pointer"> <NavLien v-bind="accueil" :replie="replie" />
Importer un PDF
<input <div v-for="groupe in groupes" :key="groupe.titre">
ref="fileInput" <!-- Replie, le titre de groupe n'a plus la place de s'ecrire : un
type="file" filet garde la separation entre saisie et analyse. -->
accept="application/pdf,.pdf" <div
class="hidden" v-if="replie"
@change="onFileSelect" class="mx-3 mb-2 border-t border-gray-800"
/> :aria-label="groupe.titre"
</label> ></div>
</nav> <div
v-else
class="px-3 pb-1 text-[11px] font-semibold uppercase tracking-wider text-gray-600"
>
{{ groupe.titre }}
</div> </div>
</header> <NavLien v-for="lien in groupe.liens" :key="lien.to" v-bind="lien" :replie="replie" />
</div>
</nav>
<!-- 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'"
/>
</svg>
<span v-if="!replie" class="truncate">Replier</span>
</button>
</div>
</aside>
<!-- Main content --> <!-- Main content -->
<main class="flex-1 flex overflow-hidden"> <main class="flex-1 flex overflow-hidden">
@@ -43,42 +73,88 @@
</template> </template>
<script setup> <script setup>
import { ref } from 'vue' import { ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import NavLien from './components/NavLien.vue'
import { pendingFile } from './store'
import { FEATURE_IA } from './features' import { FEATURE_IA } from './features'
const route = useRoute() // Le repli survit au rechargement : c'est un reglage d'espace de travail, pas
const router = useRouter() // un etat de page — le retrouver deplie a chaque F5 serait une corvee.
const fileInput = ref(null) 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 const accueil = {
// chemins à l'identique éteindrait la navigation dès qu'une page a des to: '/',
// sous-routes. label: 'Accueil',
function estActif(chemin) { paths: [
return route.path === chemin || route.path.startsWith(`${chemin}/`) '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 // URL gardent leurs noms d'origine (/revenus, /analytics), qui circulent dans
// des liens deja partages. // des liens deja partages.
const navLinks = [ //
{ to: '/', label: 'Accueil' }, // Les fleches de Recettes et Depenses sont celles des actions rapides de
{ to: '/documents', label: 'Documents' }, // l'accueil : replie, le menu ne montre plus que ces icones, elles doivent
{ to: '/logements', label: 'Logements' }, // designer la meme chose d'un ecran a l'autre.
{ to: '/lots', label: 'Lots' }, const groupes = [
{ to: '/revenus', label: 'Recettes' }, {
{ to: '/analytics', label: 'Dépenses' }, titre: 'Saisie',
...(FEATURE_IA ? [{ to: '/ia', label: 'IA' }] : []), liens: [
{ to: '/config', label: 'Config' } {
] to: '/documents',
label: 'Documents',
function onFileSelect(e) { paths: [
const file = e.target.files[0] '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'
if (file && file.name.toLowerCase().endsWith('.pdf')) { ]
pendingFile.value = file },
router.push('/extract') {
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'
]
} }
e.target.value = '' // Reset input ]
} },
{
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'
]
}
]
: [])
]
}
]
</script> </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; @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 ----------------------------------------------------------- */ /* --- Cartes ----------------------------------------------------------- */
.card { .card {