feat(b3): M3 — panneau « toutes les fiches » sur / et /agences (desktop + mobile), fiches sans géoloc visibles, ?vue=fiches

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Jules
2026-09-25 12:17:52 +02:00
co-authored by Claude Sonnet 5
parent 5b504b3e04
commit 1838f4b02d
3 changed files with 316 additions and 21 deletions
+135
View File
@@ -0,0 +1,135 @@
<!--
FichesPanel — B3-M3
Panneau « toutes les fiches » : reçoit une liste déjà filtrée (recherche +
filtres en cours côté page), l'affiche en grille de FicheMiniCard, et
expose ses propres chips de filtrage branchées sur le MÊME état que la
page appelante (pas un second état — voir vérification du brief).
Ne crée pas de second champ de recherche : `query` sert uniquement à
nuancer le message d'état vide.
-->
<template>
<div class="fiches-panel">
<div class="fiches-panel__head">
<p class="fiches-panel__intention">{{ intention }}</p>
<ChipGroup v-if="hashtags.length" aria-label="Filtrer les fiches" class="fiches-panel__chips">
<Chip
v-for="tag in hashtags"
:key="tag"
:label="tag"
variant="fonction"
:active="selected.includes(tag)"
@toggle="toggle(tag)"
/>
</ChipGroup>
<div class="fiches-panel__count">
{{ fiches.length }} fiche{{ fiches.length > 1 ? 's' : '' }}
</div>
</div>
<div class="fiches-panel__body">
<p v-if="fiches.length === 0" class="fiches-panel__empty">
Aucun résultat<template v-if="query"> pour « {{ query }} »</template>.
</p>
<div v-else class="fiches-panel__grid">
<FicheMiniCard
v-for="fiche in fiches"
:key="`${fiche.kind}-${fiche.id}`"
:fiche="fiche"
@open="emit('open', fiche)"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { MiniFiche } from '~/composables/useFicheAdapter'
const props = defineProps<{
fiches: MiniFiche[]
hashtags: string[]
selected: string[]
query: string
intention: string
}>()
const emit = defineEmits<{
'update:selected': [value: string[]]
open: [fiche: MiniFiche]
}>()
function toggle(tag: string) {
if (props.selected.includes(tag)) {
emit('update:selected', props.selected.filter((t) => t !== tag))
} else {
emit('update:selected', [...props.selected, tag])
}
}
</script>
<style scoped>
.fiches-panel {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.fiches-panel__head {
flex-shrink: 0;
padding: var(--space-sm) var(--space-md);
border-bottom: 1px solid var(--border);
background: var(--surface);
}
.fiches-panel__intention {
margin: 0 0 var(--space-xs);
font-size: var(--fs-sm);
line-height: var(--lh-base);
color: var(--text-muted);
}
.fiches-panel__count {
margin-top: var(--space-xs);
font-size: var(--fs-xs);
font-weight: var(--fw-semibold);
text-transform: uppercase;
letter-spacing: 0.02em;
color: var(--text-subtle);
}
.fiches-panel__body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: var(--space-md);
}
.fiches-panel__empty {
margin: var(--space-xl) 0;
text-align: center;
font-size: var(--fs-sm);
color: var(--text-muted);
}
.fiches-panel__grid {
display: grid;
grid-template-columns: 1fr;
gap: var(--space-sm);
}
@media (min-width: 768px) {
.fiches-panel__grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 1280px) {
.fiches-panel__grid {
grid-template-columns: repeat(3, 1fr);
}
}
</style>
+97 -11
View File
@@ -2,7 +2,15 @@
<div class="flex h-full overflow-hidden" style="background: var(--nav-bg);">
<!-- ═══════════════════════════════════════ SIDEBAR DESKTOP (>= 1024px) -->
<div class="hidden lg:block overflow-y-auto" style="width: 320px; min-width: 320px; flex-shrink: 0; border-right: 1px solid var(--nav-bg-alt); height: 100%;">
<!-- Masquée en vue « Toutes les fiches » (B3-M3, décision Jules axe 2) :
le panneau EST la liste (+ ses propres chips hashtags), la sidebar
ferait doublon. IntentionBanner reste monté (Teleport, insensible au
v-show de son conteneur). -->
<div
v-show="desktopMapView !== 'fiches'"
class="hidden lg:block overflow-y-auto"
style="width: 320px; min-width: 320px; flex-shrink: 0; border-right: 1px solid var(--nav-bg-alt); height: 100%;"
>
<!-- IntentionBanner s'auto-affiche via Teleport (overlay plein ecran) -->
<IntentionBanner />
@@ -113,22 +121,29 @@
:style="desktopMapView === 'metropole'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="desktopMapView = 'metropole'"
@click="setDesktopView('metropole')"
>Métropolitain</button>
<button
class="px-5 py-2 text-sm font-medium transition-colors"
:style="desktopMapView === 'outremer'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="desktopMapView = 'outremer'"
@click="setDesktopView('outremer')"
>Outre-mer</button>
<button
class="px-5 py-2 text-sm font-medium transition-colors"
:style="desktopMapView === 'graphe'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="desktopMapView = 'graphe'"
@click="setDesktopView('graphe')"
>Vue graphique</button>
<button
class="px-5 py-2 text-sm font-medium transition-colors"
:style="desktopMapView === 'fiches'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="setDesktopView('fiches')"
>Toutes les fiches</button>
</div>
<!-- Carte Métropole desktop -->
@@ -195,31 +210,53 @@
@applyHashtag="(tag) => { if (!selectedHashtags.includes(tag)) selectedHashtags = [...selectedHashtags, tag] }"
/>
</div>
<!-- Panneau « Toutes les fiches » desktop (B3-M3) — la sidebar est
masquée dans cette vue (décision Jules axe 2), le panneau EST
la liste, avec ses propres chips hashtags sur le même état. -->
<div v-show="desktopMapView === 'fiches'" class="flex-1 flex flex-col overflow-hidden">
<FichesPanel
:fiches="allStructuresMini"
:hashtags="allHashtags"
:selected="selectedHashtags"
:query="search"
intention="Toutes les agences et réseaux du répertoire, filtrables par hashtag."
@update:selected="selectedHashtags = $event"
@open="(fiche) => onSelectStructure(String(fiche.id))"
/>
</div>
</div>
<!-- ── VUE MOBILE : Onglets Métro/Outre-mer/Graphique + sheet swipable ── -->
<!-- ── VUE MOBILE : Onglets Métro/Outre-mer/Graphique/Fiches + sheet swipable ── -->
<div class="lg:hidden shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
<button
class="flex-1 py-2 text-sm font-medium transition-colors"
:style="mobileMapView === 'metropole'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="mobileMapView = 'metropole'"
@click="setMobileView('metropole')"
>Métropolitain</button>
<button
class="flex-1 py-2 text-sm font-medium transition-colors"
:style="mobileMapView === 'outremer'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="mobileMapView = 'outremer'"
@click="setMobileView('outremer')"
>Outre-mer</button>
<button
class="flex-1 py-2 text-sm font-medium transition-colors"
:style="mobileMapView === 'graphe'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="mobileMapView = 'graphe'"
@click="setMobileView('graphe')"
>Graphe</button>
<button
class="flex-1 py-2 text-sm font-medium transition-colors"
:style="mobileMapView === 'fiches'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="setMobileView('fiches')"
>Fiches</button>
</div>
<div class="lg:hidden flex-1 relative overflow-hidden">
@@ -273,8 +310,11 @@
</ClientOnly>
</div>
<!-- Fond neutre pour l'onglet « Fiches » (pas de carte dessous) -->
<div v-show="mobileMapView === 'fiches'" class="absolute inset-0" style="background: var(--nav-bg-alt);" />
<!-- Bottom sheet swipable (masqué en vue graphique pour ne pas occulter le canvas) -->
<ClientOnly v-if="mobileMapView !== 'graphe'">
<ClientOnly v-if="mobileMapView !== 'graphe' && mobileMapView !== 'fiches'">
<MobileSheet :resultCount="filtered.length" :pending="pending">
<!-- Bandeau intention mobile -->
<div class="px-3 py-2" style="background: var(--bifurc-banner-bg, #faf8f5); border-bottom: 1px solid var(--bifurc-banner-border, #e0d8cc);">
@@ -365,6 +405,21 @@
</div>
</MobileSheet>
</ClientOnly>
<!-- Bottom sheet — onglet « Fiches » mobile (B3-M3), contenu dédié -->
<ClientOnly v-else-if="mobileMapView === 'fiches'">
<MobileSheet :resultCount="filtered.length" :pending="pending">
<FichesPanel
:fiches="allStructuresMini"
:hashtags="allHashtags"
:selected="selectedHashtags"
:query="search"
intention="Toutes les agences et réseaux du répertoire, filtrables par hashtag."
@update:selected="selectedHashtags = $event"
@open="(fiche) => onSelectStructureMobile(String(fiche.id))"
/>
</MobileSheet>
</ClientOnly>
</div>
</main>
@@ -455,15 +510,41 @@ function familleColor(f: number): string {
}
// ── État UI ────────────────────────────────────────────────────────────────
type MapView = 'metropole' | 'outremer' | 'graphe' | 'fiches'
const route = useRoute()
const router = useRouter()
const selectedId = ref<string | null>(null)
const hoveredId = ref<string | null>(null)
const ficheModalOpen = ref(false)
const ficheModalId = ref<string | null>(null)
const chatbotOpen = ref(false)
const mobileMapView = ref<'metropole' | 'outremer' | 'graphe'>('metropole')
const desktopMapView = ref<'metropole' | 'outremer' | 'graphe'>('metropole')
const mobileMapView = ref<MapView>((route.query.vue as string) === 'fiches' ? 'fiches' : 'metropole')
const desktopMapView = ref<MapView>((route.query.vue as string) === 'fiches' ? 'fiches' : 'metropole')
const missionOpen = ref(false)
// Sync URL — B3-M3 : pose/retire ?vue=fiches uniquement (agences.vue n'avait
// aucune synchronisation URL avant B3 pour search/famille/hashtags/vue ; hors
// périmètre de cette mission de l'étendre, seul ?vue=fiches est ajouté ici).
function setDesktopView(v: MapView) {
desktopMapView.value = v
syncViewUrl()
}
function setMobileView(v: MapView) {
mobileMapView.value = v
syncViewUrl()
}
function syncViewUrl() {
const q: Record<string, string> = { ...(route.query as Record<string, string>) }
if (desktopMapView.value === 'fiches' || mobileMapView.value === 'fiches') {
q.vue = 'fiches'
} else {
delete q.vue
}
router.replace({ query: Object.keys(q).length ? q : undefined })
}
onMounted(() => {
try {
if (!localStorage.getItem('aep_reseaux_seen')) {
@@ -556,6 +637,11 @@ function resetFilters() {
// Pour simplifier : toutes les structures (la carte gère les sans-coords)
const metropoleStructures = computed<StructureV2[]>(() => filtered.value)
// Panneau « Toutes les fiches » (B3-M3) — mêmes structures que `filtered`,
// sans filtrer par géolocalisation (adaptateur mutualisé avec `/`).
const { toMini } = useFicheAdapter()
const allStructuresMini = computed(() => filtered.value.map((s) => toMini(s)))
// Outre-mer : pas de structures V2 DOM-TOM pour l'instant - garder le composant existant vide
// OutremerMap attend le format Org legacy - on passe un tableau vide
const outremerOrgsLegacy = computed(() => [])
+84 -10
View File
@@ -40,7 +40,7 @@
Mode dev — données seed
</div>
<!-- ── VUE DESKTOP : Onglets Métropole / Outre-mer ── -->
<!-- ── VUE DESKTOP : Onglets Métropole / Outre-mer / Toutes les fiches ── -->
<div class="hidden lg:flex lg:flex-1 lg:flex-col lg:overflow-hidden">
<!-- Barre onglets desktop -->
<div class="shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
@@ -49,15 +49,22 @@
:style="desktopMapView === 'metropole'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="desktopMapView = 'metropole'"
@click="setDesktopView('metropole')"
>Métropolitain</button>
<button
class="px-5 py-2 text-sm font-medium transition-colors"
:style="desktopMapView === 'outremer'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="desktopMapView = 'outremer'"
@click="setDesktopView('outremer')"
>Outre-mer</button>
<button
class="px-5 py-2 text-sm font-medium transition-colors"
:style="desktopMapView === 'fiches'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="setDesktopView('fiches')"
>Toutes les fiches</button>
</div>
<!-- Carte Métropole desktop -->
@@ -94,26 +101,58 @@
</div>
<ChatbotPlaceholder @highlightOrgs="onHighlightOrgs" />
</div>
<!-- Panneau « Toutes les fiches » desktop (B3-M3) — remplace la carte,
fiches sans géoloc incluses (gain principal de cette mission) -->
<div v-show="desktopMapView === 'fiches'" class="flex-1 flex flex-col overflow-hidden">
<div
class="shrink-0 px-4 py-2"
style="border-bottom: 1px solid var(--nav-bg-alt); background: var(--nav-surface);"
>
<button
type="button"
class="text-xs font-medium underline"
style="color: var(--nav-text-muted);"
@click="setDesktopView('metropole')"
>← Carte</button>
</div>
<FichesPanel
:fiches="allFichesMini"
:hashtags="[...FONCTIONS]"
:selected="fonctions"
:query="search"
intention="Toutes les structures d'entraide, y compris celles sans adresse sur la carte. Cochez une fonction pour filtrer."
@update:selected="onFonctions"
@open="(fiche) => onSelectOrg(Number(fiche.id))"
/>
</div>
</div>
<!-- ── VUE MOBILE : Onglets Métro/Outre-mer + carte pleine hauteur + sheet swipable ── -->
<!-- Onglets Métropolitain / Outre-mer -->
<!-- Onglets Métropolitain / Outre-mer / Toutes les fiches -->
<div class="lg:hidden shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
<button
class="flex-1 py-2 text-sm font-medium transition-colors"
:style="mobileMapView === 'metropole'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="mobileMapView = 'metropole'"
@click="setMobileView('metropole')"
>Métropolitain</button>
<button
class="flex-1 py-2 text-sm font-medium transition-colors"
:style="mobileMapView === 'outremer'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="mobileMapView = 'outremer'"
@click="setMobileView('outremer')"
>Outre-mer</button>
<button
class="flex-1 py-2 text-sm font-medium transition-colors"
:style="mobileMapView === 'fiches'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="setMobileView('fiches')"
>Fiches</button>
</div>
<div class="lg:hidden flex-1 relative overflow-hidden">
@@ -154,9 +193,12 @@
</ClientOnly>
</div>
<!-- Bottom sheet swipable (Métropole et Outre-mer) -->
<!-- Fond neutre pour l'onglet « Fiches » (pas de carte dessous) -->
<div v-show="mobileMapView === 'fiches'" class="absolute inset-0" style="background: var(--nav-bg-alt);" />
<!-- Bottom sheet swipable (Métropole, Outre-mer et Fiches) -->
<ClientOnly>
<MobileSheet :resultCount="filtered.length" :pending="pending">
<MobileSheet v-if="mobileMapView !== 'fiches'" :resultCount="filtered.length" :pending="pending">
<!-- Barre recherche -->
<div class="px-3 pt-2 pb-2" style="border-bottom: 1px solid var(--nav-bg-alt);">
<label class="mobile-search-label" aria-label="Rechercher une organisation">
@@ -272,6 +314,19 @@
</div>
</div>
</MobileSheet>
<!-- Bottom sheet — onglet « Fiches » mobile (B3-M3), contenu dédié -->
<MobileSheet v-else :resultCount="filtered.length" :pending="pending">
<FichesPanel
:fiches="allFichesMini"
:hashtags="[...FONCTIONS]"
:selected="fonctions"
:query="search"
intention="Toutes les structures d'entraide, y compris celles sans adresse sur la carte. Cochez une fonction pour filtrer."
@update:selected="onFonctions"
@open="(fiche) => onSelectOrgMobile(Number(fiche.id))"
/>
</MobileSheet>
</ClientOnly>
</div>
@@ -358,12 +413,14 @@ const territoireMode = ref<string>(
(route.query.mode as string) === 'outremer' ? 'outremer' : 'metropole'
)
const desktopMapView = ref<'metropole' | 'outremer'>('metropole')
type MapView = 'metropole' | 'outremer' | 'fiches'
const desktopMapView = ref<MapView>((route.query.vue as string) === 'fiches' ? 'fiches' : 'metropole')
const selectedId = ref<number | null>(null)
const chatbotOpen = ref(false)
const ficheModalOpen = ref(false)
const ficheModalId = ref<number | null>(null)
const mobileMapView = ref<'metropole' | 'outremer'>('metropole')
const mobileMapView = ref<MapView>((route.query.vue as string) === 'fiches' ? 'fiches' : 'metropole')
const missionOpen = ref(false)
onMounted(() => {
@@ -411,9 +468,20 @@ function syncUrl() {
if (fonctions.value.length) q.fonctions = fonctions.value.join(',')
if (territoire.value) q.territoire = territoire.value
if (territoireMode.value === 'outremer') q.mode = 'outremer'
if (desktopMapView.value === 'fiches' || mobileMapView.value === 'fiches') q.vue = 'fiches'
router.replace({ query: Object.keys(q).length ? q : undefined })
}
// Changement de vue (carte/fiches) — passe par syncUrl pour poser/retirer ?vue=fiches
function setDesktopView(v: MapView) {
desktopMapView.value = v
syncUrl()
}
function setMobileView(v: MapView) {
mobileMapView.value = v
syncUrl()
}
// Sauvegarde les filtres courants dans sessionStorage pour le bouton retour des fiches
function storeFiltersForBack() {
if (typeof window === 'undefined') return
@@ -541,6 +609,12 @@ const filtered = computed<Org[]>(() => {
return result
})
// Panneau « Toutes les fiches » (B3-M3) — mêmes fiches que `filtered`, sans
// filtrer par géolocalisation : c'est le gain principal de cette mission,
// les fiches sans latitude/longitude (invisibles sur la carte) y apparaissent.
const { toMini } = useFicheAdapter()
const allFichesMini = computed(() => filtered.value.map((org) => toMini(org)))
const DOM_TOM = ['Guadeloupe', 'Martinique', 'Guyane', 'La Réunion', 'Mayotte']
const DOM_TOM_LIST = DOM_TOM