feat: réorganise la navigation en barre latérale repliable
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:
@@ -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',
|
||||||
|
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'
|
||||||
]
|
]
|
||||||
|
|
||||||
function onFileSelect(e) {
|
|
||||||
const file = e.target.files[0]
|
|
||||||
if (file && file.name.toLowerCase().endsWith('.pdf')) {
|
|
||||||
pendingFile.value = file
|
|
||||||
router.push('/extract')
|
|
||||||
}
|
}
|
||||||
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>
|
||||||
|
|||||||
45
frontend/src/components/NavLien.vue
Normal file
45
frontend/src/components/NavLien.vue
Normal 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>
|
||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user