initial: bootstrap from BukidBountyApp base
This commit is contained in:
352
resources/js/Pages/Fragments/Home/OrgHierarchyExplorer.vue
Normal file
352
resources/js/Pages/Fragments/Home/OrgHierarchyExplorer.vue
Normal file
@@ -0,0 +1,352 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, nextTick, watch } from 'vue';
|
||||
import { useChapters } from '../../../composables/useChapters.js';
|
||||
|
||||
const { loading, fetchOrgHierarchy, fetchOrgMapData } = useChapters();
|
||||
|
||||
const view = ref('list'); // 'list' | 'map'
|
||||
|
||||
// Stack of nodes representing breadcrumb trail.
|
||||
// Each entry: { id, island, name, level }. id is null for island groups.
|
||||
const trail = ref([]);
|
||||
const currentNode = ref(null);
|
||||
const chapterList = ref([]);
|
||||
const mapDots = ref([]);
|
||||
const errorMessage = ref(null);
|
||||
|
||||
const mapContainer = ref(null);
|
||||
let leafletMap = null;
|
||||
let markerLayer = null;
|
||||
|
||||
const PH_CENTER = [12.0, 122.5];
|
||||
const PH_ZOOM = 6;
|
||||
|
||||
const LEVEL_LABELS = {
|
||||
island_group: 'Island Group',
|
||||
region: 'Region',
|
||||
province: 'Province',
|
||||
city: 'City / Municipality',
|
||||
barangay: 'Barangay',
|
||||
};
|
||||
|
||||
const NEXT_LEVEL = {
|
||||
root: 'island_group',
|
||||
island_group: 'region',
|
||||
region: 'province',
|
||||
province: 'city',
|
||||
city: 'barangay',
|
||||
};
|
||||
|
||||
function currentLevel() {
|
||||
return currentNode.value?.level ?? 'root';
|
||||
}
|
||||
function nextLevel() {
|
||||
return NEXT_LEVEL[currentLevel()] ?? 'barangay';
|
||||
}
|
||||
|
||||
async function loadRoot() {
|
||||
trail.value = [];
|
||||
currentNode.value = null;
|
||||
await fetchData({});
|
||||
}
|
||||
|
||||
async function drillIntoIsland(island, name) {
|
||||
trail.value = [{ id: null, island, name, level: 'island_group' }];
|
||||
currentNode.value = trail.value[0];
|
||||
await fetchData({ island });
|
||||
}
|
||||
|
||||
async function drillIntoChapter(chapter) {
|
||||
if (chapter.level === 'island_group') {
|
||||
return drillIntoIsland(chapter.island ?? chapter.location_key, chapter.name);
|
||||
}
|
||||
if (!chapter.has_children) return;
|
||||
trail.value = [...trail.value, { id: chapter.id, name: chapter.name, level: chapter.level }];
|
||||
currentNode.value = chapter;
|
||||
await fetchData({ chapterId: chapter.id });
|
||||
}
|
||||
|
||||
async function navigateBreadcrumb(index) {
|
||||
// index === -1 means "Philippines / root"
|
||||
if (index < 0) return loadRoot();
|
||||
const target = trail.value[index];
|
||||
trail.value = trail.value.slice(0, index + 1);
|
||||
currentNode.value = target;
|
||||
if (target.level === 'island_group') {
|
||||
await fetchData({ island: target.island });
|
||||
} else {
|
||||
await fetchData({ chapterId: target.id });
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchData({ chapterId = null, island = null } = {}) {
|
||||
errorMessage.value = null;
|
||||
const hier = await fetchOrgHierarchy({ chapterId, island });
|
||||
if (hier.error) errorMessage.value = hier.error;
|
||||
chapterList.value = hier.chapters ?? [];
|
||||
|
||||
// Map data: drilling shows the next level down within the current scope.
|
||||
let mapPayload;
|
||||
if (chapterId) {
|
||||
mapPayload = { level: nextLevel(), parentId: chapterId };
|
||||
} else if (island) {
|
||||
mapPayload = { level: 'region', island };
|
||||
} else {
|
||||
mapPayload = { level: 'island_group' };
|
||||
}
|
||||
const md = await fetchOrgMapData(mapPayload);
|
||||
mapDots.value = md.chapters ?? [];
|
||||
|
||||
if (leafletMap) {
|
||||
await renderMarkers();
|
||||
flyToCurrent();
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureLeaflet() {
|
||||
const L = (await import('leaflet')).default;
|
||||
await import('leaflet/dist/leaflet.css');
|
||||
return L;
|
||||
}
|
||||
|
||||
async function initMap() {
|
||||
if (!mapContainer.value || leafletMap) return;
|
||||
const L = await ensureLeaflet();
|
||||
leafletMap = L.map(mapContainer.value, {
|
||||
center: PH_CENTER,
|
||||
zoom: PH_ZOOM,
|
||||
zoomControl: true,
|
||||
scrollWheelZoom: true,
|
||||
});
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
maxZoom: 18,
|
||||
}).addTo(leafletMap);
|
||||
markerLayer = L.layerGroup().addTo(leafletMap);
|
||||
await renderMarkers();
|
||||
flyToCurrent();
|
||||
}
|
||||
|
||||
async function renderMarkers() {
|
||||
if (!markerLayer) return;
|
||||
const L = await ensureLeaflet();
|
||||
markerLayer.clearLayers();
|
||||
|
||||
mapDots.value
|
||||
.filter(d => d.lat && d.lng)
|
||||
.forEach(dot => {
|
||||
const radius = Math.max(10, Math.min(45, Math.sqrt((dot.count || 0) + 1) * 6));
|
||||
const circle = L.circleMarker([dot.lat, dot.lng], {
|
||||
radius,
|
||||
fillColor: '#198754',
|
||||
color: '#fff',
|
||||
weight: 2,
|
||||
opacity: 0.9,
|
||||
fillOpacity: 0.7,
|
||||
}).addTo(markerLayer);
|
||||
|
||||
circle.bindTooltip(
|
||||
`<b>${dot.name}</b><br>${dot.count} member${dot.count !== 1 ? 's' : ''}`,
|
||||
{ direction: 'top' }
|
||||
);
|
||||
|
||||
circle.on('click', () => {
|
||||
if (dot.level === 'island_group') {
|
||||
drillIntoIsland(dot.island, dot.name);
|
||||
} else {
|
||||
// Find matching chapter row in the list (which carries has_children)
|
||||
const match = chapterList.value.find(c => c.id === dot.id);
|
||||
if (match) drillIntoChapter(match);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function flyToCurrent() {
|
||||
if (!leafletMap) return;
|
||||
const node = currentNode.value;
|
||||
if (!node) {
|
||||
leafletMap.flyTo(PH_CENTER, PH_ZOOM, { duration: 0.8 });
|
||||
return;
|
||||
}
|
||||
if (node.level === 'island_group') {
|
||||
const dot = mapDots.value[0];
|
||||
// Center on the island roughly via known centers; fall back to PH center.
|
||||
const ISLAND_CENTERS = { luzon: [16.5, 121.0], visayas: [11.0, 123.5], mindanao: [7.5, 124.5] };
|
||||
const c = ISLAND_CENTERS[node.island] ?? PH_CENTER;
|
||||
leafletMap.flyTo(c, 7, { duration: 0.8 });
|
||||
return;
|
||||
}
|
||||
if (node.lat && node.lng) {
|
||||
leafletMap.flyTo([node.lat, node.lng], 9, { duration: 0.8 });
|
||||
}
|
||||
}
|
||||
|
||||
watch(view, async (v) => {
|
||||
if (v === 'map') {
|
||||
await nextTick();
|
||||
await initMap();
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
loadRoot();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (leafletMap) {
|
||||
leafletMap.remove();
|
||||
leafletMap = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="org-hierarchy-explorer">
|
||||
<!-- Header / tabs -->
|
||||
<div class="d-flex align-items-center justify-content-between mb-2">
|
||||
<div>
|
||||
<h6 class="fw_7 mb-0">Member Distribution</h6>
|
||||
<div class="text-muted small">
|
||||
{{ currentNode ? currentNode.name : 'Philippines — by Island Group' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-1 bg-soft-success p-1 rounded-pill">
|
||||
<button
|
||||
@click="view = 'list'"
|
||||
:class="['btn btn-xs rounded-pill px-3', view === 'list' ? 'btn-success shadow-sm' : 'btn-transparent text-success small']"
|
||||
>Drill</button>
|
||||
<button
|
||||
@click="view = 'map'"
|
||||
:class="['btn btn-xs rounded-pill px-3', view === 'map' ? 'btn-success shadow-sm' : 'btn-transparent text-success small']"
|
||||
>Map</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Breadcrumb -->
|
||||
<nav aria-label="breadcrumb" class="mb-2">
|
||||
<ol class="breadcrumb mb-0 small">
|
||||
<li class="breadcrumb-item">
|
||||
<a href="#" @click.prevent="navigateBreadcrumb(-1)" class="text-success">Philippines</a>
|
||||
</li>
|
||||
<li
|
||||
v-for="(crumb, idx) in trail"
|
||||
:key="idx"
|
||||
class="breadcrumb-item"
|
||||
:class="{ active: idx === trail.length - 1 }"
|
||||
>
|
||||
<a v-if="idx !== trail.length - 1" href="#" @click.prevent="navigateBreadcrumb(idx)" class="text-success">{{ crumb.name }}</a>
|
||||
<span v-else>{{ crumb.name }}</span>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div v-if="errorMessage" class="alert alert-warning py-2 small mb-2">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<!-- Map view -->
|
||||
<div v-show="view === 'map'" class="mb-2">
|
||||
<div
|
||||
ref="mapContainer"
|
||||
class="org-map rounded-3 overflow-hidden"
|
||||
style="height: 320px; width: 100%; background: #e8f5e9;"
|
||||
/>
|
||||
<div class="d-flex align-items-center gap-2 small text-muted mt-1">
|
||||
<span class="d-inline-block rounded-circle bg-success" style="width:12px;height:12px;opacity:.7;"></span>
|
||||
<span>Each dot = members in this scope. Click to drill in.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="text-center py-3">
|
||||
<div class="spinner-border spinner-border-sm text-success" role="status"></div>
|
||||
<span class="ms-2 small text-muted">Loading...</span>
|
||||
</div>
|
||||
|
||||
<!-- List view -->
|
||||
<div v-else-if="view === 'list'">
|
||||
<div class="d-flex align-items-center justify-content-between mb-2">
|
||||
<h6 class="fw_6 mb-0">
|
||||
{{ LEVEL_LABELS[nextLevel()] }}s
|
||||
<span v-if="currentNode" class="text-muted fw_4">
|
||||
in {{ currentNode.name }}
|
||||
</span>
|
||||
</h6>
|
||||
<span class="badge bg-success-subtle text-success rounded-pill">{{ chapterList.length }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="!chapterList.length" class="text-center py-4 text-muted small">
|
||||
No {{ (LEVEL_LABELS[nextLevel()] || '').toLowerCase() }}s with members here yet.
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="chapter in chapterList"
|
||||
:key="chapter.hashkey || chapter.id || chapter.island"
|
||||
class="chapter-row d-flex align-items-center gap-3 p-3 mb-2 rounded-3 border bg-white"
|
||||
:class="{ 'chapter-row--clickable': chapter.has_children }"
|
||||
@click="chapter.has_children ? drillIntoChapter(chapter) : null"
|
||||
role="button"
|
||||
>
|
||||
<div
|
||||
class="chapter-count d-flex align-items-center justify-content-center rounded-circle bg-success text-white fw_7"
|
||||
style="min-width:48px;height:48px;font-size:0.9rem;"
|
||||
>
|
||||
{{ chapter.member_count }}
|
||||
</div>
|
||||
<div class="flex-grow-1 overflow-hidden">
|
||||
<div class="fw_6 text-truncate">{{ chapter.name }}</div>
|
||||
<div class="small text-muted">
|
||||
{{ chapter.member_count }} member{{ chapter.member_count !== 1 ? 's' : '' }}
|
||||
</div>
|
||||
<div v-if="chapter.leaders?.length" class="mt-1 d-flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="leader in chapter.leaders.slice(0, 3)"
|
||||
:key="leader.hashkey"
|
||||
class="badge bg-light text-dark border small"
|
||||
>
|
||||
<span v-if="leader.photo" class="me-1">
|
||||
<img :src="leader.photo" class="rounded-circle" width="14" height="14" style="object-fit:cover;">
|
||||
</span>
|
||||
{{ leader.name }}
|
||||
<span v-if="leader.position" class="text-muted"> · {{ leader.position }}</span>
|
||||
</span>
|
||||
<span v-if="chapter.leaders.length > 3" class="badge bg-light text-muted border small">
|
||||
+{{ chapter.leaders.length - 3 }} more
|
||||
</span>
|
||||
</div>
|
||||
<div v-else-if="chapter.level !== 'island_group'" class="small text-muted fst-italic mt-1">
|
||||
No leaders assigned
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="chapter.has_children" class="text-muted">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chapter-row--clickable {
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.chapter-row--clickable:hover {
|
||||
background: #f8f9fa !important;
|
||||
}
|
||||
.org-map {
|
||||
border: 1px solid #dee2e6;
|
||||
}
|
||||
.btn-xs {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
border-radius: 50rem;
|
||||
}
|
||||
.bg-soft-success {
|
||||
background-color: rgba(25, 135, 84, 0.1);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user