initial: bootstrap from BukidBountyApp base
This commit is contained in:
378
resources/js/Pages/Fragments/Home/HomeCooperative.vue
Normal file
378
resources/js/Pages/Fragments/Home/HomeCooperative.vue
Normal file
@@ -0,0 +1,378 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, computed, watch, nextTick } from 'vue';
|
||||
import usePageData from '../../../composables/usePageData.js';
|
||||
import { useNavigate } from '../../../composables/Core/useNavigate.js';
|
||||
import { useAuth } from '../../../composables/Core/useAuth.js';
|
||||
import { useModal } from '../../../composables/Core/useModal.js';
|
||||
|
||||
import BalanceBox from '../../../Components/Core/Stats/BalanceBox.vue';
|
||||
import ServiceButtonGrid from '../../../Components/Core/Services/ServiceButtonGrid.vue';
|
||||
import SideTextButtonList from '../../../Components/Core/Services/SideTextButtonList.vue';
|
||||
import HomeSkeleton from '../../../Components/Core/Skeleton/HomeSkeleton.vue';
|
||||
|
||||
import { useChapters } from '../../../composables/useChapters.js';
|
||||
|
||||
const { user } = useAuth();
|
||||
const { navigate } = useNavigate();
|
||||
const modal = useModal();
|
||||
|
||||
const { data, loading, error, fetchPageData } = usePageData();
|
||||
const { fetchHierarchy, fetchMapData } = useChapters();
|
||||
|
||||
const stats = ref([
|
||||
{ title: 'Cooperatives', number: 0, unit: 'Total', numberId: 'cooperative_total_no' },
|
||||
{ title: 'Members', number: 0, unit: 'Enrolled', numberId: 'cooperative_members_no' },
|
||||
{ title: 'New (7d)', number: 0, unit: 'Members', numberId: 'pending_members_no' },
|
||||
]);
|
||||
|
||||
const footerItems = ref([
|
||||
{ title: 'Cooperatives', icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/04d0e432a298.bin', pagename: 'CooperativeList' },
|
||||
{ title: 'My Profile', icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/ac7a1cebe580.bin', pagename: 'UserInfoEdit' },
|
||||
]);
|
||||
|
||||
const services = ref([
|
||||
{ icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/04d0e432a298.bin', title: 'Cooperatives', pagename: 'CooperativeList' },
|
||||
{ icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/04d0e432a298.bin', title: 'Register Member', pagename: 'CooperativeMemberRegister' },
|
||||
{ icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/85605eacd4c8.bin', title: 'My Cooperative', action: 'viewMyCoop' },
|
||||
{ icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/f87407046b18.bin', title: 'Reports', pagename: 'ListReports' },
|
||||
{ icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/c9fd442fe676.bin', title: 'Documents', pagename: 'CooperativeList' },
|
||||
{ icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/c9fd442fe676.bin', title: 'Members', pagename: 'CooperativeList' },
|
||||
{ icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/04d0e432a298.bin', title: 'Create Coop', pagename: 'CreateCooperative' },
|
||||
{ icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/c9fd442fe676.bin', title: 'Chapter Map', action: 'toggleMap' },
|
||||
]);
|
||||
|
||||
const quickActions = ref([
|
||||
{ text: 'Create Cooperative', pagename: 'CreateCooperative', icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/04d0e432a298.bin' },
|
||||
{ text: 'Member Ledger', pagename: 'AccountingDashboard', icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/c9fd442fe676.bin' },
|
||||
{ text: 'Add Transaction', pagename: 'AddTransaction', icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/c9fd442fe676.bin' },
|
||||
{ text: 'My Personal Profile', pagename: 'UserInfoEdit', icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/ac7a1cebe580.bin' },
|
||||
]);
|
||||
|
||||
const activeOrgHash = computed(() => user.value?.settings?.cooperatives?.[0] ?? null);
|
||||
|
||||
const showMap = ref(false);
|
||||
|
||||
const applyStats = () => {
|
||||
if (!data.value?.stats) return;
|
||||
stats.value = stats.value.map((s) => {
|
||||
const v = data.value.stats[s.numberId];
|
||||
return v !== undefined ? { ...s, number: v } : s;
|
||||
});
|
||||
};
|
||||
|
||||
const viewMyCoop = () => {
|
||||
if (activeOrgHash.value) {
|
||||
navigate({ page: 'CooperativeDetail', props: { target: activeOrgHash.value } });
|
||||
} else {
|
||||
modal.quickDismiss({ title: 'No Cooperative', body: 'You have not joined a cooperative yet.' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleItemClick = (item) => {
|
||||
if (item?.action === 'viewMyCoop') {
|
||||
viewMyCoop();
|
||||
return;
|
||||
}
|
||||
if (item?.action === 'toggleMap') {
|
||||
showMap.value = !showMap.value;
|
||||
return;
|
||||
}
|
||||
if (item?.pagename) {
|
||||
navigate({ page: item.pagename });
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchPageData('/home-data', {});
|
||||
applyStats();
|
||||
});
|
||||
|
||||
// ---- Chapter Map (Leaflet) ----
|
||||
const mapContainer = ref(null);
|
||||
const currentChapter = ref(null);
|
||||
const breadcrumb = ref([]);
|
||||
const chapterList = ref([]);
|
||||
const mapDots = ref([]);
|
||||
|
||||
let leafletMap = null;
|
||||
let markerLayer = null;
|
||||
|
||||
const LEVEL_LABELS = {
|
||||
national: 'Philippines',
|
||||
region: 'Region',
|
||||
province: 'Province',
|
||||
city: 'City / Municipality',
|
||||
barangay: 'Barangay',
|
||||
};
|
||||
|
||||
const NEXT_LEVEL = {
|
||||
national: 'region',
|
||||
region: 'province',
|
||||
province: 'city',
|
||||
city: 'barangay',
|
||||
};
|
||||
|
||||
const currentLevel = () => currentChapter.value?.level ?? 'national';
|
||||
const nextLevel = () => NEXT_LEVEL[currentLevel()] ?? 'barangay';
|
||||
|
||||
const PH_CENTER = [12.0, 122.5];
|
||||
const PH_ZOOM = 6;
|
||||
|
||||
async function initMap() {
|
||||
if (!mapContainer.value) return;
|
||||
|
||||
const L = (await import('leaflet')).default;
|
||||
await import('leaflet/dist/leaflet.css');
|
||||
|
||||
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);
|
||||
|
||||
renderMarkers(L);
|
||||
}
|
||||
|
||||
async function renderMarkers(L) {
|
||||
if (!markerLayer || !L) return;
|
||||
markerLayer.clearLayers();
|
||||
|
||||
const dots = mapDots.value.filter(d => d.lat && d.lng);
|
||||
if (!dots.length) return;
|
||||
|
||||
dots.forEach(dot => {
|
||||
const radius = Math.max(8, Math.min(40, Math.sqrt(dot.count + 1) * 5));
|
||||
const circle = L.circleMarker([dot.lat, dot.lng], {
|
||||
radius,
|
||||
fillColor: '#198754',
|
||||
color: '#fff',
|
||||
weight: 2,
|
||||
opacity: 0.9,
|
||||
fillOpacity: 0.75,
|
||||
}).addTo(markerLayer);
|
||||
|
||||
circle.bindTooltip(`<b>${dot.name}</b><br>${dot.count} member${dot.count !== 1 ? 's' : ''}`, {
|
||||
permanent: false,
|
||||
direction: 'top',
|
||||
});
|
||||
|
||||
circle.on('click', () => drillDown(dot.id));
|
||||
});
|
||||
}
|
||||
|
||||
async function loadLevel(chapterId = null) {
|
||||
const hierarchyData = await fetchHierarchy({ chapterId });
|
||||
|
||||
currentChapter.value = hierarchyData.current ?? null;
|
||||
breadcrumb.value = hierarchyData.breadcrumb ?? [];
|
||||
chapterList.value = hierarchyData.chapters ?? [];
|
||||
|
||||
const mapData = await fetchMapData({ level: nextLevel(), parentId: chapterId });
|
||||
mapDots.value = mapData.chapters ?? [];
|
||||
|
||||
if (leafletMap) {
|
||||
const L = (await import('leaflet')).default;
|
||||
if (chapterId && hierarchyData.current?.lat && hierarchyData.current?.lng) {
|
||||
leafletMap.flyTo([hierarchyData.current.lat, hierarchyData.current.lng], 9, { duration: 1 });
|
||||
} else {
|
||||
leafletMap.flyTo(PH_CENTER, PH_ZOOM, { duration: 1 });
|
||||
}
|
||||
await renderMarkers(L);
|
||||
}
|
||||
}
|
||||
|
||||
async function drillDown(chapterId) {
|
||||
await loadLevel(chapterId);
|
||||
}
|
||||
|
||||
async function navigateBreadcrumb(crumb) {
|
||||
await loadLevel(crumb?.id ?? null);
|
||||
}
|
||||
|
||||
async function resetToNational() {
|
||||
await loadLevel(null);
|
||||
}
|
||||
|
||||
watch(showMap, async (val) => {
|
||||
if (val && !leafletMap) {
|
||||
await nextTick();
|
||||
await loadLevel(null);
|
||||
await nextTick();
|
||||
await initMap();
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (leafletMap) {
|
||||
leafletMap.remove();
|
||||
leafletMap = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="home-cooperative-fragment pb-5">
|
||||
<HomeSkeleton v-if="loading" />
|
||||
|
||||
<template v-else>
|
||||
<BalanceBox
|
||||
:stats="stats"
|
||||
:footer-items="footerItems"
|
||||
@footer-click="handleItemClick"
|
||||
/>
|
||||
|
||||
<div class="tf-container mt-4">
|
||||
<h5 class="fw_7 mb-3">Cooperative Services</h5>
|
||||
<ServiceButtonGrid :items="services" @item-click="handleItemClick" />
|
||||
</div>
|
||||
|
||||
<div class="tf-container mt-4">
|
||||
<h5 class="fw_7 mb-3">Quick Actions</h5>
|
||||
<SideTextButtonList :items="quickActions" @item-click="handleItemClick" />
|
||||
</div>
|
||||
|
||||
<div class="tf-container mt-4">
|
||||
<button class="btn btn-outline-secondary w-100 mb-3" @click="showMap = !showMap">
|
||||
{{ showMap ? 'Hide' : 'Show' }} Chapter Map
|
||||
</button>
|
||||
|
||||
<div v-show="showMap">
|
||||
<div class="mb-2">
|
||||
<h5 class="fw_7 mb-1">Organizational Map</h5>
|
||||
<p class="text-muted small mb-0">
|
||||
{{ currentChapter ? currentChapter.name : 'Philippines — National View' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="breadcrumb.length || currentChapter" class="mb-2">
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb mb-0 small">
|
||||
<li class="breadcrumb-item">
|
||||
<a href="#" @click.prevent="resetToNational()" class="text-success">Philippines</a>
|
||||
</li>
|
||||
<li
|
||||
v-for="crumb in breadcrumb"
|
||||
:key="crumb.id"
|
||||
class="breadcrumb-item"
|
||||
>
|
||||
<a href="#" @click.prevent="navigateBreadcrumb(crumb)" class="text-success">{{ crumb.name }}</a>
|
||||
</li>
|
||||
<li v-if="currentChapter" class="breadcrumb-item active">
|
||||
{{ currentChapter.name }}
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="mb-1 px-0">
|
||||
<div
|
||||
ref="mapContainer"
|
||||
class="chapter-map rounded-3 overflow-hidden"
|
||||
style="height: 320px; width: 100%;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="d-flex align-items-center gap-2 small text-muted">
|
||||
<span
|
||||
class="d-inline-block rounded-circle bg-success"
|
||||
style="width:12px;height:12px;opacity:.75;"
|
||||
></span>
|
||||
<span>Each dot = member cluster. Larger dot = more members.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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="currentChapter" class="text-muted fw_4">
|
||||
in {{ currentChapter.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 found yet.
|
||||
<br>Members will appear once addresses are synced.
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="chapter in chapterList"
|
||||
:key="chapter.id"
|
||||
class="chapter-row d-flex align-items-center gap-3 p-3 mb-2 rounded-3 border"
|
||||
:class="{ 'chapter-row--clickable': chapter.has_children }"
|
||||
@click="chapter.has_children ? drillDown(chapter.id) : null"
|
||||
role="button"
|
||||
>
|
||||
<div class="chapter-count d-flex flex-column 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 border small"
|
||||
style="background: var(--bg-card); color: var(--text-primary);"
|
||||
>
|
||||
<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 border small text-muted"
|
||||
style="background: var(--bg-card);">
|
||||
+{{ chapter.leaders.length - 3 }} more
|
||||
</span>
|
||||
</div>
|
||||
<div v-else 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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chapter-row {
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.chapter-row--clickable {
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.chapter-map {
|
||||
border: 1px solid #dee2e6;
|
||||
background: #e8f5e9;
|
||||
}
|
||||
:global(.dark-mode) .chapter-row {
|
||||
background: var(--bg-card) !important;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user