Files
nav-carte/pages/index.vue
T

281 lines
11 KiB
Vue

<template>
<div class="relative h-full overflow-hidden" style="background: var(--nav-bg);">
<!-- Indicateur source dev -->
<div
v-if="dataSource === 'seed'"
class="absolute bottom-2 left-1/2 -translate-x-1/2 z-[1002] px-2 py-1 rounded text-xs pointer-events-none"
style="background: var(--nav-accent); color: var(--nav-text);"
>
Mode dev — données seed
</div>
<!-- ═══════════════════════════════════════ VUE FICHES (B11) : Carte · Mixte · Fiches -->
<VueFiches
:vf="vf"
intention="Les structures d'entraide du métier, y compris celles sans adresse sur la carte."
libelle-recherche="Rechercher une structure…"
:pending="pending"
:selected-id="selectedId"
:lien-page="(f) => `/fiche/${f.id}`"
>
<template #carte>
<!-- Métropole -->
<div v-show="vf.etat.sousVue === 'metropole'" class="relative flex-1 min-h-0">
<ClientOnly>
<NavMap
:orgs="metropoleOrgs"
:selectedId="selectedId"
:survolId="survolNum"
:centrage="centrageMetropole"
@select-org="onSelectOrg"
@survol="(id) => (vf.survolId = id)"
/>
<template #fallback>
<div class="w-full h-full flex items-center justify-center" style="background: var(--nav-bg-alt); color: var(--nav-text-muted);">Chargement de la carte…</div>
</template>
</ClientOnly>
</div>
<!-- Outre-mer -->
<div v-show="vf.etat.sousVue === 'outremer'" class="flex-1 min-h-0 overflow-y-auto" style="background: var(--nav-bg);">
<ClientOnly>
<OutremerMap
:orgs="outremerOrgs"
:selectedId="selectedId"
@select-org="onSelectOrg"
/>
<template #fallback>
<div class="flex items-center justify-center h-48 text-sm" style="color: var(--nav-text-muted);">Chargement…</div>
</template>
</ClientOnly>
</div>
<!-- Chatbot sous la carte (ordinateur) -->
<div class="hidden lg:block shrink-0">
<ChatbotPlaceholder @highlightOrgs="onHighlightOrgs" />
</div>
</template>
<!-- Détail (B12) : la fiche complète vient de la liste (même enregistrement NocoDB que /api/fiche/:id) -->
<template #detail="{ fiche, filtrer }">
<template v-if="orgParId.get(String(fiche.id))">
<FicheDetail
:key="fiche.id"
:org="orgParId.get(String(fiche.id))!"
compact
etiquettes-cliquables
@etiquette="(t) => filtrer('fonctions', t)"
/>
<CommentSection :key="`c-${fiche.id}`" :org-id="Number(fiche.id)" :refresh="commentaires" />
<CommentForm :key="`f-${fiche.id}`" :org-id="Number(fiche.id)" @submitted="commentaires++" />
</template>
</template>
</VueFiches>
<!-- ═══════════════════════════════════════ BOUTON CHATBOT FLOTTANT (mobile) -->
<button
class="lg:hidden fixed right-4 z-[1000] flex items-center gap-2 px-4 rounded-full shadow-lg"
:style="`
bottom: ${basFlottants}px;
height: 48px;
background: var(--nav-primary-solid);
color: var(--nav-text-on-primary);
box-shadow: 0 4px 16px rgba(26,34,56,0.25);
font-family: var(--nav-font);
font-size: 0.875rem;
font-weight: 600;
`"
aria-label="Ouvrir l'assistant Chatbot"
@click="chatbotOpen = true"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
<span>Chatbot</span>
</button>
<!-- ═══════════════════════════════════════ CHATBOT BOTTOM SHEET (mobile) -->
<ChatbotSheet
:modelValue="chatbotOpen"
@update:modelValue="chatbotOpen = $event"
@highlightOrgs="onHighlightOrgs"
/>
<!-- ═══════════════════════════════════════ POP-UP MISSION ENTRAIDE -->
<button
class="mission-info-btn"
type="button"
:style="vf.largeur === 'telephone' ? `bottom: ${basFlottants}px;` : undefined"
@click="missionOpen = true"
aria-label="À propos de cette carte d'entraide"
title="À propos de cette carte"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="16" x2="12" y2="12"/>
<line x1="12" y1="8" x2="12.01" y2="8"/>
</svg>
</button>
<MissionPopup
:modelValue="missionOpen"
@update:modelValue="missionOpen = $event"
echelle="M"
/>
</div>
</template>
<script setup lang="ts">
import type { Org } from '~/types/org'
import type { ConfigVueFiches } from '~/utils/vueFiches'
import { DOM_TOM } from '~/composables/useFicheAdapter'
const route = useRoute()
const router = useRouter()
// ── Données ───────────────────────────────────────────────────────────────
const { data, pending } = await useFetch<{ list: Org[]; source: string }>('/api/organisations')
const orgs = computed<Org[]>(() => data.value?.list ?? [])
const dataSource = computed(() => data.value?.source ?? 'nocodb')
// `territoire=` (lien existant) : pré-filtre de page, hors de la vue fiches
const orgsTerritoire = computed<Org[]>(() => {
const t = route.query.territoire
return typeof t === 'string' && t ? orgs.value.filter((o) => o.territoire === t) : orgs.value
})
// ── Vue fiches (B11) : l'état vit dans l'URL ──────────────────────────────
const FONCTIONS = ['Juridique', 'Technique', 'Économique', 'Administratif', 'Chantier', 'Comptabilité', 'Développement', 'Formation', "Gestion d'agence", 'Santé mentale']
const ECHELLES = ['National', 'Régional', 'Local']
const enValeurs = (l: string[]) => l.map((v) => ({ valeur: v, libelle: v }))
const config: ConfigVueFiches = {
dimensions: [
{ cle: 'fonctions', libelle: 'Fonction', variante: 'fonction', valeurs: enValeurs(FONCTIONS) },
{ cle: 'echelle', libelle: 'Échelle', variante: 'echelle', valeurs: enValeurs(ECHELLES) },
],
tris: ['pertinence', 'nom', 'recent'],
groupes: [
{ cle: 'fonction', libelle: 'Fonction', dimension: 'fonctions', sans: 'Sans fonction' },
{ cle: 'echelle', libelle: 'Échelle', dimension: 'echelle', sans: 'Sans échelle' },
{ cle: 'territoire', libelle: 'Territoire', dimension: 'territoire', sans: 'Sans territoire', valeurs: enValeurs(['Métropole', ...DOM_TOM]) },
],
etats: ['carte', 'mixte', 'fiches'],
sousVues: {
parametre: 'mode',
defaut: 'metropole',
valeurs: [{ valeur: 'metropole', libelle: 'Métropole' }, { valeur: 'outremer', libelle: 'Outre-mer' }],
},
}
const { toMini } = useFicheAdapter()
const fichesMini = computed(() => orgsTerritoire.value.map((o) => toMini(o)))
const vf = useVueFiches(fichesMini, config)
// La carte ne montre que le résultat courant (recherche + filtres)
const orgsFiltres = computed<Org[]>(() => orgsTerritoire.value.filter((o) => vf.idsResultat.has(String(o.Id))))
const metropoleOrgs = computed<Org[]>(() => orgsFiltres.value.filter((o) => !o.territoire || !DOM_TOM.includes(o.territoire)))
const outremerOrgs = computed<Org[]>(() => orgsFiltres.value.filter((o) => o.territoire && DOM_TOM.includes(o.territoire)))
const survolNum = computed(() => (vf.survolId == null ? null : Number(vf.survolId)))
// ── Sélection (B12) : la fiche ouverte vit dans l'URL (`fiche=`), détail en panneau ──
const orgParId = computed(() => new Map(orgs.value.map((o) => [String(o.Id), o])))
// Épingle et carte-fiche allumées : la fiche ouverte, ou 5 s celle que le chatbot désigne
const surligneId = ref<number | null>(null)
const selectedId = computed<number | null>(() => surligneId.value ?? (vf.ficheId != null ? Number(vf.ficheId) : null))
const commentaires = ref(0)
function onSelectOrg(id: number) {
vf.ouvrirFiche(id)
}
// « Voir sur la carte » : la bonne sous-vue (Métropole ou Outre-mer), puis l'épingle
const centrageMetropole = ref<{ id: number; n: number } | null>(null)
watch(() => vf.centrage, (c) => {
if (!c) return
const o = orgParId.value.get(c.id)
const dom = !!o?.territoire && DOM_TOM.includes(o.territoire)
if (vf.etat.sousVue !== (dom ? 'outremer' : 'metropole')) vf.setSousVue(dom ? 'outremer' : 'metropole')
if (!dom) centrageMetropole.value = { id: Number(c.id), n: c.n }
})
// Le bouton retour de /fiche/:id (« Ouvrir la page de la fiche ») revient à la même vue
if (import.meta.client) {
watch(() => route.fullPath, () => {
try {
sessionStorage.setItem('nav_back_filters', new URLSearchParams(route.query as Record<string, string>).toString())
} catch {}
}, { immediate: true })
}
// Surlignage temporaire (5 s) suite à une réponse chatbot
let highlightTimer: ReturnType<typeof setTimeout> | null = null
function onHighlightOrgs(ids: (number | string)[]) {
if (!ids.length) return
const firstId = typeof ids[0] === 'string' ? parseInt(ids[0], 10) : ids[0]
if (isNaN(firstId)) return
surligneId.value = firstId
if (highlightTimer) clearTimeout(highlightTimer)
highlightTimer = setTimeout(() => {
surligneId.value = null
highlightTimer = null
}, 5000)
}
// Fiche aléatoire — ?random=1, au chargement comme en navigation interne
if (import.meta.client) {
watch([() => route.query.random, () => orgs.value.length], ([v, n]) => {
if (v === '1' && n > 0) {
const randomOrg = orgs.value[Math.floor(Math.random() * n)]
router.replace({ path: `/fiche/${randomOrg.Id}` })
}
}, { immediate: true })
}
// ── Boutons flottants (téléphone) : au-dessus du pied « Voir les N fiches » en état Carte ──
const chatbotOpen = ref(false)
const missionOpen = ref(false)
const basFlottants = computed(() => (vf.largeur === 'telephone' && vf.vueAffichee === 'carte' ? 76 : 24))
onMounted(() => {
try {
if (!localStorage.getItem('aep_mission_seen')) missionOpen.value = true
} catch {}
})
// Un lien partagé avec fiche= porte le nom de la fiche (onglet, aperçu de lien), dès le rendu serveur
useHead({ title: computed(() => (vf.ficheOuverte ? `${vf.ficheOuverte.nom} — AEP` : 'AEP — Cartographie de l\'écologie politique architecturale')) })
</script>
<style scoped>
.mission-info-btn {
position: fixed;
bottom: 24px;
left: 16px;
z-index: 1000;
width: 36px;
height: 36px;
border-radius: 50%;
border: none;
background: var(--nav-surface);
color: var(--nav-text-muted);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 12px rgba(26,34,56,0.18);
cursor: pointer;
transition: opacity 0.15s, transform 0.1s;
}
.mission-info-btn:hover { opacity: 0.85; transform: translateY(-1px); color: var(--nav-text); }
@media (min-width: 1024px) {
.mission-info-btn { bottom: 10px; left: auto; right: 52px; }
}
</style>