Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AGNSEFZdoKvbdsGyfSnu5S
281 lines
11 KiB
Vue
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>
|