initial: bootstrap from BukidBountyApp base

This commit is contained in:
Jonathan Sykes
2026-06-06 18:43:00 +08:00
commit eb4a5731fb
5674 changed files with 160857 additions and 0 deletions

View File

@@ -0,0 +1,214 @@
<script setup>
import { ref, onMounted } from 'vue';
import axios from 'axios';
const props = defineProps({
orgHash: String
});
const documents = ref([]);
const isLoading = ref(false);
const fileInput = ref(null);
const revisionInput = ref(null);
const activeDocForRevision = ref(null);
const expandedHistory = ref({});
const fetchDocuments = async () => {
if (!props.orgHash) return;
isLoading.value = true;
try {
const response = await axios.post('/Cooperatives/Documents/List', { orgHash: props.orgHash });
if (response.data.success) {
documents.value = response.data.data;
}
} catch (error) {
console.error('Failed to fetch documents:', error);
} finally {
isLoading.value = false;
}
};
const triggerUpload = () => {
fileInput.value.click();
};
const handleFileUpload = async (event) => {
const file = event.target.files[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
formData.append('orgHash', props.orgHash);
formData.append('type', 'OTHERS');
try {
if (window.toastr) window.toastr.info('Uploading document...');
const response = await axios.post('/Cooperatives/Documents/Upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
if (response.data.success) {
if (window.toastr) window.toastr.success('Document uploaded successfully');
fetchDocuments();
} else {
if (window.toastr) window.toastr.error(response.data.error || 'Upload failed');
}
} catch (error) {
if (window.toastr) window.toastr.error('Failed to upload document');
console.error(error);
} finally {
event.target.value = ''; // Reset input
}
};
const triggerRevision = (doc) => {
activeDocForRevision.value = doc;
revisionInput.value.click();
};
const handleRevisionUpload = async (event) => {
const file = event.target.files[0];
if (!file || !activeDocForRevision.value) return;
const formData = new FormData();
formData.append('file', file);
formData.append('parentHash', activeDocForRevision.value.hashkey);
formData.append('note', 'New version');
try {
if (window.toastr) window.toastr.info('Uploading revision...');
const response = await axios.post('/Cooperatives/Documents/Revise', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
if (response.data.success) {
if (window.toastr) window.toastr.success('Revision uploaded successfully');
fetchDocuments();
} else {
if (window.toastr) window.toastr.error(response.data.error || 'Revision failed');
}
} catch (error) {
if (window.toastr) window.toastr.error('Failed to upload revision');
console.error(error);
} finally {
event.target.value = ''; // Reset input
activeDocForRevision.value = null;
}
};
const toggleHistory = (doc) => {
expandedHistory.value[doc.hashkey] = !expandedHistory.value[doc.hashkey];
};
const downloadDoc = (doc) => {
if (doc.url) {
window.open(doc.url, '_blank');
}
};
const getFileIcon = (type) => {
if (type === 'PDF') return 'fas fa-file-pdf';
if (['JPG', 'PNG', 'JPEG'].includes(type)) return 'fas fa-file-image';
return 'fas fa-file-alt';
};
const getIconBg = (type) => {
if (type === 'PDF') return 'bg-soft-danger text-danger';
if (['JPG', 'PNG', 'JPEG'].includes(type)) return 'bg-soft-primary text-primary';
return 'bg-soft-secondary text-secondary';
};
onMounted(() => {
fetchDocuments();
});
</script>
<template>
<div class="document-repository mt-3">
<div class="d-flex justify-content-between align-items-center mb-4">
<h5 class="fw_7 mb-0">Documents & Records</h5>
<div>
<input type="file" ref="fileInput" class="d-none" @change="handleFileUpload">
<button class="btn btn-primary rounded-pill btn-sm px-3 shadow-sm" @click="triggerUpload" :disabled="isLoading">
<i class="fas fa-upload me-1"></i> Upload Document
</button>
</div>
</div>
<div v-if="isLoading" class="text-center py-5">
<div class="spinner-border text-primary spinner-border-sm" role="status"></div>
<p class="text-muted smallest mt-2">Loading documents...</p>
</div>
<div v-else-if="documents.length === 0" class="text-center py-5 bg-light rounded-20 opacity-75">
<i class="fas fa-folder-open fa-3x text-muted mb-3 opacity-25"></i>
<p class="text-muted mb-0">No documents found</p>
<p class="smallest text-muted">Upload important files for this organization</p>
</div>
<div v-else class="document-list">
<input type="file" ref="revisionInput" class="d-none" @change="handleRevisionUpload">
<div v-for="doc in documents" :key="doc.hashkey" class="mb-3">
<div class="card border-0 shadow-sm rounded-20 p-3 hover-card" @click="downloadDoc(doc)">
<div class="d-flex align-items-center gap-3">
<div :class="[getIconBg(doc.type), 'rounded-circle p-2 flex-shrink-0']" style="width: 40px; height: 40px; display: flex; align-items: center; justify-content: center;">
<i :class="getFileIcon(doc.type)"></i>
</div>
<div class="flex-grow-1 overflow-hidden">
<h6 class="fw_6 mb-1 text-truncate">{{ doc.name }}</h6>
<div class="d-flex flex-wrap gap-2 text-muted smallest">
<span class="badge bg-light text-dark rounded-pill px-2">V{{ doc.version }}</span>
<span>{{ doc.date }}</span>
<span>{{ doc.size }}</span>
</div>
</div>
<div class="d-flex align-items-center gap-2">
<button class="btn btn-sm btn-outline-primary rounded-pill px-3" @click.stop="triggerRevision(doc)">
<i class="fas fa-edit me-1"></i> Revise
</button>
<button v-if="doc.history && doc.history.length > 1"
class="btn btn-icon btn-light rounded-circle shadow-sm flex-shrink-0"
style="width: 32px; height: 32px;"
:class="{'rotate-180': expandedHistory[doc.hashkey]}"
@click.stop="toggleHistory(doc)">
<i class="fas fa-chevron-down smallest"></i>
</button>
<button class="btn btn-icon btn-primary rounded-circle shadow-sm flex-shrink-0"
style="width: 36px; height: 36px;"
@click.stop="downloadDoc(doc)">
<i class="fas fa-download small"></i>
</button>
</div>
</div>
</div>
<!-- History Section -->
<div v-if="expandedHistory[doc.hashkey]" class="history-list mt-2 ms-4 border-start ps-3">
<div v-for="h in doc.history.slice(1)" :key="h.hashkey" class="history-item d-flex align-items-center gap-2 mb-2 p-2 bg-light rounded-15" @click="downloadDoc(h)">
<span class="smallest fw_6 text-muted">V{{ h.version }}</span>
<div class="flex-grow-1 overflow-hidden">
<p class="smallest mb-0 text-truncate">{{ h.name }}</p>
<p class="smallest text-muted mb-0">{{ h.date }} {{ h.note || 'No note' }}</p>
</div>
<i class="fas fa-download smallest text-muted"></i>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.rounded-20 { border-radius: 20px; }
.rounded-15 { border-radius: 15px; }
.bg-soft-danger { background-color: rgba(220, 53, 69, 0.1); }
.bg-soft-primary { background-color: rgba(13, 110, 253, 0.1); }
.bg-soft-secondary { background-color: rgba(108, 117, 125, 0.1); }
.hover-card { cursor: pointer; transition: all 0.2s; }
.hover-card:hover { transform: translateY(-2px); box-shadow: 0 4px 15px rgba(0,0,0,0.1) !important; }
.smallest { font-size: 0.75rem; }
.rotate-180 { transform: rotate(180deg); }
.history-item { cursor: pointer; transition: background 0.2s; }
.history-item:hover { background-color: #e9ecef !important; }
</style>

View File

@@ -0,0 +1,182 @@
<script setup>
import { ref, onMounted, computed } from 'vue';
import axios from 'axios';
import LoadingSpinner from '@/Components/LoadingSpinner.vue';
import { useAuth } from '@/composables/Core/useAuth';
const props = defineProps({
orgHash: String
});
const { user } = useAuth();
const resolutions = ref([]);
const loading = ref(true);
const showCreateModal = ref(false);
const newResolution = ref({
title: '',
description: ''
});
const fetchResolutions = async () => {
if (!props.orgHash) return;
loading.value = true;
try {
const response = await axios.post('/Governance/Resolutions/List', { org_hash: props.orgHash });
if (response.data.success) {
resolutions.value = response.data.data;
}
} catch (error) {
console.error('Failed to fetch resolutions');
} finally {
loading.value = false;
}
};
const castVote = async (resolutionHash, vote) => {
try {
const response = await axios.post('/Governance/Resolutions/Vote', {
resolution_hash: resolutionHash,
vote: vote
});
if (response.data.success) {
// Refresh to get updated counts
await fetchResolutions();
}
} catch (error) {
console.error('Failed to cast vote', error);
alert(error.response?.data?.message || 'Failed to cast vote');
}
};
const submitResolution = async () => {
try {
const response = await axios.post('/Governance/Resolutions/Create', {
org_hash: props.orgHash,
...newResolution.value
});
if (response.data.success) {
showCreateModal.value = false;
newResolution.value = { title: '', description: '' };
await fetchResolutions();
}
} catch (error) {
console.error('Failed to create resolution');
}
};
onMounted(fetchResolutions);
</script>
<template>
<div class="governance-resolutions mt-3">
<div class="d-flex justify-content-between align-items-center mb-4">
<h5 class="fw_7 mb-0">Decisions & Resolutions</h5>
<button @click="showCreateModal = true" class="btn btn-primary rounded-pill btn-sm px-3">
<i class="fas fa-plus me-1"></i> Propose Resolution
</button>
</div>
<div v-if="loading" class="text-center py-4">
<LoadingSpinner />
</div>
<div v-else-if="resolutions.length === 0" class="text-center py-5 bg-light rounded-20 border-dashed">
<div class="mb-3 opacity-25">
<i class="fas fa-gavel fa-3x"></i>
</div>
<h6 class="fw_6">No active resolutions</h6>
<p class="text-muted small">Proposed decisions for the cooperative will appear here.</p>
</div>
<div v-else class="resolution-list">
<div v-for="res in resolutions" :key="res.hashkey" class="card border-0 shadow-sm rounded-20 p-3 mb-3 hover-card">
<div class="d-flex justify-content-between align-items-start mb-2">
<div>
<span :class="['badge rounded-pill px-3 py-1 fw_6 mb-2',
res.status === 'APPROVED' ? 'bg-soft-success text-success' :
res.status === 'RESCINDED' ? 'bg-soft-danger text-danger' :
'bg-soft-primary text-primary']">
{{ res.status }}
</span>
<h6 class="fw_7 mb-1">{{ res.title }}</h6>
</div>
<div class="text-end text-muted smallest">
{{ new Date(res.created_at).toLocaleDateString() }}
</div>
</div>
<p class="text-muted small mb-3">{{ res.description }}</p>
<div class="voting-section bg-light rounded-15 p-3">
<div class="d-flex justify-content-between align-items-center mb-2">
<span class="smallest fw_6 text-uppercase text-muted">Current Votes</span>
<div class="d-flex gap-2">
<span class="text-success smallest fw_7"><i class="fas fa-check-circle me-1"></i> {{ res.yes_votes || 0 }}</span>
<span class="text-danger smallest fw_7"><i class="fas fa-times-circle me-1"></i> {{ res.no_votes || 0 }}</span>
<span class="text-muted smallest fw_7"><i class="fas fa-minus-circle me-1"></i> {{ res.abstain_votes || 0 }}</span>
</div>
</div>
<div class="progress rounded-pill mb-3" style="height: 6px;">
<div class="progress-bar bg-success" :style="{ width: ((res.yes_votes || 0) / ((res.yes_votes || 0) + (res.no_votes || 0) + (res.abstain_votes || 1)) * 100) + '%' }"></div>
<div class="progress-bar bg-danger" :style="{ width: ((res.no_votes || 0) / ((res.yes_votes || 0) + (res.no_votes || 0) + (res.abstain_votes || 1)) * 100) + '%' }"></div>
</div>
<div v-if="res.status === 'PROPOSED'" class="voter-actions d-flex gap-2 justify-content-center">
<button @click="castVote(res.hashkey, 'YES')" class="btn btn-soft-success btn-sm rounded-pill px-3 fw_6">
Vote YES
</button>
<button @click="castVote(res.hashkey, 'NO')" class="btn btn-soft-danger btn-sm rounded-pill px-3 fw_6">
Vote NO
</button>
<button @click="castVote(res.hashkey, 'ABSTAIN')" class="btn btn-soft-secondary btn-sm rounded-pill px-3 fw_6">
Abstain
</button>
</div>
</div>
</div>
</div>
<!-- Create Modal -->
<div v-if="showCreateModal" class="custom-modal-overlay" @click.self="showCreateModal = false">
<div class="card border-0 shadow rounded-20 p-4 w-100 mx-3" style="max-width: 500px;">
<h5 class="fw_7 mb-4">Propose Resolution</h5>
<div class="form-group mb-3">
<label class="small fw_6 mb-1">Resolution Title</label>
<input v-model="newResolution.title" type="text" class="form-control rounded-pill px-3 bg-light border-0" placeholder="e.g. Quarterly Dividend Distribution">
</div>
<div class="form-group mb-4">
<label class="small fw_6 mb-1">Description / Details</label>
<textarea v-model="newResolution.description" class="form-control rounded-20 px-3 bg-light border-0" rows="4" placeholder="Detailed explanation..."></textarea>
</div>
<div class="d-flex gap-2">
<button @click="showCreateModal = false" class="btn btn-light rounded-pill flex-fill py-2 fw_6">Cancel</button>
<button @click="submitResolution" class="btn btn-primary rounded-pill flex-fill py-2 fw_7 shadow-sm">Submit Proposal</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.rounded-20 { border-radius: 20px; }
.rounded-15 { border-radius: 15px; }
.bg-soft-primary { background-color: rgba(var(--primary-rgb), 0.1); }
.bg-soft-success { background-color: rgba(40, 167, 69, 0.1); }
.bg-soft-danger { background-color: rgba(220, 53, 69, 0.1); }
.bg-soft-secondary { background-color: rgba(108, 117, 125, 0.1); }
.hover-card { transition: all 0.2s; }
.hover-card:hover { transform: translateY(-2px); box-shadow: 0 4px 15px rgba(0,0,0,0.1) !important; }
.border-dashed { border: 2px dashed #eee; }
.custom-modal-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.5);
z-index: 10000;
display: flex; align-items: center; justify-content: center;
}
.btn-soft-success { background: rgba(40, 167, 69, 0.1); color: #28a745; border: none; }
.btn-soft-danger { background: rgba(220, 53, 69, 0.1); color: #dc3545; border: none; }
.btn-soft-secondary { background: rgba(108, 117, 125, 0.1); color: #6c757d; border: none; }
</style>

View File

@@ -0,0 +1,153 @@
<script setup>
import { ref, computed, onMounted } 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 { useChapters } from '../../../composables/useChapters.js';
import HomeSkeleton from '../../../Components/Core/Skeleton/HomeSkeleton.vue';
const { user } = useAuth();
const { navigate } = useNavigate();
const modal = useModal();
const { data, loading, fetchPageData } = usePageData();
const { fetchOrgChart } = useChapters();
const officers = ref([]);
const chapterInfo = computed(() => data.value?.chapter_info ?? null);
const chapterTitle = computed(() => {
if (!chapterInfo.value) return 'No chapter assigned';
const level = (chapterInfo.value.chapter_level || '').toUpperCase();
return `${level}${chapterInfo.value.chapter_name || ''}`;
});
const memberCount = computed(() => chapterInfo.value?.member_count ?? 0);
const services = [
{ icon: 'fas fa-sitemap', title: 'Org Chart', pagename: 'ChapterOrgChart' },
{ icon: 'fas fa-handshake', title: 'My Cooperative', action: 'viewMyCoop' },
{ icon: 'fas fa-user-circle', title: 'My Profile', pagename: 'UserInfoEdit' },
{ icon: 'fas fa-wallet', title: 'My Wallet', pagename: 'MyWallet' },
];
const activeOrgHash = computed(() => user.value?.settings?.cooperatives?.[0] ?? null);
const roleLabel = (role) => {
const map = {
PRESIDENT: 'Pres.',
VICE_PRESIDENT: 'V.P.',
SECRETARY: 'Sec.',
TREASURER: 'Treas.',
AUDITOR: 'Auditor',
BOARD_MEMBER: 'Board',
};
return map[role] || role;
};
const viewMyCoop = () => {
if (activeOrgHash.value) {
navigate({ page: 'CooperativeDetail', props: { target: activeOrgHash.value } });
} else {
modal.quickDismiss({ title: 'No Cooperative', body: 'You are not linked to a cooperative yet.' });
}
};
const handleAction = (item) => {
if (item.action === 'viewMyCoop') return viewMyCoop();
if (item.pagename) navigate({ page: item.pagename });
};
onMounted(async () => {
await fetchPageData('/home-data', {});
const chart = await fetchOrgChart({});
officers.value = chart?.own_chapter?.officers ?? [];
});
</script>
<template>
<div class="home-coop-member-fragment pb-5">
<HomeSkeleton v-if="loading" />
<template v-else>
<div class="tf-container mt-3">
<div class="chapter-card rounded-4 p-3 mb-3">
<div class="small text-uppercase opacity-75" style="letter-spacing:.05em;">Your Chapter</div>
<div class="fw-bold fs-5">{{ chapterTitle }}</div>
<div v-if="chapterInfo?.cooperative_name" class="small mt-1 opacity-75">
<i class="fas fa-handshake me-1"></i>{{ chapterInfo.cooperative_name }}
</div>
</div>
<div class="member-count-badge rounded-pill px-4 py-2 mb-3 d-inline-flex align-items-center gap-2">
<i class="fas fa-users"></i>
<span class="fw-semibold">{{ memberCount }} member{{ memberCount !== 1 ? 's' : '' }} in this chapter</span>
</div>
<div class="officers-strip rounded-4 p-3 mb-3">
<div class="small fw-semibold mb-2"><i class="fas fa-user-tie me-1"></i>Chapter Officers</div>
<div v-if="!officers.length" class="small opacity-75">No officers assigned yet.</div>
<div v-else class="d-flex flex-wrap gap-2">
<span v-for="(o, i) in officers" :key="i" class="officer-badge rounded-pill px-3 py-1 small">
<strong>{{ roleLabel(o.role) }}</strong> {{ o.name }}
</span>
</div>
</div>
</div>
<div class="tf-container mt-4">
<h5 class="fw_7 mb-3">My Cooperative</h5>
<div class="row g-2">
<div v-for="item in services" :key="item.title" class="col-6 col-md-3">
<button
class="service-tile rounded-4 p-3 w-100 h-100 d-flex flex-column align-items-center justify-content-center gap-2"
@click="handleAction(item)"
>
<i :class="item.icon" class="fa-lg" style="color: var(--accent-color);"></i>
<span class="small fw-semibold text-center">{{ item.title }}</span>
</button>
</div>
</div>
</div>
</template>
</div>
</template>
<style scoped>
.chapter-card {
background: var(--accent-color);
color: #fff;
}
.member-count-badge {
background: var(--bg-card);
color: var(--text-primary);
border: 1px solid rgba(0, 0, 0, 0.06);
}
.officers-strip {
background: var(--bg-card);
color: var(--text-primary);
border: 1px solid rgba(0, 0, 0, 0.06);
}
.officer-badge {
background: var(--bg-primary);
color: var(--text-primary);
border: 1px solid rgba(0, 0, 0, 0.08);
}
.service-tile {
background: var(--bg-card);
color: var(--text-primary);
border: 1px solid rgba(0, 0, 0, 0.06);
transition: transform 0.15s;
}
.service-tile:hover {
transform: translateY(-2px);
}
:global(.dark-mode) .member-count-badge,
:global(.dark-mode) .officers-strip,
:global(.dark-mode) .officer-badge,
:global(.dark-mode) .service-tile {
border-color: rgba(255, 255, 255, 0.08);
}
</style>

View File

@@ -0,0 +1,169 @@
<script setup>
import { ref, computed, onMounted } 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 HomeSkeleton from '../../../Components/Core/Skeleton/HomeSkeleton.vue';
const { user } = useAuth();
const { navigate } = useNavigate();
const modal = useModal();
const { data, loading, fetchPageData } = usePageData();
const chapterInfo = computed(() => data.value?.chapter_info ?? null);
const statsData = computed(() => data.value?.stats ?? {});
const stats = computed(() => [
{ title: 'Members', number: statsData.value.chapter_member_count ?? 0, unit: 'In Chapter' },
{ title: 'Sub-Chapters', number: statsData.value.child_chapter_count ?? 0, unit: 'Direct' },
{ title: 'New (7d)', number: statsData.value.new_members_7d ?? 0, unit: 'Members' },
]);
const chapterBadge = computed(() => {
if (!chapterInfo.value) return '';
const level = (chapterInfo.value.chapter_level || '').toUpperCase();
return `${level}${chapterInfo.value.chapter_name || ''}`;
});
const services = [
{ icon: 'fas fa-sitemap', title: 'Org Chart', pagename: 'ChapterOrgChart' },
{ icon: 'fas fa-search', title: 'Search Members', pagename: 'CoopMemberSearch' },
{ icon: 'fas fa-user-plus', title: 'Create User', pagename: 'CreateCoopUser' },
{ icon: 'fas fa-user-tie', title: 'Assign Officer', pagename: 'AssignChapterOfficer' },
{ icon: 'fas fa-map-marker-alt', title: 'Create Chapter', pagename: 'CreateChapter' },
{ icon: 'fas fa-share-alt', title: 'Share Invite', action: 'shareChapterLink' },
];
const sideButtons = [
{ text: 'My Cooperative', action: 'viewMyCoop' },
{ text: 'My Profile', pagename: 'UserInfoEdit' },
{ text: 'Member Ledger', pagename: 'AccountingDashboard' },
];
const activeOrgHash = computed(
() => chapterInfo.value?.cooperative_hash ?? user.value?.settings?.cooperatives?.[0] ?? null
);
const viewMyCoop = () => {
if (activeOrgHash.value) {
navigate({ page: 'CooperativeDetail', props: { target: activeOrgHash.value } });
} else {
modal.quickDismiss({ title: 'No Cooperative', body: 'You are not linked to a cooperative yet.' });
}
};
const shareChapterLink = () => {
const coopHash = chapterInfo.value?.cooperative_hash;
const chapterHash = chapterInfo.value?.chapter_hashkey;
if (!coopHash || !chapterHash) {
modal.quickDismiss({ title: 'Unavailable', body: 'No chapter link is available yet.' });
return;
}
const encoded = btoa(JSON.stringify({ coop_hash: coopHash, chapter_hash: chapterHash }));
const url = `${window.location.origin}/register-chapter--e:${encoded}`;
if (navigator.share) {
navigator.share({ title: 'Join our cooperative chapter', url }).catch(() => {});
} else {
navigator.clipboard?.writeText(url);
modal.quickDismiss({ title: 'Link Copied', body: 'Registration link copied to clipboard.' });
}
};
const handleAction = (item) => {
if (item.action === 'shareChapterLink') return shareChapterLink();
if (item.action === 'viewMyCoop') return viewMyCoop();
if (item.pagename) navigate({ page: item.pagename });
};
onMounted(async () => {
await fetchPageData('/home-data', {});
});
</script>
<template>
<div class="home-coop-officer-fragment pb-5">
<HomeSkeleton v-if="loading" />
<template v-else>
<div class="tf-container mt-3">
<div class="chapter-badge-card rounded-4 p-3 mb-3">
<div class="small text-uppercase opacity-75" style="letter-spacing:.05em;">Your Chapter</div>
<div class="fw-bold fs-5">{{ chapterBadge || 'No chapter assigned' }}</div>
</div>
<div class="row g-2 mb-2">
<div v-for="s in stats" :key="s.title" class="col-4">
<div class="stat-card rounded-4 p-3 text-center h-100">
<div class="fw-bold fs-4">{{ s.number }}</div>
<div class="small fw-semibold">{{ s.title }}</div>
<div class="small opacity-75">{{ s.unit }}</div>
</div>
</div>
</div>
</div>
<div class="tf-container mt-4">
<h5 class="fw_7 mb-3">Officer Tools</h5>
<div class="row g-2">
<div v-for="item in services" :key="item.title" class="col-4 col-md-3">
<button
class="service-tile rounded-4 p-3 w-100 h-100 d-flex flex-column align-items-center justify-content-center gap-2"
@click="handleAction(item)"
>
<i :class="item.icon" class="fa-lg" style="color: var(--accent-color);"></i>
<span class="small fw-semibold text-center">{{ item.title }}</span>
</button>
</div>
</div>
</div>
<div class="tf-container mt-4">
<h5 class="fw_7 mb-3">Quick Actions</h5>
<div class="d-grid gap-2">
<button
v-for="b in sideButtons"
:key="b.text"
class="side-btn rounded-pill text-start px-4 py-2 d-flex align-items-center justify-content-between"
@click="handleAction(b)"
>
<span class="fw-semibold">{{ b.text }}</span>
<i class="fas fa-chevron-right small opacity-50"></i>
</button>
</div>
</div>
</template>
</div>
</template>
<style scoped>
.chapter-badge-card {
background: var(--accent-color);
color: #fff;
}
.stat-card {
background: var(--bg-card);
color: var(--text-primary);
border: 1px solid rgba(var(--bg-card-rgb, 0, 0, 0), 0.08);
}
.service-tile {
background: var(--bg-card);
color: var(--text-primary);
border: 1px solid rgba(0, 0, 0, 0.06);
transition: transform 0.15s;
}
.service-tile:hover {
transform: translateY(-2px);
}
.side-btn {
background: var(--bg-card);
color: var(--text-primary);
border: 1px solid rgba(0, 0, 0, 0.06);
}
:global(.dark-mode) .stat-card,
:global(.dark-mode) .service-tile,
:global(.dark-mode) .side-btn {
border-color: rgba(255, 255, 255, 0.08);
}
</style>

View 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>

View File

@@ -0,0 +1,263 @@
<script setup>
import { ref, onMounted, h, computed } from 'vue';
import usePageData from '../../../composables/usePageData.js';
import { useNavigate } from '../../../composables/Core/useNavigate.js';
import { useUserNotes } from '../../../composables/useUserNotes.js';
import { useModal } from '../../../composables/Core/useModal.js';
import { useAuth } from '../../../composables/Core/useAuth.js';
import { useGlobalTransactions } from '../../../composables/useGlobalTransactions.js';
import { useActivity } from '../../../composables/useActivity.js';
import { usePrefetch } from '../../../composables/Core/usePrefetch.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 SearchableList from '../../../Components/Core/Search/SearchableList.vue';
import GlobalAnnouncement from '../../../Components/GlobalAnnouncement.vue';
import HomeSkeleton from '../../../Components/Core/Skeleton/HomeSkeleton.vue';
import CooperativeDetail from '@/Pages/CooperativeDetail.vue';
import DocumentRepository from '@/Pages/Fragments/DocumentRepository.vue';
import GovernanceResolutions from '@/Pages/Fragments/GovernanceResolutions.vue';
import OrgHierarchyExplorer from '@/Pages/Fragments/Home/OrgHierarchyExplorer.vue';
import { useUIStore } from '../../../stores/ui';
const uiStore = useUIStore();
const isOrgExplorerMode = computed(() => ['tandem', 'ngo'].includes(uiStore.app_mode));
const { user } = useAuth();
const { precache } = useGlobalTransactions();
const url = '/home-data';
const payload = {};
const { data, loading, error, fetchPageData } = usePageData();
const { navigate } = useNavigate();
const { notes, fetchNotes, dismissNotes, hasNotes } = useUserNotes();
const { activities, fetchRecentActivities, loading: loadingActivities } = useActivity();
const { prefetchEverything } = usePrefetch();
const modal = useModal();
const showStaleIndicator = ref(false);
const stats = ref([
{ title: "Stores", number: 0, unit: "Managed", align: "left", numberId: "managed_stores_no" },
{ title: "Revenue", number: '0.00', unit: "PHP Today", align: "left", numberId: "today_revenue_php" },
{ title: "POS Live", number: 0, unit: "Active", align: "right", numberId: "active_pos_sessions_no" },
]);
const balanceFooterItems = ref([
{ title: "Add Transaction", icon: "https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/c9fd442fe676.bin", pagename: "AddTransaction" },
{ title: "View Reports", icon: "https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/f87407046b18.bin", pagename: "ListReports" },
]);
const activeOrgHash = computed(() => {
const coops = user.value?.settings?.cooperatives;
if (Array.isArray(coops) && coops.length > 0) return coops[0];
return null;
});
const hubTab = ref('overview');
const services = [
{ icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/32248fe10b94.bin', title: 'Users', pagename: 'UserList' },
{ icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/85605eacd4c8.bin', title: 'Stores', pagename: 'ManageStoresAdmin' },
{ icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/ef1a9a079a2d.svg', title: 'Products', pagename: 'ManageProductsAdmin' },
{ icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/c9fd442fe676.bin', title: 'Transactions', pagename: 'ManageGlobalTransactions' },
{ 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/5b5ef88c0ad1.svg', title: 'POS Keys', pagename: 'PosAccessKeys' },
{ icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/04d0e432a298.bin', title: 'Market', pagename: 'ListProductsMarket' },
{ icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/fa711c34b4ef.svg', title: 'Accounting', pagename: 'AccountingDashboard' },
{ icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/4d9cb130fad1.bin', title: 'Shipments', pagename: 'ShipmentList' },
{ icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/5d0ad5d52b8c.bin', title: 'Farmers', pagename: 'FarmerProfileEdit' },
{ icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/3360da347e6b.svg', title: 'Verification', pagename: 'VerificationDashboard' },
{ icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/d9b9b9179ce0.svg', title: 'Cooperatives', pagename: 'CooperativeList' },
];
const quickActionsItems = computed(() => [
{ text: 'Onboard New User', pagename: 'CreateUser', icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/516ed2aaaa4c.bin' },
{ text: 'Register New Store', pagename: 'CreateStore', icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/85605eacd4c8.bin' },
{ text: 'Create New Product', pagename: 'CreateProductUltimate', icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/f0a0193d728e.bin' },
{ text: 'Create New Cooperative', pagename: 'CreateCooperative', icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/d9b9b9179ce0.svg' },
{ text: 'Add Organization', pagename: 'CreateOrganization', icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/f0cc8da0402c.svg' },
{ text: 'My Personal Profile', pagename: 'UserInfoEdit', pagestring: user.value?.hashkey, icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/ac7a1cebe580.bin' },
{ text: 'Referrals & Leads', pagename: 'ListReferrals', icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/7d45d4bdbc74.bin' },
{ text: 'Send Credit Transfer', pagename: 'TransferMyCredit', icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/074af7aca12b.bin' },
{ text: 'Landing Page Editor', pagename: 'LandingPageEditor', icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/3d721f4acf47.svg' },
]);
const showNotesModal = () => {
modal.continueCancelModal({
title: 'Notes',
body: h('div', { style: 'white-space: pre-wrap; font-size: 16px; line-height: 1.5; color: #333;' }, notes.value),
continueText: 'Dismiss Note',
cancelText: 'Close',
continueClass: 'btn btn-danger w-50 py-2 rounded-3 shadow-sm fw-bold',
cancelClass: 'btn btn-light w-50 py-2 rounded-3 border fw-bold text-muted',
onContinue: async () => {
const success = await dismissNotes();
if (success) await fetchNotes();
}
});
};
onMounted(async () => {
precache();
fetchRecentActivities(10);
const result = await fetchPageData(url, payload);
if (result && result.stale) showStaleIndicator.value = true;
if (data.value?.stats) {
stats.value = stats.value.map(s => {
if (data.value.stats[s.numberId] !== undefined) return { ...s, number: data.value.stats[s.numberId] };
return s;
});
}
await fetchNotes();
if (hasNotes()) showNotesModal();
setTimeout(() => { prefetchEverything(); }, 1000);
});
const handleItemClick = (item) => {
if (item.pagename) navigate({ page: item.pagename, props: { data: item.pagestring || '' } });
};
</script>
<template>
<div class="home-operator-fragment pb-5">
<HomeSkeleton v-if="loading && !data" />
<div v-if="!loading" class="tf-container mt-3 mb-1">
<div class="d-flex align-items-center gap-3">
<div class="corp-avatar d-flex align-items-center justify-content-center rounded-3 bg-primary text-white fw_7"
style="width:48px;height:48px;font-size:1.2rem;flex-shrink:0;">
<i class="fas fa-building"></i>
</div>
<div>
<div class="fw_7" style="font-size:1rem;color:var(--text-primary);">{{ user?.name }}</div>
<div class="small text-muted">Operator Account · Corporation View</div>
</div>
</div>
</div>
<div v-if="showStaleIndicator" class="stale-notice">
Displaying cached data.
</div>
<div v-if="data">
<BalanceBox :stats="stats" :footer-items="balanceFooterItems" @footer-click="handleItemClick" />
<div class="mt-2">
<ServiceButtonGrid :items="services" @item-click="handleItemClick" />
</div>
<div class="mt-3">
<GlobalAnnouncement />
</div>
<div v-if="isOrgExplorerMode" class="mt-4 px-3">
<div class="card border-0 shadow-sm rounded-4 p-3">
<OrgHierarchyExplorer />
</div>
</div>
<div v-if="activeOrgHash && !isOrgExplorerMode" class="mt-4 px-3">
<div class="d-flex align-items-center justify-content-between mb-3">
<h5 class="fw_7 mb-0 d-flex align-items-center gap-2" style="color: var(--text-primary) !important;">
<i class="fas fa-landmark text-primary opacity-50"></i>
Cooperative Hub
</h5>
<div class="d-flex gap-1 bg-soft-primary p-1 rounded-pill">
<button
@click="hubTab = 'overview'"
:class="['btn btn-xs rounded-pill px-3 transition-all', hubTab === 'overview' ? 'btn-primary shadow-sm' : 'btn-transparent text-primary small']"
>Overview</button>
<button
@click="hubTab = 'docs'"
:class="['btn btn-xs rounded-pill px-3 transition-all', hubTab === 'docs' ? 'btn-primary shadow-sm' : 'btn-transparent text-primary small']"
>Docs</button>
<button
@click="hubTab = 'votes'"
:class="['btn btn-xs rounded-pill px-3 transition-all', hubTab === 'votes' ? 'btn-primary shadow-sm' : 'btn-transparent text-primary small']"
>Resolutions</button>
</div>
</div>
<div class="card border-0 shadow-sm rounded-20 overflow-hidden p-0">
<div v-if="hubTab === 'overview'">
<CooperativeDetail :target="activeOrgHash" />
</div>
<div v-else-if="hubTab === 'docs'" class="p-3">
<DocumentRepository :org-hash="activeOrgHash" />
</div>
<div v-else-if="hubTab === 'votes'" class="p-3">
<GovernanceResolutions :org-hash="activeOrgHash" />
</div>
</div>
</div>
<div class="tf-container mt-4">
<h5 class="fw_7 mb-3" style="color: var(--text-primary) !important;">Management & Tools</h5>
<SideTextButtonList :items="quickActionsItems" @item-click="handleItemClick" />
</div>
<div class="mt-4 px-3">
<div class="activity-section card border-0 shadow-sm rounded-4 overflow-hidden">
<div class="card-header border-0 py-3 px-4 d-flex align-items-center justify-content-between">
<h5 class="fw-bold mb-0 d-flex align-items-center gap-2">
<i class="fas fa-history text-primary"></i>
Recent System Activity
</h5>
<a
href="javascript:void(0);"
class="btn btn-sm btn-outline-primary rounded-pill px-3 fw-semibold"
@click="navigate({ page: 'ManageGlobalTransactions' })"
>View All</a>
</div>
<div class="card-body p-0">
<SearchableList
title=""
:items="activities"
:loading="loadingActivities"
empty-text="No recent activity recorded"
@item-click="handleItemClick"
/>
</div>
</div>
</div>
</div>
<div v-if="error && !data">
<div class="tf-container mt-5 text-center">
<p style="color: var(--text-muted);">Dashboard disconnected. Please retry.</p>
<button class="btn btn-primary mt-2 rounded-pill px-4" @click="fetchPageData(url, payload)">Reconnect</button>
</div>
</div>
</div>
</template>
<style scoped>
.stale-notice {
background-color: var(--bs-warning-bg-subtle, #fff3cd);
color: var(--bs-warning-text-emphasis, #856404);
padding: 8px;
border-radius: 4px;
margin-bottom: 10px;
font-size: 0.85rem;
text-align: center;
}
.btn-xs {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
line-height: 1.5;
border-radius: 50rem;
}
.bg-soft-primary {
background-color: rgba(var(--primary-rgb), 0.1);
}
.transition-all {
transition: all 0.2s ease;
}
</style>

View File

@@ -0,0 +1,177 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import axios from 'axios';
import { useNavigate } from '../../../composables/Core/useNavigate.js';
// Core Components
import ServiceButtonGrid from '../../../Components/Core/Services/ServiceButtonGrid.vue';
import SideTextButtonList from '../../../Components/Core/Services/SideTextButtonList.vue';
import CardSimple from '../../../Components/Core/CardSimple.vue';
import GlobalAnnouncement from '../../../Components/GlobalAnnouncement.vue';
import { useUIStore } from '../../../stores/ui';
const { navigate } = useNavigate();
const uiStore = useUIStore();
const landingPageHtml = ref(null);
const hasLandingPage = ref(false);
const loadingLanding = ref(true);
// Fetch the active landing page
const fetchLandingPage = async () => {
loadingLanding.value = true;
try {
const res = await axios.get('/api/public/landing-page');
if (res.data.success && res.data.has_landing_page && res.data.data) {
landingPageHtml.value = res.data.data.html_content;
hasLandingPage.value = true;
uiStore.setFullWidth(true);
// Hide global app UI for a clean landing page experience
uiStore.setHeaderVisibility(false);
uiStore.setBottomNavVisibility(false);
} else {
hasLandingPage.value = false;
uiStore.setFullWidth(false);
uiStore.setHeaderVisibility(true);
uiStore.setBottomNavVisibility(true);
}
} catch (err) {
hasLandingPage.value = false;
uiStore.setFullWidth(false);
uiStore.setHeaderVisibility(true);
uiStore.setBottomNavVisibility(true);
} finally {
loadingLanding.value = false;
}
};
// Also ensure we reset full width if this fragment is unmounted
import { onUnmounted, watch } from 'vue';
onUnmounted(() => {
uiStore.setFullWidth(false);
uiStore.setHeaderVisibility(true);
uiStore.setBottomNavVisibility(true);
});
// Watch for changes in hasLandingPage to update UI store
watch(hasLandingPage, (newVal) => {
uiStore.setFullWidth(newVal);
});
onMounted(() => {
fetchLandingPage();
});
// Services for public users (no auth required)
const services = computed(() => [
{ icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/938032617d05.bin', title: 'Market', pagename: 'ListProductsMarket' },
{ icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/85605eacd4c8.bin', title: 'Stores', pagename: 'ListStores' },
]);
// Quick actions for public users
const quickActionsItems = computed(() => [
{ text: 'Browse Products', pagename: 'ListProductsMarket', icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/f0a0193d728e.bin' },
{ text: 'View Available Stores', pagename: 'ListStores', icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/85605eacd4c8.bin' },
]);
const handleItemClick = (item) => {
if (item.pagename) {
navigate({ page: item.pagename, props: { data: item.pagestring || '' } });
}
};
const goToLogin = () => {
navigate({ page: 'Auth.Login' });
};
</script>
<template>
<div class="home-public-fragment">
<!-- Loading state -->
<div v-if="loadingLanding" class="text-center py-5">
<div class="spinner-border text-primary" role="status"></div>
</div>
<!-- Custom Landing Page (from DB) -->
<div v-else-if="hasLandingPage && landingPageHtml" class="landing-page-container">
<div class="landing-page-content" v-html="landingPageHtml"></div>
</div>
<!-- Default Public Homepage (fallback) -->
<div v-else class="pb-5">
<!-- Global Announcements -->
<GlobalAnnouncement />
<!-- Services Grid -->
<div class="mt-4">
<h5 class="tf-container fw_6 mb-3">Featured Services</h5>
<ServiceButtonGrid :items="services" @item-click="handleItemClick" />
</div>
<!-- Quick Actions -->
<div class="tf-container mt-4">
<h5 class="fw_6 mb-3">Quick Links</h5>
<SideTextButtonList :items="quickActionsItems" @item-click="handleItemClick" />
</div>
<!-- Login CTA -->
<div class="tf-container mt-5">
<div class="login-cta-box text-center p-4">
<h4 class="fw_6 mb-2">Ready to trade?</h4>
<p class="mb-4 text-muted">Join the {{ uiStore.appName }} community today.</p>
<button @click="goToLogin" class="btn btn-primary w-100 py-3 rounded-pill fw_6">
Login / Sign Up
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.announcement-img {
width: 100%;
border-radius: 10px;
box-shadow: 0 0 5px rgba(0, 0, 0, 0.1);
margin-bottom: 15px;
}
.announcement-text {
line-height: 1.6;
font-size: 0.95rem;
}
.login-cta-box {
background: #f8f9fa;
border-radius: 15px;
border: 1px dashed #dee2e6;
}
.btn-primary {
background: #42b983;
border: none;
font-size: 1.1rem;
transition: transform 0.2s;
}
.btn-primary:active {
transform: scale(0.98);
}
/* Landing page container - allow full-width custom content */
.landing-page-container {
width: 100%;
}
.landing-page-content {
width: 100%;
}
/* Ensure dark mode compatibility */
:global(.dark-mode) .login-cta-box {
background: var(--bg-card, #1e1e2e);
border-color: rgba(255,255,255,0.1);
}
</style>

View File

@@ -0,0 +1,117 @@
<script setup>
import { computed, h } from 'vue';
import axios from 'axios';
import { useNavigate } from '../../../composables/Core/useNavigate.js';
import { useAuth } from '../../../composables/Core/useAuth.js';
import { useModal } from '../../../composables/Core/useModal.js';
import ServiceButtonGrid from '../../../Components/Core/Services/ServiceButtonGrid.vue';
import SideTextButtonList from '../../../Components/Core/Services/SideTextButtonList.vue';
const props = defineProps({
title: { type: String, default: 'Dashboard' }
});
const { UserTypes, role } = useAuth();
const { navigate } = useNavigate();
const modal = useModal();
const services = computed(() => [
{ icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/04d0e432a298.bin', title: 'Market', pagename: 'ListProductsMarket' },
{ icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/9908be28dd8a.bin', title: 'My Wallet', pagename: 'MyWallet' },
{ icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/4d9cb130fad1.bin', title: 'Shipments', pagename: 'ShipmentList' },
]);
const quickActionsItems = computed(() => {
const items = [
{
text: 'Open POS',
action: 'openPos',
icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/5b5ef88c0ad1.svg',
roles: [UserTypes.STORE_MANAGER],
},
{
text: 'Onboard New User',
pagename: 'CreateUser',
icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/516ed2aaaa4c.bin',
roles: [
UserTypes.ULTIMATE,
UserTypes.SUPER_OPERATOR,
UserTypes.OPERATOR,
UserTypes.COORDINATOR,
UserTypes.STORE_OWNER,
UserTypes.STORE_MANAGER,
UserTypes.SUPPLIER_OVERSEER,
UserTypes.SUPPLIER
]
},
{ text: 'My Personal Profile', pagename: 'UserInfoEdit', icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/ac7a1cebe580.bin' },
];
return items.filter(item => {
if (!item.roles) return true;
return item.roles.includes(role.value);
});
});
const showStoreSelectModal = (stores) => {
modal.open({
title: 'Select a Store',
body: h('div', { class: 'd-flex flex-column gap-2' },
stores.map(store =>
h('button', {
class: 'btn btn-outline-primary rounded-pill text-start',
onClick: () => {
modal.hideModal();
navigate({ page: 'PosMain', props: { target: store.hashkey } });
},
}, [
h('i', { class: 'fas fa-store me-2' }),
store.name,
store.category ? h('small', { class: 'text-muted ms-2' }, `(${store.category})`) : null,
])
)
),
});
};
const openPos = async () => {
try {
const { data: stores } = await axios.post('/ListStores/MyStores/data', {});
if (!stores || stores.length === 0) {
modal.quickDismiss({ title: 'No Store Found', body: 'You have no active stores assigned to your account.' });
return;
}
if (stores.length === 1) {
navigate({ page: 'PosMain', props: { target: stores[0].hashkey } });
return;
}
showStoreSelectModal(stores);
} catch (e) {
modal.quickDismiss({ title: 'Error', body: 'Could not load your stores. Please try again.' });
}
};
const handleItemClick = async (item) => {
if (item?.action === 'openPos') {
await openPos();
return;
}
if (item.pagename) {
navigate({ page: item.pagename });
}
};
</script>
<template>
<div class="home-fragment pb-5">
<div class="tf-container mt-4">
<h5 class="fw_7 mb-3">{{ title }} Dashboard</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="quickActionsItems" @item-click="handleItemClick" />
</div>
</div>
</template>

View File

@@ -0,0 +1,141 @@
<script setup>
import { ref, onMounted, h } from 'vue';
import axios from 'axios';
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';
const { user } = useAuth();
const { navigate } = useNavigate();
const modal = useModal();
const { data, loading, error, fetchPageData } = usePageData();
const stats = ref([
{ title: 'Transactions', number: 0, unit: 'Today', align: 'left', numberId: 'transactions_today_no' },
{ title: 'Cash Flow', number: '0.00', unit: 'PHP Today', align: 'left', numberId: 'cash_flow_today_php' },
{ title: 'My Stores', number: 0, unit: 'Assigned', align: 'right', numberId: 'my_stores_no' },
]);
const balanceFooterItems = ref([
{ title: 'Open POS', icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/5b5ef88c0ad1.svg', action: 'openPos' },
{ title: 'My Stores', icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/85605eacd4c8.bin', pagename: 'ManageStoresAdmin' },
]);
const services = [
{ icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/5b5ef88c0ad1.svg', title: 'Open POS', action: 'openPos' },
{ icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/ef1a9a079a2d.svg', title: 'Inventory', pagename: 'ManageProductsAdmin' },
{ icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/f87407046b18.bin', title: 'POS History', action: 'openPosHistory' },
{ icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/85605eacd4c8.bin', title: 'Manage Stores', pagename: 'ManageStoresAdmin' },
{ icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/f0a0193d728e.bin', title: 'Add Product', pagename: 'CreateProductStoreOwner' },
{ icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/5b5ef88c0ad1.svg', title: 'POS Keys', pagename: 'PosAccessKeys' },
{ icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/516ed2aaaa4c.bin', title: 'Customers', action: 'viewCustomers' },
{ icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/f87407046b18.bin', title: 'Reports', pagename: 'ListReports' },
];
const quickActions = [
{ text: 'Onboard New User', pagename: 'CreateUser', icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/516ed2aaaa4c.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' },
{ text: 'Add Transaction', pagename: 'AddTransaction', icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/c9fd442fe676.bin' },
];
const showStoreSelectModal = (stores, onSelect) => {
modal.open({
title: 'Select a Store',
body: h('div', { class: 'd-flex flex-column gap-2' },
stores.map(store =>
h('button', {
class: 'btn btn-outline-primary rounded-pill text-start',
onClick: () => {
modal.hideModal();
onSelect(store);
},
}, [
h('i', { class: 'fas fa-store me-2' }),
store.name,
store.category ? h('small', { class: 'text-muted ms-2' }, `(${store.category})`) : null,
])
)
),
});
};
const openPos = async () => {
try {
const { data: stores } = await axios.post('/ListStores/MyStores/data', {});
if (!stores || stores.length === 0) {
modal.quickDismiss({ title: 'No Store Found', body: 'You have no active stores assigned to your account.' });
return;
}
if (stores.length === 1) {
navigate({ page: 'PosMain', props: { target: stores[0].hashkey } });
return;
}
showStoreSelectModal(stores, (store) => navigate({ page: 'PosMain', props: { target: store.hashkey } }));
} catch (e) {
modal.quickDismiss({ title: 'Error', body: 'Could not load your stores. Please try again.' });
}
};
const openPosHistory = async () => {
try {
const { data: stores } = await axios.post('/ListStores/MyStores/data', {});
if (!stores || stores.length === 0) {
modal.quickDismiss({ title: 'No Store Found', body: 'You have no active stores assigned to your account.' });
return;
}
if (stores.length === 1) {
navigate({ page: 'PosHistory', props: { target: stores[0].hashkey } });
return;
}
showStoreSelectModal(stores, (store) => navigate({ page: 'PosHistory', props: { target: store.hashkey } }));
} catch (e) {
modal.quickDismiss({ title: 'Error', body: 'Could not load your stores. Please try again.' });
}
};
const viewCustomers = () => {
navigate({ page: 'ManageStoresAdmin' });
};
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 handleItemClick = async (item) => {
if (item?.action === 'openPos') { await openPos(); return; }
if (item?.action === 'openPosHistory') { await openPosHistory(); return; }
if (item?.action === 'viewCustomers') { viewCustomers(); return; }
if (item.pagename) { navigate({ page: item.pagename }); }
};
onMounted(async () => {
await fetchPageData('/home-data', {});
applyStats();
});
</script>
<template>
<div class="home-fragment pb-5">
<HomeSkeleton v-if="loading" />
<template v-else>
<BalanceBox :stats="stats" :footerItems="balanceFooterItems" @footer-click="handleItemClick" />
<div class="tf-container mt-4">
<h5 class="fw_7 mb-3">Store Manager Tools</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>
</template>
</div>
</template>

View File

@@ -0,0 +1,224 @@
<script setup>
import { ref, onMounted, computed, h } from 'vue';
import axios from 'axios';
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 HomeSkeleton from '../../../Components/Core/Skeleton/HomeSkeleton.vue';
const props = defineProps({
title: { type: String, default: 'Store Owner' },
});
const { user } = useAuth();
const { navigate } = useNavigate();
const modal = useModal();
const { data, loading, error, fetchPageData } = usePageData();
const stats = ref([
{ title: 'Transactions', number: 0, unit: 'Today', align: 'left', numberId: 'transactions_today_no' },
{ title: 'Cash Flow', number: '0.00', unit: 'PHP Today', align: 'left', numberId: 'cash_flow_today_php' },
{ title: 'My Stores', number: 0, unit: 'Active', align: 'right', numberId: 'my_stores_no' },
]);
const balanceFooterItems = ref([
{ title: 'Open POS', icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/5b5ef88c0ad1.svg', action: 'openPos' },
{ title: 'My Stores', icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/85605eacd4c8.bin', pagename: 'ManageStoresAdmin' },
]);
const services = computed(() => [
{
icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/85605eacd4c8.bin',
title: 'Create Store',
action: 'chooseCreateStoreMode',
},
{
icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/ef1a9a079a2d.svg',
title: 'Import Products',
pagename: 'BatchAddProducts',
},
{
icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/f0a0193d728e.bin',
title: 'New Product',
pagename: 'CreateProductStoreOwner',
},
{
icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/ef1a9a079a2d.svg',
title: 'My Products',
pagename: 'ManageProductsAdmin',
},
{
icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/5b5ef88c0ad1.svg',
title: 'POS Keys',
pagename: 'PosAccessKeys',
},
{
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/fa711c34b4ef.svg',
title: 'Accounting',
pagename: 'AccountingDashboard',
},
{
icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/85605eacd4c8.bin',
title: 'Manage Stores',
pagename: 'ManageStoresAdmin',
},
]);
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 creatingQuickStore = ref(false);
const quickCreateStore = async () => {
if (creatingQuickStore.value) return;
creatingQuickStore.value = true;
try {
const { data: res } = await axios.post('/Store/AutoCreate', {});
if (res?.success) {
await fetchPageData('/home-data', {});
applyStats();
modal.quickDismiss({
title: 'Store Created',
body: `"${res.name}" was created and assigned to your account.`,
});
if (res.hashkey) {
navigate({ page: 'AddProductsToStore', props: { target: res.hashkey } });
} else {
navigate({ page: 'ManageStoresAdmin' });
}
} else {
modal.quickDismiss({ title: 'Could not create store', body: 'Please try again.' });
}
} catch (e) {
modal.quickDismiss({
title: 'Could not create store',
body: e?.response?.data?.message || e.message || 'Unexpected error',
});
} finally {
creatingQuickStore.value = false;
}
};
const openCreateStoreChooser = () => {
modal.yesNoModal({
title: 'Create Store',
body: 'Choose how you want to create your store. Quick Create makes a store instantly with default details. Custom Create lets you set the name, address, photos, and more.',
yesText: 'Quick Create',
yesClass: 'btn btn-primary w-50 py-2 rounded-3 shadow-sm fw-bold',
onYes: quickCreateStore,
noText: 'Custom Create',
noClass: 'btn btn-outline-primary w-50 py-2 rounded-3 fw-bold',
onNo: () => navigate({ page: 'CreateStore' }),
});
};
const showStoreSelectModal = (stores) => {
modal.open({
title: 'Select a Store',
body: h('div', { class: 'd-flex flex-column gap-2' },
stores.map(store =>
h('button', {
class: 'btn btn-outline-primary rounded-pill text-start',
onClick: () => {
modal.hideModal();
navigate({ page: 'PosMain', props: { target: store.hashkey } });
},
}, [
h('i', { class: 'fas fa-store me-2' }),
store.name,
store.category ? h('small', { class: 'text-muted ms-2' }, `(${store.category})`) : null,
])
)
),
});
};
const openPos = async () => {
try {
const { data: stores } = await axios.post('/ListStores/MyStores/data', {});
if (!stores || stores.length === 0) {
modal.quickDismiss({ title: 'No Store Found', body: 'You have no active stores assigned to your account.' });
return;
}
if (stores.length === 1) {
navigate({ page: 'PosMain', props: { target: stores[0].hashkey } });
return;
}
showStoreSelectModal(stores);
} catch (e) {
modal.quickDismiss({ title: 'Error', body: 'Could not load your stores. Please try again.' });
}
};
const handleItemClick = async (item) => {
if (item?.action === 'chooseCreateStoreMode') {
openCreateStoreChooser();
return;
}
if (item?.action === 'openPos') {
await openPos();
return;
}
if (item?.pagename) {
navigate({ page: item.pagename, props: { data: item.pagestring || '' } });
}
};
onMounted(async () => {
await fetchPageData('/home-data', {});
applyStats();
});
</script>
<template>
<div class="home-store-owner-fragment pb-5">
<HomeSkeleton v-if="loading && !data" />
<div v-if="data">
<BalanceBox
:stats="stats"
:footer-items="balanceFooterItems"
@footer-click="handleItemClick"
/>
<div class="tf-container mt-3">
<h5 class="fw_7 mb-2" style="color: var(--text-primary) !important;">
{{ title }} Tools
</h5>
<ServiceButtonGrid :items="services" @item-click="handleItemClick" />
</div>
<div v-if="creatingQuickStore" class="tf-container mt-3 text-center">
<small class="text-muted">
<i class="fas fa-spinner fa-spin me-1"></i>
Creating your store...
</small>
</div>
</div>
<div v-if="error && !data" class="tf-container mt-5 text-center">
<p style="color: var(--text-muted);">Dashboard disconnected. Please retry.</p>
<button
class="btn btn-primary mt-2 rounded-pill px-4"
@click="fetchPageData('/home-data', {})"
>
Reconnect
</button>
</div>
</div>
</template>

View File

@@ -0,0 +1,250 @@
<script setup>
import { ref, onMounted, h, computed } from 'vue';
import usePageData from '../../../composables/usePageData.js';
import { useNavigate } from '../../../composables/Core/useNavigate.js';
import { useUserNotes } from '../../../composables/useUserNotes.js';
import { useModal } from '../../../composables/Core/useModal.js';
import { useAuth } from '../../../composables/Core/useAuth.js';
import { useGlobalTransactions } from '../../../composables/useGlobalTransactions.js';
import { useActivity } from '../../../composables/useActivity.js';
import { usePrefetch } from '../../../composables/Core/usePrefetch.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 SearchableList from '../../../Components/Core/Search/SearchableList.vue';
import GlobalAnnouncement from '../../../Components/GlobalAnnouncement.vue';
import HomeSkeleton from '../../../Components/Core/Skeleton/HomeSkeleton.vue';
import CooperativeDetail from '@/Pages/CooperativeDetail.vue';
import DocumentRepository from '@/Pages/Fragments/DocumentRepository.vue';
import GovernanceResolutions from '@/Pages/Fragments/GovernanceResolutions.vue';
import OrgHierarchyExplorer from '@/Pages/Fragments/Home/OrgHierarchyExplorer.vue';
import { useUIStore } from '../../../stores/ui';
const uiStore = useUIStore();
const isOrgExplorerMode = computed(() => ['tandem', 'ngo'].includes(uiStore.app_mode));
const { user } = useAuth();
const { precache } = useGlobalTransactions();
const url = '/home-data';
const payload = {};
const { data, loading, error, fetchPageData } = usePageData();
const { navigate } = useNavigate();
const { notes, fetchNotes, dismissNotes, hasNotes } = useUserNotes();
const { activities, fetchRecentActivities, loading: loadingActivities } = useActivity();
const { prefetchEverything } = usePrefetch();
const modal = useModal();
const showStaleIndicator = ref(false);
const stats = ref([
{ title: "Transactions", number: 0, unit: "Total", align: "left", numberId: "total_transactions_no" },
{ title: "Value", number: 0, unit: "PHP", align: "left", numberId: "total_transactions_php" },
{ title: "Projected", number: 0, unit: "Income", align: "right", numberId: "projected_income_today" },
]);
const balanceFooterItems = ref([
{ title: "Add Transaction", icon: "https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/c9fd442fe676.bin", pagename: "AddTransaction" },
{ title: "View Reports", icon: "https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/f87407046b18.bin", pagename: "ListReports" },
]);
const activeOrgHash = computed(() => {
const coops = user.value?.settings?.cooperatives;
if (Array.isArray(coops) && coops.length > 0) return coops[0];
return null;
});
const hubTab = ref('overview');
const services = [
{ icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/32248fe10b94.bin', title: 'Users', pagename: 'UserList' },
{ icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/85605eacd4c8.bin', title: 'Stores', pagename: 'ManageStoresAdmin' },
{ icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/ef1a9a079a2d.svg', title: 'Products', pagename: 'ManageProductsAdmin' },
{ icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/c9fd442fe676.bin', title: 'Transactions', pagename: 'ManageGlobalTransactions' },
{ 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/fa711c34b4ef.svg', title: 'Accounting', pagename: 'AccountingDashboard' },
{ icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/5b5ef88c0ad1.svg', title: 'POS Keys', pagename: 'PosAccessKeys' },
{ icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/04d0e432a298.bin', title: 'Market', pagename: 'ListProductsMarket' },
{ icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/4d9cb130fad1.bin', title: 'Shipments', pagename: 'ShipmentList' },
{ icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/5d0ad5d52b8c.bin', title: 'Farmers', pagename: 'FarmerProfileEdit' },
{ icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/3360da347e6b.svg', title: 'Verification', pagename: 'VerificationDashboard' },
{ icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/d9b9b9179ce0.svg', title: 'Cooperatives', pagename: 'CooperativeList' },
];
const quickActionsItems = computed(() => [
{ text: 'Onboard New User', pagename: 'CreateUser', icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/516ed2aaaa4c.bin' },
{ text: 'Register New Store', pagename: 'CreateStore', icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/85605eacd4c8.bin' },
{ text: 'Create New Product', pagename: 'CreateProductUltimate', icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/f0a0193d728e.bin' },
{ text: 'Create New Cooperative', pagename: 'CreateCooperative', icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/d9b9b9179ce0.svg' },
{ text: 'Add Organization', pagename: 'CreateOrganization', icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/f0cc8da0402c.svg' },
{ text: 'My Personal Profile', pagename: 'UserInfoEdit', pagestring: user.value?.hashkey, icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/ac7a1cebe580.bin' },
{ text: 'Referrals & Leads', pagename: 'ListReferrals', icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/7d45d4bdbc74.bin' },
{ text: 'Send Credit Transfer', pagename: 'TransferMyCredit', icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/074af7aca12b.bin' },
{ text: 'Landing Page Editor', pagename: 'LandingPageEditor', icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/3d721f4acf47.svg' },
]);
const showNotesModal = () => {
modal.continueCancelModal({
title: 'Notes',
body: h('div', { style: 'white-space: pre-wrap; font-size: 16px; line-height: 1.5; color: #333;' }, notes.value),
continueText: 'Dismiss Note',
cancelText: 'Close',
continueClass: 'btn btn-danger w-50 py-2 rounded-3 shadow-sm fw-bold',
cancelClass: 'btn btn-light w-50 py-2 rounded-3 border fw-bold text-muted',
onContinue: async () => {
const success = await dismissNotes();
if (success) await fetchNotes();
}
});
};
onMounted(async () => {
precache();
fetchRecentActivities(10);
const result = await fetchPageData(url, payload);
if (result && result.stale) showStaleIndicator.value = true;
if (data.value?.stats) {
stats.value = stats.value.map(s => {
if (data.value.stats[s.numberId] !== undefined) return { ...s, number: data.value.stats[s.numberId] };
return s;
});
}
await fetchNotes();
if (hasNotes()) showNotesModal();
setTimeout(() => { prefetchEverything(); }, 1000);
});
const handleItemClick = (item) => {
if (item.pagename) navigate({ page: item.pagename, props: { data: item.pagestring || '' } });
};
</script>
<template>
<div class="home-super-operator-fragment pb-5">
<HomeSkeleton v-if="loading && !data" />
<div v-if="showStaleIndicator" class="stale-notice">
Displaying cached data.
</div>
<div v-if="data">
<BalanceBox :stats="stats" :footer-items="balanceFooterItems" @footer-click="handleItemClick" />
<div class="mt-2">
<ServiceButtonGrid :items="services" @item-click="handleItemClick" />
</div>
<div class="mt-3">
<GlobalAnnouncement />
</div>
<div v-if="isOrgExplorerMode" class="mt-4 px-3">
<div class="card border-0 shadow-sm rounded-4 bg-white p-3">
<OrgHierarchyExplorer />
</div>
</div>
<div v-if="activeOrgHash && !isOrgExplorerMode" class="mt-4 px-3">
<div class="d-flex align-items-center justify-content-between mb-3">
<h5 class="fw_7 mb-0 d-flex align-items-center gap-2" style="color: var(--text-primary) !important;">
<i class="fas fa-landmark text-primary opacity-50"></i>
Cooperative Hub
</h5>
<div class="d-flex gap-1 bg-soft-primary p-1 rounded-pill">
<button
@click="hubTab = 'overview'"
:class="['btn btn-xs rounded-pill px-3 transition-all', hubTab === 'overview' ? 'btn-primary shadow-sm' : 'btn-transparent text-primary small']"
>Overview</button>
<button
@click="hubTab = 'docs'"
:class="['btn btn-xs rounded-pill px-3 transition-all', hubTab === 'docs' ? 'btn-primary shadow-sm' : 'btn-transparent text-primary small']"
>Docs</button>
<button
@click="hubTab = 'votes'"
:class="['btn btn-xs rounded-pill px-3 transition-all', hubTab === 'votes' ? 'btn-primary shadow-sm' : 'btn-transparent text-primary small']"
>Resolutions</button>
</div>
</div>
<div class="card border-0 shadow-sm rounded-20 bg-white overflow-hidden p-0">
<div v-if="hubTab === 'overview'">
<CooperativeDetail :target="activeOrgHash" />
</div>
<div v-else-if="hubTab === 'docs'" class="p-3">
<DocumentRepository :org-hash="activeOrgHash" />
</div>
<div v-else-if="hubTab === 'votes'" class="p-3">
<GovernanceResolutions :org-hash="activeOrgHash" />
</div>
</div>
</div>
<div class="tf-container mt-4">
<h5 class="fw_7 mb-3" style="color: var(--text-primary) !important;">Management & Tools</h5>
<SideTextButtonList :items="quickActionsItems" @item-click="handleItemClick" />
</div>
<div class="mt-4 px-3">
<div class="activity-section card border-0 shadow-sm rounded-4 bg-white overflow-hidden">
<div class="card-header bg-white border-0 py-3 px-4 d-flex align-items-center justify-content-between">
<h5 class="fw-bold mb-0 d-flex align-items-center gap-2">
<i class="fas fa-history text-primary"></i>
Recent System Activity
</h5>
<a
href="javascript:void(0);"
class="btn btn-sm btn-outline-primary rounded-pill px-3 fw-semibold"
@click="navigate({ page: 'ManageGlobalTransactions' })"
>View All</a>
</div>
<div class="card-body p-0">
<SearchableList
title=""
:items="activities"
:loading="loadingActivities"
empty-text="No recent activity recorded"
@item-click="handleItemClick"
/>
</div>
</div>
</div>
</div>
<div v-if="error && !data">
<div class="tf-container mt-5 text-center">
<p style="color: var(--text-muted);">Dashboard disconnected. Please retry.</p>
<button class="btn btn-primary mt-2 rounded-pill px-4" @click="fetchPageData(url, payload)">Reconnect</button>
</div>
</div>
</div>
</template>
<style scoped>
.stale-notice {
background-color: #fff3cd;
color: #856404;
padding: 8px;
border-radius: 4px;
margin-bottom: 10px;
font-size: 0.85rem;
text-align: center;
}
.btn-xs {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
line-height: 1.5;
border-radius: 50rem;
}
.bg-soft-primary {
background-color: rgba(var(--primary-rgb), 0.1);
}
.transition-all {
transition: all 0.2s ease;
}
</style>

View File

@@ -0,0 +1,341 @@
<script setup>
import { ref, onMounted, h, computed } from 'vue';
import usePageData from '../../../composables/usePageData.js';
import { useNavigate } from '../../../composables/Core/useNavigate.js';
import { useUserNotes } from '../../../composables/useUserNotes.js';
import { useModal } from '../../../composables/Core/useModal.js';
import { useAuth } from '../../../composables/Core/useAuth.js';
import { useGlobalTransactions } from '../../../composables/useGlobalTransactions.js';
import { useActivity } from '../../../composables/useActivity.js';
import { usePrefetch } from '../../../composables/Core/usePrefetch.js';
import { useUIStore } from '../../../stores/ui.js';
// Core Components
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 SearchableList from '../../../Components/Core/Search/SearchableList.vue';
import CardSimple from '../../../Components/Core/CardSimple.vue';
import GlobalAnnouncement from '../../../Components/GlobalAnnouncement.vue';
import HomeSkeleton from '../../../Components/Core/Skeleton/HomeSkeleton.vue';
import CooperativeDetail from '@/Pages/CooperativeDetail.vue';
import DocumentRepository from '@/Pages/Fragments/DocumentRepository.vue';
import GovernanceResolutions from '@/Pages/Fragments/GovernanceResolutions.vue';
const { hasRole, UserTypes, user } = useAuth();
const { precache } = useGlobalTransactions();
const url = '/home-data';
const payload = {};
const { data, loading, error, stale, fetchPageData } = usePageData();
const { navigate } = useNavigate();
const { notes, fetchNotes, dismissNotes, hasNotes } = useUserNotes();
const { activities, fetchRecentActivities, loading: loadingActivities } = useActivity();
const { prefetchEverything } = usePrefetch();
const modal = useModal();
const uiStore = useUIStore();
const showStaleIndicator = ref(false);
// Stats data mapping to enhanced backend
const stats = ref([
{ title: "Pending", number: 0, unit: "Orders", align: "left", numberId: "pending_orders_no" },
{ title: "Stores", number: 0, unit: "Active", align: "left", numberId: "active_stores_no" },
{ title: "Users", number: 0, unit: "Total", align: "right", numberId: "total_users_no" },
{ title: "Balance", number: 0, unit: "PHP", align: "right", numberId: "total_balance_php" }
]);
const balanceFooterItems = ref([
{ title: "Add Transaction", icon: "https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/c9fd442fe676.bin", pagename: "AddTransaction" },
{ title: "View Reports", icon: "https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/f87407046b18.bin", pagename: "ListReports" }
]);
const activeOrgHash = computed(() => {
const coops = user.value?.settings?.cooperatives;
if (Array.isArray(coops) && coops.length > 0) {
return coops[0];
}
return null;
});
const hubTab = ref('overview');
// Helper to filter items based on roles and module state
const filterByRole = (items) => {
return items.filter(item => {
if (item.module && !uiStore.isModuleEnabled(item.module)) return false;
if (!item.roles || item.roles === 'all') return true;
if (Array.isArray(item.roles)) {
return item.roles.some(role => hasRole(role));
}
return hasRole(item.roles);
});
};
// Primary Grid Services
const services = computed(() => filterByRole([
{ icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/32248fe10b94.bin', title: 'Users', pagename: 'UserList', roles: [UserTypes.ULTIMATE, UserTypes.SUPER_OPERATOR] },
{ icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/85605eacd4c8.bin', title: 'Stores', pagename: 'ManageStoresAdmin', roles: [UserTypes.ULTIMATE, UserTypes.SUPER_OPERATOR, UserTypes.OPERATOR] },
{ icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/ef1a9a079a2d.svg', title: 'Products', pagename: 'ManageProductsAdmin', roles: [UserTypes.ULTIMATE, UserTypes.SUPER_OPERATOR] },
{ icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/c9fd442fe676.bin', title: 'Transactions', pagename: 'ManageGlobalTransactions', roles: [UserTypes.ULTIMATE, UserTypes.SUPER_OPERATOR] },
{ icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/f87407046b18.bin', title: 'Reports', pagename: 'ListReports', roles: [UserTypes.ULTIMATE, UserTypes.SUPER_OPERATOR] },
{ icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/fa711c34b4ef.svg', title: 'Accounting', pagename: 'AccountingDashboard', roles: [UserTypes.ULTIMATE, UserTypes.SUPER_OPERATOR, UserTypes.OPERATOR], module: 'accounting' },
{ icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/5b5ef88c0ad1.svg', title: 'POS Keys', pagename: 'PosAccessKeys', roles: [UserTypes.ULTIMATE, UserTypes.SUPER_OPERATOR, UserTypes.OPERATOR] },
{ icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/04d0e432a298.bin', title: 'Market', pagename: 'ListProductsMarket', roles: 'all' },
{ icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/4d9cb130fad1.bin', title: 'Shipments', pagename: 'ShipmentList', roles: 'all' },
{ icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/5d0ad5d52b8c.bin', title: 'Farmers', pagename: 'FarmerProfileEdit', roles: 'all' },
{ icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/3360da347e6b.svg', title: 'Verification', pagename: 'VerificationDashboard', roles: [UserTypes.ULTIMATE, UserTypes.SUPER_OPERATOR, UserTypes.OPERATOR] },
{ icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/d9b9b9179ce0.svg', title: 'Cooperatives', pagename: 'CooperativeList', roles: 'all' },
{ icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/01f266928e54.svg', title: 'Console', pagename: 'UltimateConsole', roles: [UserTypes.ULTIMATE] },
{ icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/3d721f4acf47.svg', title: 'Announce', pagename: 'ManageAnnouncements', roles: [UserTypes.ULTIMATE] },
]));
// Secondary List Actions
const quickActionsItems = computed(() => filterByRole([
{ text: 'Onboard New User', pagename: 'CreateUser', icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/516ed2aaaa4c.bin', roles: [UserTypes.ULTIMATE, UserTypes.SUPER_OPERATOR] },
{ text: 'Register New Store', pagename: 'CreateStore', icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/85605eacd4c8.bin', roles: [UserTypes.ULTIMATE, UserTypes.SUPER_OPERATOR, UserTypes.OPERATOR] },
{ text: 'Create New Product', pagename: 'CreateProductUltimate', icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/f0a0193d728e.bin', roles: [UserTypes.ULTIMATE, UserTypes.SUPER_OPERATOR] },
{ text: 'Create New Cooperative', pagename: 'CreateCooperative', icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/d9b9b9179ce0.svg', roles: [UserTypes.ULTIMATE, UserTypes.SUPER_OPERATOR] },
{ text: 'Add Organization', pagename: 'CreateOrganization', icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/f0cc8da0402c.svg', roles: [UserTypes.ULTIMATE, UserTypes.SUPER_OPERATOR, UserTypes.OPERATOR] },
{ text: 'My Personal Profile', pagename: 'UserInfoEdit', pagestring: user.hashkey, icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/ac7a1cebe580.bin', roles: 'all' },
{ text: 'Referrals & Leads', pagename: 'ListReferrals', icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/7d45d4bdbc74.bin', roles: 'all', module: 'properties' },
{ text: 'Property Listings', pagename: 'ListProperties', icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/53c45417d1d1.bin', roles: 'all', module: 'properties' },
{ text: 'Send Credit Transfer', pagename: 'TransferMyCredit', icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/074af7aca12b.bin', roles: 'all' },
{ text: 'Global System Settings', pagename: 'SystemSettings', icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/b1e4fd15d8bd.bin', roles: [UserTypes.ULTIMATE] },
{ text: 'Landing Page Editor', pagename: 'LandingPageEditor', icon: 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/3d721f4acf47.svg', roles: [UserTypes.ULTIMATE, UserTypes.SUPER_OPERATOR, UserTypes.COORDINATOR] },
]));
// Remove internal reactive recentItems as we use useActivity now
const showNotesModal = () => {
modal.continueCancelModal({
title: 'Notes',
body: h('div', {
style: 'white-space: pre-wrap; font-size: 16px; line-height: 1.5; color: #333;'
}, notes.value),
continueText: 'Dismiss Note',
cancelText: 'Close',
continueClass: 'btn btn-danger w-50 py-2 rounded-3 shadow-sm fw-bold',
cancelClass: 'btn btn-light w-50 py-2 rounded-3 border fw-bold text-muted',
onContinue: async () => {
const success = await dismissNotes();
if (success) {
await fetchNotes();
}
}
});
};
onMounted(async () => {
precache(); // Precache transactions for smoother experience
fetchRecentActivities(10); // Fetch real activities
const result = await fetchPageData(url, payload);
if (result && result.stale) {
showStaleIndicator.value = true;
}
// Update stats if data provides them
if (data.value?.stats) {
stats.value = stats.value.map(s => {
if (data.value.stats[s.numberId] !== undefined) {
return { ...s, number: data.value.stats[s.numberId] };
}
return s;
});
}
// Fetch notes and auto-show if they exist
await fetchNotes();
if (hasNotes()) {
showNotesModal();
}
// --- Start background universal prefetch ---
// User wants "everything" preloaded once they land on home.
// Staggered trigger to ensure smooth initial experience.
setTimeout(() => {
prefetchEverything();
}, 1000); // 1-second delay to give priority to dashboard data
});
const handleItemClick = (item) => {
if (item.pagename) {
navigate({ page: item.pagename, props: { data: item.pagestring || '' } });
}
};
</script>
<template>
<div class="home-ultimate-fragment pb-5">
<!-- Global loading skeleton -->
<HomeSkeleton v-if="loading && !data" />
<!-- Stale data notification -->
<div v-if="showStaleIndicator" class="stale-notice">
Displaying cached command center data.
</div>
<!-- Main Content -->
<div v-if="data">
<!-- Balance / Stats Box -->
<BalanceBox :stats="stats" :footer-items="balanceFooterItems" @footer-click="handleItemClick" />
<!-- Primary Services Grid -->
<div class="mt-2">
<ServiceButtonGrid :items="services" @item-click="handleItemClick" />
</div>
<!-- Global Announcements -->
<div class="mt-3">
<GlobalAnnouncement />
</div>
<!-- Primary Cooperative Hub -->
<div v-if="activeOrgHash" class="mt-4 px-3">
<div class="d-flex align-items-center justify-content-between mb-3">
<h5 class="fw_7 mb-0 d-flex align-items-center gap-2" style="color: var(--text-primary) !important;">
<i class="fas fa-landmark text-primary opacity-50"></i>
Cooperative Hub
</h5>
<div class="d-flex gap-1 bg-soft-primary p-1 rounded-pill">
<button
@click="hubTab = 'overview'"
:class="['btn btn-xs rounded-pill px-3 transition-all', hubTab === 'overview' ? 'btn-primary shadow-sm' : 'btn-transparent text-primary small']"
>
Overview
</button>
<button
@click="hubTab = 'docs'"
:class="['btn btn-xs rounded-pill px-3 transition-all', hubTab === 'docs' ? 'btn-primary shadow-sm' : 'btn-transparent text-primary small']"
>
Docs
</button>
<button
@click="hubTab = 'votes'"
:class="['btn btn-xs rounded-pill px-3 transition-all', hubTab === 'votes' ? 'btn-primary shadow-sm' : 'btn-transparent text-primary small']"
>
Resolutions
</button>
</div>
</div>
<div class="card border-0 shadow-sm rounded-20 bg-white overflow-hidden p-0">
<div v-if="hubTab === 'overview'">
<CooperativeDetail :target="activeOrgHash" />
</div>
<div v-else-if="hubTab === 'docs'" class="p-3">
<DocumentRepository :org-hash="activeOrgHash" />
</div>
<div v-else-if="hubTab === 'votes'" class="p-3">
<GovernanceResolutions :org-hash="activeOrgHash" />
</div>
</div>
</div>
<!-- Detailed Quick Actions -->
<div class="tf-container mt-4">
<h5 class="fw_7 mb-3" style="color: var(--text-primary) !important;">Management & Tools</h5>
<SideTextButtonList :items="quickActionsItems" @item-click="handleItemClick" />
</div>
<!-- Recent Activity List -->
<div class="mt-4 px-3">
<div class="activity-section card border-0 shadow-sm rounded-4 bg-white overflow-hidden">
<div class="card-header bg-white border-0 py-3 px-4 d-flex align-items-center justify-content-between">
<h5 class="fw-bold mb-0 d-flex align-items-center gap-2">
<i class="fas fa-history text-primary"></i>
Recent System Activity
</h5>
<a
href="javascript:void(0);"
class="btn btn-sm btn-outline-primary rounded-pill px-3 fw-semibold"
@click="navigate({ page: 'ManageGlobalTransactions' })"
>
View All
</a>
</div>
<div class="card-body p-0">
<SearchableList
title=""
:items="activities"
:loading="loadingActivities"
empty-text="No recent activity recorded"
@item-click="handleItemClick"
/>
</div>
</div>
</div>
</div>
<!-- Error display -->
<div v-if="error && !data">
<div class="tf-container mt-5 text-center">
<p style="color: var(--text-muted);">Command center disconnected. Please retry.</p>
<button class="btn btn-primary mt-2 rounded-pill px-4" @click="fetchPageData(url, payload)">Reconnect</button>
</div>
</div>
</div>
</template>
<style scoped>
.spinner-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 200px;
}
.spinner {
width: 40px;
height: 40px;
border: 5px solid var(--bg-tertiary);
border-top-color: var(--accent-color);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.stale-notice {
background-color: #fff3cd;
color: #856404;
padding: 8px;
border-radius: 4px;
margin-bottom: 10px;
font-size: 0.85rem;
text-align: center;
}
.announcement-img {
width: 100%;
border-radius: 10px;
box-shadow: 0 0 5px rgba(0, 0, 0, 0.1);
margin-bottom: 15px;
}
.announcement-text {
line-height: 1.6;
font-size: 0.95rem;
}
.btn-xs {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
line-height: 1.5;
border-radius: 50rem;
}
.bg-soft-primary {
background-color: rgba(var(--primary-rgb), 0.1);
}
.transition-all {
transition: all 0.2s ease;
}
</style>

View 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>