feat: complete all plan phases — reports, cleanup market fragments
- Add Reports page with population/household/document/blotter/budget/project views - Add ReportsController with year-filtered queries for all report types - Add /reports module to config/modules.php - Register /barangay/reports in VueRouteMap and web.php - Remove unused market Home fragments (HomeCoopMember, HomeStoreOwner, etc.) - Remove leftover market Components/Market/ directory - Add Reports card to Home.vue admin quick access
This commit is contained in:
226
app/Http/Controllers/Barangay/ReportsController.php
Normal file
226
app/Http/Controllers/Barangay/ReportsController.php
Normal file
@@ -0,0 +1,226 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Barangay;
|
||||
|
||||
use App\Enums\UserActions;
|
||||
use App\Http\Controllers\Helpers\Permissions\UserPermissions;
|
||||
use App\Http\Controllers\Helpers\ResponseHelper;
|
||||
use App\Models\Barangay\Blotter;
|
||||
use App\Models\Barangay\BarangayBudget;
|
||||
use App\Models\Barangay\BarangayProject;
|
||||
use App\Models\Barangay\DocumentRequest;
|
||||
use App\Models\Barangay\Household;
|
||||
use App\Models\Barangay\Resident;
|
||||
use Carbon\Carbon;
|
||||
use Hypervel\Http\Request;
|
||||
use Hypervel\Support\Facades\Auth;
|
||||
use Hypervel\Support\Facades\DB;
|
||||
|
||||
class ReportsController
|
||||
{
|
||||
public function generate(Request $request)
|
||||
{
|
||||
if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::ViewGlobalReports)) {
|
||||
return ResponseHelper::returnUnauthorized();
|
||||
}
|
||||
|
||||
$type = $request->input('type', 'population');
|
||||
$year = (int) $request->input('year', date('Y'));
|
||||
|
||||
$data = match ($type) {
|
||||
'population' => $this->populationReport(),
|
||||
'households' => $this->householdsReport(),
|
||||
'documents' => $this->documentsReport($year),
|
||||
'blotters' => $this->blottersReport($year),
|
||||
'budget' => $this->budgetReport($year),
|
||||
'projects' => $this->projectsReport($year),
|
||||
default => [],
|
||||
};
|
||||
|
||||
return response()->json(['success' => true, 'data' => $data]);
|
||||
}
|
||||
|
||||
private function populationReport(): array
|
||||
{
|
||||
$total = Resident::count();
|
||||
$active = Resident::where('is_active', true)->count();
|
||||
$voters = Resident::where('voter_status', true)->count();
|
||||
|
||||
$genderBreakdown = Resident::select('gender', DB::raw('count(*) as count'))
|
||||
->whereNotNull('gender')
|
||||
->groupBy('gender')
|
||||
->pluck('count', 'gender')
|
||||
->toArray();
|
||||
|
||||
$civilBreakdown = Resident::select('civil_status', DB::raw('count(*) as count'))
|
||||
->whereNotNull('civil_status')
|
||||
->groupBy('civil_status')
|
||||
->pluck('count', 'civil_status')
|
||||
->toArray();
|
||||
|
||||
$purokBreakdown = Resident::select('purok', DB::raw('count(*) as count'))
|
||||
->whereNotNull('purok')
|
||||
->groupBy('purok')
|
||||
->orderBy('purok')
|
||||
->pluck('count', 'purok')
|
||||
->toArray();
|
||||
|
||||
// Age groups
|
||||
$now = Carbon::now();
|
||||
$ageGroups = [
|
||||
'0–12 (Children)' => [0, 12],
|
||||
'13–17 (Youth)' => [13, 17],
|
||||
'18–30 (Young Adult)' => [18, 30],
|
||||
'31–59 (Adult)' => [31, 59],
|
||||
'60+ (Senior)' => [60, 150],
|
||||
];
|
||||
|
||||
$ageBreakdown = [];
|
||||
foreach ($ageGroups as $label => [$min, $max]) {
|
||||
$from = $now->copy()->subYears($max)->format('Y-m-d');
|
||||
$to = $now->copy()->subYears($min)->format('Y-m-d');
|
||||
$ageBreakdown[$label] = Resident::whereBetween('date_of_birth', [$from, $to])->count();
|
||||
}
|
||||
|
||||
return compact('total', 'active', 'voters', 'genderBreakdown', 'civilBreakdown', 'purokBreakdown', 'ageBreakdown') + [
|
||||
'total_residents' => $total,
|
||||
'active_residents' => $active,
|
||||
'registered_voters' => $voters,
|
||||
'gender_breakdown' => $genderBreakdown,
|
||||
'civil_status_breakdown'=> $civilBreakdown,
|
||||
'purok_breakdown' => $purokBreakdown,
|
||||
'age_breakdown' => $ageBreakdown,
|
||||
];
|
||||
}
|
||||
|
||||
private function householdsReport(): array
|
||||
{
|
||||
$total = Household::count();
|
||||
$withElectricity = Household::where('has_electricity', true)->count();
|
||||
$withWater = Household::where('has_water', true)->count();
|
||||
$avgMembers = round(Household::withCount('chapterMembers')->avg('chapter_members_count') ?? 0, 1);
|
||||
|
||||
$byOwnership = Household::select('ownership_type', DB::raw('count(*) as count'))
|
||||
->groupBy('ownership_type')
|
||||
->pluck('count', 'ownership_type')
|
||||
->toArray();
|
||||
|
||||
return [
|
||||
'total_households' => $total,
|
||||
'with_electricity' => $withElectricity,
|
||||
'with_water' => $withWater,
|
||||
'avg_members' => $avgMembers,
|
||||
'by_ownership' => $byOwnership,
|
||||
];
|
||||
}
|
||||
|
||||
private function documentsReport(int $year): array
|
||||
{
|
||||
$base = DocumentRequest::whereYear('created_at', $year);
|
||||
|
||||
$total = (clone $base)->count();
|
||||
$claimed = (clone $base)->where('status', 'CLAIMED')->count();
|
||||
$revenue = (clone $base)->where('payment_status', 'PAID')->sum('base_fee');
|
||||
|
||||
$byStatus = (clone $base)->select('status', DB::raw('count(*) as count'))
|
||||
->groupBy('status')
|
||||
->pluck('count', 'status')
|
||||
->toArray();
|
||||
|
||||
$byType = (clone $base)->select(
|
||||
'barangay_request_types.name',
|
||||
DB::raw('count(*) as count'),
|
||||
DB::raw('sum(case when barangay_document_requests.payment_status = \'PAID\' then barangay_document_requests.base_fee else 0 end) as revenue')
|
||||
)
|
||||
->leftJoin('barangay_request_types', 'barangay_request_types.id', '=', 'barangay_document_requests.request_type_id')
|
||||
->groupBy('barangay_request_types.name')
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
return compact('total', 'claimed', 'revenue', 'byStatus', 'byType') + [
|
||||
'total_revenue' => $revenue,
|
||||
'by_status' => $byStatus,
|
||||
'by_type' => $byType,
|
||||
];
|
||||
}
|
||||
|
||||
private function blottersReport(int $year): array
|
||||
{
|
||||
$base = Blotter::whereYear('created_at', $year);
|
||||
|
||||
$total = (clone $base)->count();
|
||||
$resolved = (clone $base)->whereIn('status', ['RESOLVED', 'SETTLED'])->count();
|
||||
$pending = (clone $base)->whereIn('status', ['FILED', 'FOR_HEARING'])->count();
|
||||
|
||||
$byStatus = (clone $base)->select('status', DB::raw('count(*) as count'))
|
||||
->groupBy('status')
|
||||
->pluck('count', 'status')
|
||||
->toArray();
|
||||
|
||||
$byType = (clone $base)->select('incident_type', DB::raw('count(*) as count'))
|
||||
->groupBy('incident_type')
|
||||
->pluck('count', 'incident_type')
|
||||
->toArray();
|
||||
|
||||
return [
|
||||
'total' => $total,
|
||||
'resolved' => $resolved,
|
||||
'pending' => $pending,
|
||||
'by_status' => $byStatus,
|
||||
'by_incident_type'=> $byType,
|
||||
];
|
||||
}
|
||||
|
||||
private function budgetReport(int $year): array
|
||||
{
|
||||
$income = BarangayBudget::income()->byYear($year)->sum('amount');
|
||||
$expense = BarangayBudget::expense()->byYear($year)->sum('amount');
|
||||
$balance = $income - $expense;
|
||||
|
||||
$bySource = BarangayBudget::select('source', 'category', DB::raw('sum(amount) as amount'))
|
||||
->byYear($year)
|
||||
->groupBy('source', 'category')
|
||||
->orderBy('category')
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
return [
|
||||
'summary' => compact('income', 'expense', 'balance') + [
|
||||
'total_income' => $income,
|
||||
'total_expense' => $expense,
|
||||
],
|
||||
'by_source' => $bySource,
|
||||
];
|
||||
}
|
||||
|
||||
private function projectsReport(int $year): array
|
||||
{
|
||||
$base = BarangayProject::whereYear('created_at', $year);
|
||||
|
||||
$total = (clone $base)->count();
|
||||
$ongoing = (clone $base)->where('status', 'ONGOING')->count();
|
||||
$completed = (clone $base)->where('status', 'COMPLETED')->count();
|
||||
$budget = (clone $base)->sum('budget');
|
||||
|
||||
$byStatus = (clone $base)->select('status', DB::raw('count(*) as count'))
|
||||
->groupBy('status')
|
||||
->pluck('count', 'status')
|
||||
->toArray();
|
||||
|
||||
$byType = (clone $base)->select('type', DB::raw('count(*) as count'))
|
||||
->groupBy('type')
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
return [
|
||||
'total' => $total,
|
||||
'ongoing' => $ongoing,
|
||||
'completed' => $completed,
|
||||
'total_budget' => $budget,
|
||||
'by_status' => $byStatus,
|
||||
'by_type' => $byType,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,9 @@ class VueRouteMap
|
||||
|
||||
// ── Budget
|
||||
'/barangay/budgetledger' => ['component' => 'Barangay.BudgetLedger', 'loginRequired' => true, 'module' => 'budget'],
|
||||
|
||||
// ── Reports
|
||||
'/barangay/reports' => ['component' => 'Barangay.Reports', 'loginRequired' => true, 'module' => 'reports'],
|
||||
];
|
||||
|
||||
|
||||
|
||||
@@ -155,6 +155,17 @@ return [
|
||||
'description' => 'Fee schedules for certificate types and barangay services.',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Reports
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
'reports' => [
|
||||
'enabled' => (bool) env('MODULE_REPORTS_ENABLED', true),
|
||||
'label' => 'Reports',
|
||||
'description' => 'Population, document, blotter, budget, and project reports.',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Landing Pages
|
||||
|
||||
@@ -1,259 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
session: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const isExpanded = ref(false);
|
||||
|
||||
const toggleExpand = () => {
|
||||
isExpanded.value = !isExpanded.value;
|
||||
};
|
||||
|
||||
const transactions = computed(() => {
|
||||
return props.session.transactions || [];
|
||||
});
|
||||
|
||||
const formattedDate = computed(() => {
|
||||
if (!props.session.created_at) return 'N/A';
|
||||
const date = new Date(props.session.created_at);
|
||||
return date.toLocaleString('en-PH', {
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
}).replace(',', ' •');
|
||||
});
|
||||
|
||||
const statusClass = computed(() => {
|
||||
switch (props.session.status) {
|
||||
case 'completed': return 'badge-soft-success';
|
||||
case 'active': return 'badge-soft-primary';
|
||||
case 'voided': return 'badge-soft-danger';
|
||||
default: return 'badge-soft-secondary';
|
||||
}
|
||||
});
|
||||
|
||||
const paymentIcon = computed(() => {
|
||||
switch (props.session.payment_method?.toLowerCase()) {
|
||||
case 'cash': return 'fas fa-money-bill-wave';
|
||||
case 'credit': return 'fas fa-credit-card';
|
||||
case 'online': return 'fas fa-mobile-alt';
|
||||
default: return 'fas fa-receipt';
|
||||
}
|
||||
});
|
||||
|
||||
const formatCurrency = (amount) => {
|
||||
return new Intl.NumberFormat('en-PH', {
|
||||
style: 'currency',
|
||||
currency: 'PHP'
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const getProductInfo = (transaction) => {
|
||||
return {
|
||||
name: transaction.product?.name || 'Unknown Product',
|
||||
quantity: transaction.quantity || 0,
|
||||
unitPrice: transaction.price_at_sale || 0,
|
||||
totalPrice: transaction.total_price || 0
|
||||
};
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card mb-3 border-0 shadow-sm rounded-4 overflow-hidden pos-history-card">
|
||||
<div class="card-body p-3">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<div>
|
||||
<span :class="['badge rounded-pill px-3 py-2 text-uppercase fw-bold', statusClass]">
|
||||
{{ session.status }}
|
||||
</span>
|
||||
<h6 class="mb-0 mt-2 text-primary fw-bold">
|
||||
{{ session.customer_name || 'Walk-in Customer' }}
|
||||
</h6>
|
||||
<small class="text-muted d-block mt-1">
|
||||
<i class="far fa-clock me-1"></i> {{ formattedDate }}
|
||||
</small>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<h5 class="mb-0 fw-black text-dark">
|
||||
{{ formatCurrency(session.total_amount) }}
|
||||
</h5>
|
||||
<small class="text-muted">
|
||||
{{ session.items_count }} {{ session.items_count === 1 ? 'item' : 'items' }}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-center justify-content-between mt-3 pt-3 border-top border-light">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="payment-icon-wrapper bg-light rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 32px; height: 32px;">
|
||||
<i :class="[paymentIcon, 'text-muted sm']"></i>
|
||||
</div>
|
||||
<span class="text-muted small text-capitalize">{{ session.payment_method || 'N/A' }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="session.hashkey" class="text-muted small">
|
||||
<span class="badge bg-light text-muted fw-normal rounded-pill">#{{ session.hashkey.substring(0, 8) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expandable Items Section -->
|
||||
<div v-if="isExpanded && transactions.length > 0" class="items-section mt-3 pt-3 border-top border-light">
|
||||
<div class="items-header d-flex align-items-center mb-2">
|
||||
<i class="fas fa-box-open text-muted me-2"></i>
|
||||
<span class="text-muted small fw-bold">Transaction Items</span>
|
||||
</div>
|
||||
<div class="items-list">
|
||||
<div v-for="item in transactions" :key="item.id" class="item-row d-flex align-items-center justify-content-between py-2 border-bottom border-light">
|
||||
<div class="item-info flex-grow-1">
|
||||
<div class="item-name text-dark fw-semibold small">
|
||||
{{ getProductInfo(item).name }}
|
||||
</div>
|
||||
<div class="item-qty text-muted small">
|
||||
{{ getProductInfo(item).quantity }} × {{ formatCurrency(getProductInfo(item).unitPrice) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="item-total text-end">
|
||||
<span class="fw-bold text-dark small">
|
||||
{{ formatCurrency(getProductInfo(item).totalPrice) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer with Toggle Button -->
|
||||
<div
|
||||
v-if="transactions.length > 0"
|
||||
class="card-footer-toggle d-flex align-items-center justify-content-center mt-3 pt-3 border-top border-light cursor-pointer"
|
||||
@click="toggleExpand"
|
||||
>
|
||||
<span class="text-muted small fw-bold me-2">
|
||||
{{ isExpanded ? 'Hide Items' : 'View Items' }}
|
||||
</span>
|
||||
<i :class="['fas text-muted small', isExpanded ? 'fa-chevron-up' : 'fa-chevron-down']"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pos-history-card {
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.pos-history-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 20px rgba(0,0,0,0.05) !important;
|
||||
}
|
||||
|
||||
:global(.dark-mode) .pos-history-card {
|
||||
background: rgba(var(--bg-card-rgb), 0.7);
|
||||
backdrop-filter: blur(15px);
|
||||
}
|
||||
|
||||
.badge-soft-success {
|
||||
background-color: rgba(40, 167, 69, 0.1);
|
||||
color: #28a745;
|
||||
}
|
||||
|
||||
.badge-soft-primary {
|
||||
background-color: rgba(0, 123, 255, 0.1);
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
.badge-soft-danger {
|
||||
background-color: rgba(220, 53, 69, 0.1);
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.badge-soft-secondary {
|
||||
background-color: rgba(108, 117, 125, 0.1);
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
:global(.dark-mode) .badge-soft-success { background-color: rgba(40, 167, 69, 0.2); }
|
||||
:global(.dark-mode) .badge-soft-primary { background-color: rgba(0, 123, 255, 0.2); }
|
||||
:global(.dark-mode) .badge-soft-danger { background-color: rgba(220, 53, 69, 0.2); }
|
||||
:global(.dark-mode) .border-light { border-color: rgba(255,255,255,0.05) !important; }
|
||||
|
||||
/* Font Awesome standard sizes for visual hierarchy as per dictionary */
|
||||
.sm { font-size: 0.875rem; }
|
||||
|
||||
/* Items section styling */
|
||||
.items-section {
|
||||
animation: slideDown 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
max-height: 500px;
|
||||
}
|
||||
}
|
||||
|
||||
.item-row:last-child {
|
||||
border-bottom: none !important;
|
||||
}
|
||||
|
||||
.item-row {
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.item-row:hover {
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
:global(.dark-mode) .item-row:hover {
|
||||
background-color: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
:global(.dark-mode) .item-name,
|
||||
:global(.dark-mode) .item-total span {
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
/* Toggle button styling */
|
||||
.card-footer-toggle {
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card-footer-toggle:hover {
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
:global(.dark-mode) .card-footer-toggle:hover {
|
||||
background-color: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
/* Mobile responsiveness */
|
||||
@media (max-width: 576px) {
|
||||
.item-name {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.item-qty {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.item-total span {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,93 +0,0 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { usePosStore } from '../../stores/pos';
|
||||
import PosHistoryCard from './PosHistoryCard.vue';
|
||||
|
||||
const props = defineProps({
|
||||
storeHash: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const posStore = usePosStore();
|
||||
const isLoadingMore = ref(false);
|
||||
|
||||
onMounted(async () => {
|
||||
// Only fetch if empty or if needed (could always fetch for simplicity on mount)
|
||||
await posStore.fetchPosSessions(props.storeHash, 1);
|
||||
});
|
||||
|
||||
const loadMore = async () => {
|
||||
if (posStore.posSessions.length < posStore.posSessionsCount && !posStore.loading) {
|
||||
isLoadingMore.value = true;
|
||||
await posStore.fetchPosSessions(props.storeHash, posStore.posSessionsPage + 1);
|
||||
isLoadingMore.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pos-history-list position-relative mt-2">
|
||||
<div v-if="posStore.loading && posStore.posSessions.length === 0" class="text-center py-5">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
<p class="mt-2 text-muted small">Loading POS history...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="posStore.posSessions.length === 0" class="text-center py-5 bg-light rounded-4 border border-dashed">
|
||||
<div class="empty-state-icon mb-3 opacity-2">
|
||||
<i class="fad fa-receipt fa-4x text-muted"></i>
|
||||
</div>
|
||||
<p class="text-muted small">No POS history found for this store.</p>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div class="history-items">
|
||||
<div v-for="session in posStore.posSessions" :key="session.hashkey">
|
||||
<PosHistoryCard :session="session" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="posStore.posSessions.length < posStore.posSessionsCount" class="text-center mt-4">
|
||||
<button
|
||||
@click="loadMore"
|
||||
class="btn btn-outline-primary btn-sm rounded-pill px-4 py-2 d-inline-flex align-items-center fw-bold"
|
||||
:disabled="isLoadingMore || posStore.loading"
|
||||
>
|
||||
<span v-if="isLoadingMore || posStore.loading" class="spinner-border spinner-border-sm me-2" role="status"></span>
|
||||
<i v-else class="fas fa-plus me-2"></i>
|
||||
{{ isLoadingMore || posStore.loading ? 'LOADING...' : 'LOAD MORE' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.empty-state-icon {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.opacity-2 {
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.pos-history-list {
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.bg-light {
|
||||
background-color: var(--bg-secondary) !important;
|
||||
}
|
||||
|
||||
.border-dashed {
|
||||
border-style: dashed !important;
|
||||
border-width: 2px !important;
|
||||
}
|
||||
|
||||
:global(.dark-mode) .bg-light {
|
||||
background-color: rgba(0,0,0,0.2) !important;
|
||||
}
|
||||
</style>
|
||||
@@ -1,108 +0,0 @@
|
||||
<template>
|
||||
<div class="pos-today-stats mb-4">
|
||||
<div class="d-flex align-items-center justify-content-between mb-3 mt-1">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="icon-avatar me-3 shadow-sm">
|
||||
<i class="fas fa-chart-line text-primary"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h5 class="fw_7 mb-0">Today's Performance</h5>
|
||||
<span class="text-muted small">Daily sales summary</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!loading" class="date-badge px-3 py-1 text-primary small fw_6 rounded-pill border">
|
||||
Today
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="text-center py-4 glass-card p-3 p-md-4 rounded-xl">
|
||||
<div class="spinner-border text-primary spinner-border-sm" role="status">
|
||||
<span class="sr-only">Loading...</span>
|
||||
</div>
|
||||
<p class="small text-muted mt-2 mb-0">Fetching stats...</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="glass-card p-3 p-md-4 rounded-xl">
|
||||
<div class="row text-center mt-2">
|
||||
<div class="col-6 border-right">
|
||||
<p class="small text-muted mb-1 text-uppercase ls_1">Transactions</p>
|
||||
<h3 class="mb-0 fw_7">{{ todayStats.count || 0 }}</h3>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<p class="small text-muted mb-1 text-uppercase ls_1">Total Sales</p>
|
||||
<h3 class="mb-0 fw_7">₱{{ formatAmount(todayStats.total) }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!loading && todayStats.store_name" class="mt-3 text-center border-top-dashed pt-3">
|
||||
<p class="small text-muted italic mb-0">
|
||||
Terminal: <span class="fw_6 text-primary">{{ todayStats.store_name }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { usePosStore } from '../../stores/pos'
|
||||
|
||||
const props = defineProps({
|
||||
loading: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const posStore = usePosStore()
|
||||
const todayStats = computed(() => posStore.todayStats)
|
||||
|
||||
const formatAmount = (val) => {
|
||||
if (!val) return '0.00'
|
||||
return parseFloat(val).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pos-today-stats {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.ls_1 {
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.border-right {
|
||||
border-right: 1px solid var(--border-color);
|
||||
}
|
||||
.border-top-dashed {
|
||||
border-top: 1px dashed var(--border-color);
|
||||
}
|
||||
.icon-avatar {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.date-badge {
|
||||
background: rgba(var(--primary-rgb), 0.05) !important;
|
||||
}
|
||||
|
||||
.rounded-xl {
|
||||
border-radius: 20px !important;
|
||||
}
|
||||
|
||||
:global(.dark-mode) .icon-avatar {
|
||||
background: #2d3138;
|
||||
}
|
||||
|
||||
:global(.dark-mode) .icon-avatar i {
|
||||
color: #10b981 !important;
|
||||
}
|
||||
:global(.dark-mode) .date-badge {
|
||||
background: rgba(16, 185, 129, 0.1) !important;
|
||||
color: #10b981 !important;
|
||||
}
|
||||
:global(.dark-mode) .text-primary {
|
||||
color: #10b981 !important;
|
||||
}
|
||||
</style>
|
||||
@@ -1,138 +0,0 @@
|
||||
<template>
|
||||
<div class="product-card" @click="$emit('click')">
|
||||
<div class="product-image-wrapper">
|
||||
<FileImage :src="image" :alt="name" class="product-image" fallback="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/146710fe9ece.bin" />
|
||||
<div v-if="price" class="product-price-badge">
|
||||
₱{{ price }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="product-info">
|
||||
<h5 class="product-name">{{ name }}</h5>
|
||||
<p v-if="unit" class="product-unit">per {{ unit }}</p>
|
||||
<p v-if="description" class="product-description text-truncate-2">
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import FileImage from '../Core/FileImage.vue'
|
||||
|
||||
const props = defineProps({
|
||||
name: { type: String, required: true },
|
||||
price: { type: [String, Number], default: '' },
|
||||
unit: { type: String, default: '' },
|
||||
description: { type: String, default: '' },
|
||||
image: { type: String, default: '' }
|
||||
})
|
||||
|
||||
defineEmits(['click'])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.product-card {
|
||||
background: #ffffff;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
cursor: pointer;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.product-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.product-image-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding-top: 100%;
|
||||
/* 1:1 Aspect Ratio */
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.product-image {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.product-price-badge {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
background: rgba(66, 185, 131, 0.9);
|
||||
color: white;
|
||||
padding: 6px 12px;
|
||||
border-top-left-radius: 12px;
|
||||
font-weight: 700;
|
||||
backdrop-filter: blur(4px);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.product-info {
|
||||
padding: 12px;
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.product-name {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
color: #2c3e50;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.product-unit {
|
||||
font-size: 0.75rem;
|
||||
color: #7f8c8d;
|
||||
margin-bottom: 8px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.product-description {
|
||||
font-size: 0.85rem;
|
||||
color: #636e72;
|
||||
margin-bottom: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.text-truncate-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Dark mode support (if applicable) */
|
||||
:global(.dark-mode) .product-card {
|
||||
background: #24272c;
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
:global(.dark-mode) .product-name {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
:global(.dark-mode) .product-description {
|
||||
color: #b0b0b0;
|
||||
}
|
||||
|
||||
:global(.dark-mode) .product-image-wrapper {
|
||||
background: #1a1c20;
|
||||
}
|
||||
</style>
|
||||
@@ -1,128 +0,0 @@
|
||||
<template>
|
||||
<div class="store-card" @click="$emit('click')">
|
||||
<div class="store-image-wrapper">
|
||||
<img :src="resolvedImage" :alt="name" class="store-image" @error="handleImageError" />
|
||||
<div v-if="category" class="store-category-badge">
|
||||
{{ category }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="store-info">
|
||||
<h5 class="store-name">{{ name }}</h5>
|
||||
<p v-if="subcategory" class="store-subcategory">{{ subcategory }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
const props = defineProps({
|
||||
name: { type: String, required: true },
|
||||
category: { type: String, default: '' },
|
||||
subcategory: { type: String, default: '' },
|
||||
image: { type: String, default: '' }
|
||||
})
|
||||
|
||||
defineEmits(['click'])
|
||||
|
||||
const resolvedImage = computed(() => {
|
||||
if (!props.image) return 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/85605eacd4c8.bin';
|
||||
// Return blob URLs directly
|
||||
if (props.image.startsWith('blob:')) {
|
||||
return props.image;
|
||||
}
|
||||
// Check for http, https, or data URIs
|
||||
if (props.image.startsWith('http') || props.image.startsWith('/') || props.image.startsWith('data:')) {
|
||||
return props.image;
|
||||
}
|
||||
// If it's a hash (long string without slashes), resolve it
|
||||
return `/RequestData/File/${props.image}`;
|
||||
});
|
||||
|
||||
const handleImageError = (event) => {
|
||||
event.target.src = 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/85605eacd4c8.bin';
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.store-card {
|
||||
background: #ffffff;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
cursor: pointer;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.store-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.store-image-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding-top: 60%;
|
||||
/* 16:9 approx */
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.store-image {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.store-category-badge {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background: rgba(0, 123, 255, 0.85);
|
||||
color: white;
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
font-weight: 600;
|
||||
backdrop-filter: blur(4px);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.store-info {
|
||||
padding: 12px;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.store-name {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 2px;
|
||||
color: #2c3e50;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.store-subcategory {
|
||||
font-size: 0.8rem;
|
||||
color: #7f8c8d;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
:global(.dark-mode) .store-card {
|
||||
background: #24272c;
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
:global(.dark-mode) .store-name {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
:global(.dark-mode) .store-image-wrapper {
|
||||
background: #1a1c20;
|
||||
}
|
||||
</style>
|
||||
@@ -1,378 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, watch } from 'vue';
|
||||
import axios from 'axios';
|
||||
import LoadingSpinner from '../LoadingSpinner.vue';
|
||||
import Dropzone from '../Core/Dropzone.vue';
|
||||
import { useFileUpload } from '../../composables/useFileUpload.js';
|
||||
|
||||
const props = defineProps({
|
||||
productHash: { type: String, required: true },
|
||||
storeHash: { type: String, default: null },
|
||||
onSaved: { type: Function, default: null },
|
||||
onClose: { type: Function, default: null }
|
||||
});
|
||||
|
||||
const { uploadFile, removeHash, setInitialHashes, isUploading: isFileUploading } = useFileUpload({
|
||||
category: 'ProductMarket',
|
||||
maxSizeMB: 10
|
||||
});
|
||||
|
||||
// Form state
|
||||
const productName = ref('');
|
||||
const productDescription = ref('');
|
||||
const productCategory = ref('');
|
||||
const productSubcategory = ref('');
|
||||
const productPrice = ref(0);
|
||||
const productUnitName = ref('');
|
||||
const productAvailable = ref(0);
|
||||
const productBarcode = ref('');
|
||||
|
||||
// Data lists
|
||||
const categoryList = ref([]);
|
||||
const subcategoryList = ref([]);
|
||||
|
||||
// Loading state
|
||||
const isLoading = ref(false);
|
||||
const isSaving = ref(false);
|
||||
const showSuccessState = ref(false);
|
||||
const successMessage = ref('');
|
||||
|
||||
const error = ref(null);
|
||||
|
||||
// Dropzone handling
|
||||
const dropzoneRef = ref(null);
|
||||
const dropzoneFiles = ref([]);
|
||||
|
||||
onMounted(async () => {
|
||||
await loadCategories();
|
||||
await loadProductData();
|
||||
});
|
||||
|
||||
const loadCategories = async () => {
|
||||
try {
|
||||
const response = await axios.post('/Products/New/Category/Datalist', {});
|
||||
const data = response.data.categories || response.data;
|
||||
if (data && Array.isArray(data)) {
|
||||
categoryList.value = data.map(item => ({
|
||||
value: typeof item === 'string' ? item : item[0],
|
||||
label: typeof item === 'string' ? item : (item[1] || item[0])
|
||||
}));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading categories:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const loadSubcategories = async () => {
|
||||
if (!productCategory.value) {
|
||||
subcategoryList.value = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await axios.post('/Products/New/SubCategory/Datalist', {
|
||||
category: productCategory.value
|
||||
});
|
||||
const data = response.data.subcategories || response.data;
|
||||
if (data && Array.isArray(data)) {
|
||||
subcategoryList.value = data.map(item => ({
|
||||
value: typeof item === 'string' ? item : item[0],
|
||||
label: typeof item === 'string' ? item : (item[1] || item[0])
|
||||
}));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading subcategories:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const loadProductData = async () => {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
const response = await axios.post('/View/Product/Details/data', {
|
||||
target: props.productHash,
|
||||
data: {
|
||||
product_id: props.productHash,
|
||||
store_hash: props.storeHash
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data && response.data.success && response.data.data) {
|
||||
const product = response.data.data;
|
||||
productName.value = product.name || '';
|
||||
productDescription.value = props.storeHash ? (product.store_description || product.description) : (product.description || '');
|
||||
productCategory.value = product.category || '';
|
||||
productSubcategory.value = product.subcategory || '';
|
||||
productPrice.value = props.storeHash ? (product.store_price || product.price) : (product.price || 0);
|
||||
productUnitName.value = product.unitname || '';
|
||||
productBarcode.value = product.barcode || '';
|
||||
productAvailable.value = product.available || 0;
|
||||
|
||||
if (productCategory.value) {
|
||||
await loadSubcategories();
|
||||
productSubcategory.value = product.subcategory || '';
|
||||
}
|
||||
|
||||
if (product.photourlDropzone && Array.isArray(product.photourlDropzone)) {
|
||||
dropzoneFiles.value = product.photourlDropzone.map(f => ({
|
||||
file: { name: f.name || 'Image' },
|
||||
hashkey: f.hashkey,
|
||||
progress: 100,
|
||||
uploading: false,
|
||||
preview: f.url
|
||||
}));
|
||||
setInitialHashes(product.photourlDropzone.map(f => f.hashkey));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading product data:', err);
|
||||
error.value = 'Failed to load product data';
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
watch(() => dropzoneFiles.value, async (newFiles) => {
|
||||
const filesToUpload = newFiles.filter(f => !f.uploading && !f.hashkey && !f.error);
|
||||
for (const fileObj of filesToUpload) {
|
||||
const index = newFiles.indexOf(fileObj);
|
||||
if (index === -1) continue;
|
||||
dropzoneRef.value.setFileStatus(index, { uploading: true, progress: 30 });
|
||||
const result = await uploadFile(fileObj.file);
|
||||
if (result && result.hashkey) {
|
||||
dropzoneRef.value.setFileStatus(index, {
|
||||
uploading: false,
|
||||
progress: 100,
|
||||
hashkey: result.hashkey
|
||||
});
|
||||
} else {
|
||||
dropzoneRef.value.setFileStatus(index, {
|
||||
uploading: false,
|
||||
progress: 0,
|
||||
error: 'Upload failed'
|
||||
});
|
||||
}
|
||||
}
|
||||
}, { deep: true });
|
||||
|
||||
const handlePhotoRemoved = (hashkey) => {
|
||||
removeHash(hashkey);
|
||||
};
|
||||
|
||||
const handleCategoryChange = () => {
|
||||
loadSubcategories();
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!props.storeHash && (!productName.value || !productCategory.value)) {
|
||||
error.value = 'Name and Category are required';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
isSaving.value = true;
|
||||
error.value = null;
|
||||
|
||||
const response = await axios.post('/Products/Admin/Edit/', {
|
||||
target: props.productHash,
|
||||
data: {
|
||||
store_hash: props.storeHash
|
||||
},
|
||||
EditProductName: productName.value,
|
||||
EditProductDescription: productDescription.value,
|
||||
EditProductCategory: productCategory.value,
|
||||
EditProductSubCategory: productSubcategory.value,
|
||||
EditProductPrice: parseFloat(productPrice.value),
|
||||
EditProductUnitName: productUnitName.value,
|
||||
EditProductAvailable: parseInt(productAvailable.value),
|
||||
EditProductBarcode: productBarcode.value,
|
||||
status: true,
|
||||
photourl: dropzoneFiles.value
|
||||
.filter(f => f.hashkey)
|
||||
.map(f => f.hashkey)
|
||||
});
|
||||
|
||||
if (response.data && response.data.success) {
|
||||
showSuccessState.value = true;
|
||||
successMessage.value = 'Product updated successfully!';
|
||||
setTimeout(() => {
|
||||
if (props.onSaved) props.onSaved();
|
||||
}, 1500);
|
||||
} else {
|
||||
error.value = response.data?.message || 'Failed to update product';
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.message || 'Failed to update product';
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="update-product-modal p-1">
|
||||
<div v-if="isLoading" class="text-center py-5">
|
||||
<LoadingSpinner />
|
||||
<p class="mt-2 text-muted">Loading product details...</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="successMessage" class="alert alert-success rounded-xl mb-3 animate-fade-in">
|
||||
<i class="fas fa-check-circle me-2"></i> {{ successMessage }}
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="alert alert-danger rounded-xl mb-3 animate-shake">
|
||||
<i class="fas fa-exclamation-triangle me-2"></i> {{ error }}
|
||||
</div>
|
||||
|
||||
<div class="form-scroll-area custom-scrollbar pe-2" style="max-height: 70vh; overflow-y: auto;">
|
||||
<div class="row g-3">
|
||||
<div v-if="!storeHash" class="col-12">
|
||||
<div class="form-group mb-3">
|
||||
<label class="form-label fw_7 small text-muted text-uppercase">Product Name</label>
|
||||
<input v-model="productName" type="text" class="form-control rounded-pill border-0 shadow-sm px-4" placeholder="Enter product name">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<div class="form-group mb-3">
|
||||
<label class="form-label fw_7 small text-muted text-uppercase">Description</label>
|
||||
<textarea v-model="productDescription" class="form-control rounded-2xl border-0 shadow-sm px-4 py-3" rows="3" placeholder="Enter description"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!storeHash" class="col-md-6">
|
||||
<div class="form-group mb-3">
|
||||
<label class="form-label fw_7 small text-muted text-uppercase">Category</label>
|
||||
<select v-model="productCategory" @change="handleCategoryChange" class="form-select rounded-pill border-0 shadow-sm px-4">
|
||||
<option value="" disabled>Select Category</option>
|
||||
<option v-for="cat in categoryList" :key="cat.value" :value="cat.value">{{ cat.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!storeHash" class="col-md-6">
|
||||
<div class="form-group mb-3">
|
||||
<label class="form-label fw_7 small text-muted text-uppercase">Subcategory</label>
|
||||
<select v-model="productSubcategory" :disabled="!subcategoryList.length" class="form-select rounded-pill border-0 shadow-sm px-4">
|
||||
<option value="" disabled>Select Subcategory</option>
|
||||
<option v-for="sub in subcategoryList" :key="sub.value" :value="sub.value">{{ sub.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="form-group mb-3">
|
||||
<label class="form-label fw_7 small text-muted text-uppercase">{{ storeHash ? 'Store Price' : 'Global Price' }} (PHP)</label>
|
||||
<input v-model="productPrice" type="number" step="0.01" class="form-control rounded-pill border-0 shadow-sm px-4">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!storeHash" class="col-md-6">
|
||||
<div class="form-group mb-3">
|
||||
<label class="form-label fw_7 small text-muted text-uppercase">Unit</label>
|
||||
<input v-model="productUnitName" type="text" class="form-control rounded-pill border-0 shadow-sm px-4" placeholder="e.g. 25kg">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="form-group mb-3">
|
||||
<label class="form-label fw_7 small text-muted text-uppercase">Available Stock</label>
|
||||
<input v-model="productAvailable" type="number" class="form-control rounded-pill border-0 shadow-sm px-4">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!storeHash" class="col-md-6">
|
||||
<div class="form-group mb-3">
|
||||
<label class="form-label fw_7 small text-muted text-uppercase">Barcode</label>
|
||||
<input v-model="productBarcode" type="text" class="form-control rounded-pill border-0 shadow-sm px-4" maxlength="12">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!storeHash" class="col-12 mt-2">
|
||||
<label class="form-label fw_7 small text-muted text-uppercase mb-2">Product Photos</label>
|
||||
<Dropzone ref="dropzoneRef" v-model:files="dropzoneFiles" @removed="handlePhotoRemoved" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer-actions d-flex gap-3 mt-4 pt-3 border-top">
|
||||
<button type="button" class="btn btn-light rounded-pill px-4 flex-fill fw_6" @click="onClose" :disabled="isSaving">Cancel</button>
|
||||
<AnimatedButton
|
||||
type="button"
|
||||
btnClass="btn btn-primary rounded-pill px-5 flex-fill fw_6 shadow-sm"
|
||||
@click="handleSubmit"
|
||||
:loading="isSaving"
|
||||
:success="showSuccessState"
|
||||
:disabled="isFileUploading"
|
||||
>
|
||||
Update Product
|
||||
</AnimatedButton>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.form-label {
|
||||
margin-bottom: 0.4rem;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.form-control, .form-select {
|
||||
background: #f8f9fa;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.form-control:focus, .form-select:focus {
|
||||
background: #fff;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.05) !important;
|
||||
}
|
||||
|
||||
.rounded-2xl { border-radius: 1.2rem; }
|
||||
.rounded-xl { border-radius: 1rem; }
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: #ddd;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: #ccc;
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(-5px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
10%, 90% { transform: translate3d(-1px, 0, 0); }
|
||||
20%, 80% { transform: translate3d(2px, 0, 0); }
|
||||
30%, 50%, 70% { transform: translate3d(-4px, 0, 0); }
|
||||
40%, 60% { transform: translate3d(4px, 0, 0); }
|
||||
}
|
||||
|
||||
.animate-shake {
|
||||
animation: shake 0.5s cubic-bezier(.36,.07,.19,.97) both;
|
||||
}
|
||||
|
||||
:global(.dark-mode) .form-control,
|
||||
:global(.dark-mode) .form-select {
|
||||
background: #24272c;
|
||||
color: #eee;
|
||||
}
|
||||
|
||||
:global(.dark-mode) .form-control:focus,
|
||||
:global(.dark-mode) .form-select:focus {
|
||||
background: #2c3036;
|
||||
}
|
||||
</style>
|
||||
336
resources/js/Pages/Barangay/Reports.vue
Normal file
336
resources/js/Pages/Barangay/Reports.vue
Normal file
@@ -0,0 +1,336 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { usePageTitle } from '../../composables/Core/usePageTitle';
|
||||
import { executeRequest } from '../../utils/executeRequest.js';
|
||||
|
||||
usePageTitle('Barangay Reports');
|
||||
|
||||
const loading = ref(false);
|
||||
const activeReport = ref('population');
|
||||
const selectedYear = ref(new Date().getFullYear());
|
||||
const data = ref({});
|
||||
|
||||
const years = computed(() => {
|
||||
const current = new Date().getFullYear();
|
||||
return Array.from({ length: 5 }, (_, i) => current - i);
|
||||
});
|
||||
|
||||
const reports = [
|
||||
{ key: 'population', label: 'Population Summary', icon: '👥' },
|
||||
{ key: 'households', label: 'Household Statistics', icon: '🏠' },
|
||||
{ key: 'documents', label: 'Document Requests', icon: '📋' },
|
||||
{ key: 'blotters', label: 'Blotter/Incident Log', icon: '⚖️' },
|
||||
{ key: 'budget', label: 'Budget & Finance', icon: '💰' },
|
||||
{ key: 'projects', label: 'Projects Summary', icon: '🏗️' },
|
||||
];
|
||||
|
||||
const loadReport = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await executeRequest('/reports/generate', 'POST', {
|
||||
type: activeReport.value,
|
||||
year: selectedYear.value,
|
||||
});
|
||||
if (res.success) data.value = res.data;
|
||||
} catch (e) { /* silent */ }
|
||||
loading.value = false;
|
||||
};
|
||||
|
||||
const printReport = () => window.print();
|
||||
|
||||
// Population computed
|
||||
const genderBreakdown = computed(() => data.value?.gender_breakdown ?? {});
|
||||
const civilStatusBreakdown = computed(() => data.value?.civil_status_breakdown ?? {});
|
||||
const ageBreakdown = computed(() => data.value?.age_breakdown ?? {});
|
||||
const purokBreakdown = computed(() => data.value?.purok_breakdown ?? {});
|
||||
|
||||
// Document computed
|
||||
const docByType = computed(() => data.value?.by_type ?? []);
|
||||
const docByStatus = computed(() => data.value?.by_status ?? {});
|
||||
|
||||
// Blotter computed
|
||||
const blotterByStatus = computed(() => data.value?.by_status ?? {});
|
||||
const blotterByType = computed(() => data.value?.by_incident_type ?? {});
|
||||
|
||||
// Budget computed
|
||||
const budgetSummary = computed(() => data.value?.summary ?? {});
|
||||
const budgetBySource = computed(() => data.value?.by_source ?? []);
|
||||
|
||||
// Projects computed
|
||||
const projectsByStatus = computed(() => data.value?.by_status ?? {});
|
||||
const projectsByType = computed(() => data.value?.by_type ?? []);
|
||||
|
||||
onMounted(loadReport);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4 max-w-5xl mx-auto">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-4 print:hidden">
|
||||
<h1 class="text-2xl font-bold">Barangay Reports</h1>
|
||||
<button @click="printReport" class="btn-secondary text-sm">🖨️ Print</button>
|
||||
</div>
|
||||
|
||||
<!-- Report selector -->
|
||||
<div class="flex flex-wrap gap-2 mb-4 print:hidden">
|
||||
<button
|
||||
v-for="r in reports" :key="r.key"
|
||||
@click="activeReport = r.key; loadReport()"
|
||||
:class="`px-3 py-1.5 rounded-lg text-sm font-medium border transition ${activeReport === r.key ? 'bg-blue-500 text-white border-blue-500' : 'bg-white text-gray-600 border-gray-200 hover:border-blue-300'}`">
|
||||
{{ r.icon }} {{ r.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Year selector -->
|
||||
<div class="flex items-center gap-2 mb-5 print:hidden">
|
||||
<label class="text-sm text-gray-600">Year:</label>
|
||||
<select v-model="selectedYear" @change="loadReport" class="input w-28 py-1 text-sm">
|
||||
<option v-for="y in years" :key="y" :value="y">{{ y }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Print title -->
|
||||
<div class="hidden print:block mb-6">
|
||||
<h1 class="text-xl font-bold">{{ reports.find(r => r.key === activeReport)?.label }} — {{ selectedYear }}</h1>
|
||||
<p class="text-sm text-gray-500">Generated: {{ new Date().toLocaleDateString('en-PH') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="text-center py-12 text-gray-400">Generating report...</div>
|
||||
|
||||
<div v-else>
|
||||
<!-- Population Report -->
|
||||
<div v-if="activeReport === 'population'" class="space-y-4">
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
<div class="bg-blue-50 rounded-lg p-4 text-center">
|
||||
<div class="text-3xl font-bold text-blue-600">{{ data.total_residents ?? 0 }}</div>
|
||||
<div class="text-sm text-gray-500 mt-1">Total Residents</div>
|
||||
</div>
|
||||
<div class="bg-green-50 rounded-lg p-4 text-center">
|
||||
<div class="text-3xl font-bold text-green-600">{{ data.active_residents ?? 0 }}</div>
|
||||
<div class="text-sm text-gray-500 mt-1">Active Residents</div>
|
||||
</div>
|
||||
<div class="bg-purple-50 rounded-lg p-4 text-center">
|
||||
<div class="text-3xl font-bold text-purple-600">{{ data.registered_voters ?? 0 }}</div>
|
||||
<div class="text-sm text-gray-500 mt-1">Registered Voters</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="bg-white rounded-lg shadow p-4">
|
||||
<h3 class="font-semibold text-gray-700 mb-3">By Gender</h3>
|
||||
<div v-for="(count, gender) in genderBreakdown" :key="gender" class="flex justify-between py-1 border-b text-sm">
|
||||
<span class="text-gray-600 capitalize">{{ gender.toLowerCase() }}</span>
|
||||
<span class="font-semibold">{{ count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow p-4">
|
||||
<h3 class="font-semibold text-gray-700 mb-3">By Civil Status</h3>
|
||||
<div v-for="(count, status) in civilStatusBreakdown" :key="status" class="flex justify-between py-1 border-b text-sm">
|
||||
<span class="text-gray-600 capitalize">{{ status.toLowerCase() }}</span>
|
||||
<span class="font-semibold">{{ count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow p-4">
|
||||
<h3 class="font-semibold text-gray-700 mb-3">By Purok</h3>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
<div v-for="(count, purok) in purokBreakdown" :key="purok" class="bg-gray-50 rounded p-2 text-sm flex justify-between">
|
||||
<span>Purok {{ purok }}</span>
|
||||
<span class="font-semibold">{{ count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow p-4">
|
||||
<h3 class="font-semibold text-gray-700 mb-3">Age Groups</h3>
|
||||
<div class="space-y-1">
|
||||
<div v-for="(count, group) in ageBreakdown" :key="group" class="flex items-center gap-2 text-sm">
|
||||
<span class="text-gray-600 w-28">{{ group }}</span>
|
||||
<div class="flex-1 bg-gray-100 rounded-full h-4 overflow-hidden">
|
||||
<div class="bg-blue-400 h-4 rounded-full" :style="`width: ${data.total_residents ? Math.round(count/data.total_residents*100) : 0}%`"></div>
|
||||
</div>
|
||||
<span class="font-semibold w-8 text-right">{{ count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Households Report -->
|
||||
<div v-else-if="activeReport === 'households'" class="space-y-4">
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
<div class="bg-blue-50 rounded-lg p-4 text-center">
|
||||
<div class="text-3xl font-bold text-blue-600">{{ data.total_households ?? 0 }}</div>
|
||||
<div class="text-sm text-gray-500 mt-1">Total Households</div>
|
||||
</div>
|
||||
<div class="bg-yellow-50 rounded-lg p-4 text-center">
|
||||
<div class="text-3xl font-bold text-yellow-600">{{ data.avg_members ?? 0 }}</div>
|
||||
<div class="text-sm text-gray-500 mt-1">Avg. Members</div>
|
||||
</div>
|
||||
<div class="bg-green-50 rounded-lg p-4 text-center">
|
||||
<div class="text-3xl font-bold text-green-600">{{ data.with_electricity ?? 0 }}</div>
|
||||
<div class="text-sm text-gray-500 mt-1">With Electricity</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow p-4">
|
||||
<h3 class="font-semibold text-gray-700 mb-3">By Ownership Type</h3>
|
||||
<div v-for="(count, type) in (data.by_ownership ?? {})" :key="type" class="flex justify-between py-1 border-b text-sm">
|
||||
<span class="capitalize">{{ type.toLowerCase() }}</span>
|
||||
<span class="font-semibold">{{ count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Document Requests Report -->
|
||||
<div v-else-if="activeReport === 'documents'" class="space-y-4">
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
<div class="bg-blue-50 rounded-lg p-4 text-center">
|
||||
<div class="text-3xl font-bold text-blue-600">{{ data.total ?? 0 }}</div>
|
||||
<div class="text-sm text-gray-500 mt-1">Total Requests</div>
|
||||
</div>
|
||||
<div class="bg-green-50 rounded-lg p-4 text-center">
|
||||
<div class="text-3xl font-bold text-green-600">₱{{ Number(data.total_revenue ?? 0).toLocaleString() }}</div>
|
||||
<div class="text-sm text-gray-500 mt-1">Total Revenue</div>
|
||||
</div>
|
||||
<div class="bg-teal-50 rounded-lg p-4 text-center">
|
||||
<div class="text-3xl font-bold text-teal-600">{{ data.claimed ?? 0 }}</div>
|
||||
<div class="text-sm text-gray-500 mt-1">Claimed</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow p-4">
|
||||
<h3 class="font-semibold text-gray-700 mb-3">By Document Type</h3>
|
||||
<table class="w-full text-sm">
|
||||
<thead class="text-left text-gray-500 border-b">
|
||||
<tr><th class="pb-2">Type</th><th class="pb-2">Count</th><th class="pb-2">Revenue</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="t in docByType" :key="t.type" class="border-b">
|
||||
<td class="py-1.5">{{ t.name }}</td>
|
||||
<td class="py-1.5">{{ t.count }}</td>
|
||||
<td class="py-1.5">₱{{ Number(t.revenue ?? 0).toLocaleString() }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow p-4">
|
||||
<h3 class="font-semibold text-gray-700 mb-3">By Status</h3>
|
||||
<div v-for="(count, status) in docByStatus" :key="status" class="flex justify-between py-1 border-b text-sm">
|
||||
<span class="capitalize">{{ status.toLowerCase() }}</span>
|
||||
<span class="font-semibold">{{ count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Blotter Report -->
|
||||
<div v-else-if="activeReport === 'blotters'" class="space-y-4">
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
<div class="bg-blue-50 rounded-lg p-4 text-center">
|
||||
<div class="text-3xl font-bold text-blue-600">{{ data.total ?? 0 }}</div>
|
||||
<div class="text-sm text-gray-500 mt-1">Total Cases</div>
|
||||
</div>
|
||||
<div class="bg-green-50 rounded-lg p-4 text-center">
|
||||
<div class="text-3xl font-bold text-green-600">{{ data.resolved ?? 0 }}</div>
|
||||
<div class="text-sm text-gray-500 mt-1">Resolved</div>
|
||||
</div>
|
||||
<div class="bg-orange-50 rounded-lg p-4 text-center">
|
||||
<div class="text-3xl font-bold text-orange-600">{{ data.pending ?? 0 }}</div>
|
||||
<div class="text-sm text-gray-500 mt-1">Pending</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="bg-white rounded-lg shadow p-4">
|
||||
<h3 class="font-semibold text-gray-700 mb-3">By Status</h3>
|
||||
<div v-for="(count, status) in blotterByStatus" :key="status" class="flex justify-between py-1 border-b text-sm">
|
||||
<span class="capitalize">{{ status.toLowerCase().replace('_', ' ') }}</span>
|
||||
<span class="font-semibold">{{ count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow p-4">
|
||||
<h3 class="font-semibold text-gray-700 mb-3">By Incident Type</h3>
|
||||
<div v-for="(count, type) in blotterByType" :key="type" class="flex justify-between py-1 border-b text-sm">
|
||||
<span class="capitalize">{{ type.toLowerCase() }}</span>
|
||||
<span class="font-semibold">{{ count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Budget Report -->
|
||||
<div v-else-if="activeReport === 'budget'" class="space-y-4">
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div class="bg-green-50 rounded-lg p-4 text-center">
|
||||
<div class="text-2xl font-bold text-green-600">₱{{ Number(budgetSummary.total_income ?? 0).toLocaleString() }}</div>
|
||||
<div class="text-sm text-gray-500 mt-1">Total Income</div>
|
||||
</div>
|
||||
<div class="bg-red-50 rounded-lg p-4 text-center">
|
||||
<div class="text-2xl font-bold text-red-600">₱{{ Number(budgetSummary.total_expense ?? 0).toLocaleString() }}</div>
|
||||
<div class="text-sm text-gray-500 mt-1">Total Expense</div>
|
||||
</div>
|
||||
<div class="bg-blue-50 rounded-lg p-4 text-center">
|
||||
<div class="text-2xl font-bold text-blue-600">₱{{ Number(budgetSummary.balance ?? 0).toLocaleString() }}</div>
|
||||
<div class="text-sm text-gray-500 mt-1">Balance</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow p-4">
|
||||
<h3 class="font-semibold text-gray-700 mb-3">By Source / Category</h3>
|
||||
<table class="w-full text-sm">
|
||||
<thead class="text-left text-gray-500 border-b">
|
||||
<tr><th class="pb-2">Source</th><th class="pb-2">Type</th><th class="pb-2">Amount</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in budgetBySource" :key="`${row.source}-${row.category}`" class="border-b">
|
||||
<td class="py-1.5">{{ row.source }}</td>
|
||||
<td class="py-1.5">
|
||||
<span :class="`text-xs px-1.5 py-0.5 rounded ${row.category === 'INCOME' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`">
|
||||
{{ row.category }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-1.5 font-medium">₱{{ Number(row.amount).toLocaleString() }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Projects Report -->
|
||||
<div v-else-if="activeReport === 'projects'" class="space-y-4">
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<div class="bg-blue-50 rounded-lg p-4 text-center">
|
||||
<div class="text-3xl font-bold text-blue-600">{{ data.total ?? 0 }}</div>
|
||||
<div class="text-sm text-gray-500 mt-1">Total Projects</div>
|
||||
</div>
|
||||
<div class="bg-yellow-50 rounded-lg p-4 text-center">
|
||||
<div class="text-3xl font-bold text-yellow-600">{{ data.ongoing ?? 0 }}</div>
|
||||
<div class="text-sm text-gray-500 mt-1">Ongoing</div>
|
||||
</div>
|
||||
<div class="bg-green-50 rounded-lg p-4 text-center">
|
||||
<div class="text-3xl font-bold text-green-600">{{ data.completed ?? 0 }}</div>
|
||||
<div class="text-sm text-gray-500 mt-1">Completed</div>
|
||||
</div>
|
||||
<div class="bg-purple-50 rounded-lg p-4 text-center">
|
||||
<div class="text-2xl font-bold text-purple-600">₱{{ Number(data.total_budget ?? 0).toLocaleString() }}</div>
|
||||
<div class="text-sm text-gray-500 mt-1">Total Budget</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="bg-white rounded-lg shadow p-4">
|
||||
<h3 class="font-semibold text-gray-700 mb-3">By Status</h3>
|
||||
<div v-for="(count, status) in projectsByStatus" :key="status" class="flex justify-between py-1 border-b text-sm">
|
||||
<span class="capitalize">{{ status.toLowerCase() }}</span>
|
||||
<span class="font-semibold">{{ count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow p-4">
|
||||
<h3 class="font-semibold text-gray-700 mb-3">By Type</h3>
|
||||
<div v-for="row in projectsByType" :key="row.type" class="flex justify-between py-1 border-b text-sm">
|
||||
<span class="capitalize">{{ row.type.toLowerCase() }}</span>
|
||||
<span class="font-semibold">{{ row.count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="!loading && Object.keys(data).length === 0" class="text-center text-gray-400 py-8">No data available for this report.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,214 +0,0 @@
|
||||
<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>
|
||||
@@ -1,182 +0,0 @@
|
||||
<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>
|
||||
@@ -1,153 +0,0 @@
|
||||
<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>
|
||||
@@ -1,169 +0,0 @@
|
||||
<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>
|
||||
@@ -1,378 +0,0 @@
|
||||
<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>
|
||||
@@ -1,263 +0,0 @@
|
||||
<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>
|
||||
@@ -1,177 +0,0 @@
|
||||
<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>
|
||||
@@ -1,117 +0,0 @@
|
||||
<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>
|
||||
@@ -1,141 +0,0 @@
|
||||
<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>
|
||||
@@ -1,224 +0,0 @@
|
||||
<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>
|
||||
@@ -1,250 +0,0 @@
|
||||
<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>
|
||||
@@ -1,341 +0,0 @@
|
||||
<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>
|
||||
@@ -1,352 +0,0 @@
|
||||
<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>
|
||||
@@ -31,6 +31,7 @@ const adminCards = [
|
||||
{ label: 'Projects', icon: '🏗️', route: '/Barangay/ManageProjects', color: 'bg-indigo-500' },
|
||||
{ label: 'Budget', icon: '💰', route: '/Barangay/BudgetLedger', color: 'bg-emerald-500' },
|
||||
{ label: 'Announcements', icon: '📢', route: '/ManageAnnouncements', color: 'bg-yellow-500' },
|
||||
{ label: 'Reports', icon: '📊', route: '/barangay/reports', color: 'bg-rose-500' },
|
||||
{ label: 'Settings', icon: '⚙️', route: '/SystemSettings', color: 'bg-gray-500' },
|
||||
];
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ use App\Http\Controllers\Admin\UserController;
|
||||
use App\Http\Controllers\Barangay\BlotterController;
|
||||
use App\Http\Controllers\Barangay\BlotterHearingController;
|
||||
use App\Http\Controllers\Barangay\BudgetController;
|
||||
use App\Http\Controllers\Barangay\ReportsController;
|
||||
use App\Http\Controllers\Barangay\DocumentRequestController;
|
||||
use App\Http\Controllers\Barangay\HouseholdController;
|
||||
use App\Http\Controllers\Barangay\ProjectController;
|
||||
@@ -315,3 +316,9 @@ Route::get('/budget/fiscal-years', [BudgetController::class, 'fiscalYears'], ['m
|
||||
Route::post('/budget/create', [BudgetController::class, 'store'], ['middleware' => 'auth']);
|
||||
Route::post('/budget/update', [BudgetController::class, 'update'], ['middleware' => 'auth']);
|
||||
Route::post('/budget/delete', [BudgetController::class, 'destroy'], ['middleware' => 'auth']);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Reports
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Route::post('/reports/generate', [ReportsController::class, 'generate'], ['middleware' => 'auth']);
|
||||
|
||||
Reference in New Issue
Block a user