Files
BarangaySystem/resources/js/Pages/AccountingDashboard.vue
2026-06-06 18:43:00 +08:00

512 lines
21 KiB
Vue

<template>
<div class="accounting-dashboard min-vh-100 bg-light pb-5">
<!-- Premium Header -->
<header class="header-premium text-white py-4 shadow-sm position-relative overflow-hidden mb-4 bg-primary-gradient">
<div class="container position-relative z-2">
<div class="d-flex align-items-center justify-content-between flex-wrap gap-4">
<div class="d-flex align-items-center gap-4 animate-fade-in">
<div class="display-container position-relative bg-white rounded-circle p-3 shadow">
<i class="fas fa-file-invoice-dollar fa-2x text-primary"></i>
</div>
<div>
<h2 class="fw-bold text-white mb-0">Accounting Dashboard</h2>
<p class="text-white-50 small text-uppercase ls-wide mt-1">
{{ isBig3 ? 'Financial Records & Reports' : 'Store Financial Records' }}
</p>
</div>
</div>
<a href="/manage-accounts" class="btn btn-light btn-sm fw-semibold shadow-sm">
<i class="fas fa-sitemap me-1"></i> Manage accounts
</a>
</div>
</div>
</header>
<div class="container">
<div class="card border-0 shadow-lg rounded-4 bg-white overflow-hidden">
<!-- Tabs Header -->
<div class="card-header bg-white border-bottom p-0">
<ul class="nav nav-tabs nav-justified border-0" id="accountingTabs" role="tablist">
<li class="nav-item" role="presentation">
<button
class="nav-link py-3 fw-bold border-0"
:class="{ 'active text-primary border-bottom border-primary border-3': activeTab === 'daily' }"
@click="activeTab = 'daily'"
>
<i class="fas fa-calendar-day me-2"></i> Daily Entry
</button>
</li>
<li class="nav-item" role="presentation">
<button
class="nav-link py-3 fw-bold border-0"
:class="{ 'active text-primary border-bottom border-primary border-3': activeTab === 'transactions' }"
@click="activeTab = 'transactions'"
>
<i class="fas fa-list me-2"></i> Transactions
</button>
</li>
<li class="nav-item" role="presentation">
<button
class="nav-link py-3 fw-bold border-0"
:class="{ 'active text-primary border-bottom border-primary border-3': activeTab === 'monthly' }"
@click="activeTab = 'monthly'"
>
<i class="fas fa-table me-2"></i> Monthly Matrix
</button>
</li>
</ul>
</div>
<div class="card-body p-4">
<!-- Daily Entry Tab -->
<div v-if="activeTab === 'daily'" class="animate-fade-in">
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="fw-bold mb-0 text-dark"><i class="fas fa-calendar-alt text-primary me-2"></i> Date Selection</h4>
<div class="d-flex align-items-center gap-3">
<input type="date" v-model="selectedDate" class="form-control form-control-lg fw-bold text-center border-primary shadow-sm" style="max-width: 200px;" @change="fetchDailyData" />
<button @click="fetchDailyData" class="btn btn-primary" :disabled="loading.daily">
<i class="fas fa-sync-alt" :class="{ 'fa-spin': loading.daily }"></i>
</button>
</div>
</div>
<div v-if="loading.daily" class="text-center py-5">
<div class="spinner-border text-primary" role="status"></div>
<div class="mt-2 text-muted fw-bold">Loading accounts...</div>
</div>
<div v-else>
<div class="alert alert-info border-0 rounded-3 small">
<i class="fas fa-info-circle me-2"></i> Enter amounts for the selected date. Leave empty or 0 if no transaction occurred.
</div>
<!-- Grouping Leaf Accounts by their immediate parent -->
<div class="row g-4 mt-2">
<div v-for="(accounts, parentName) in groupedLeafAccounts" :key="parentName" class="col-md-6 col-lg-4">
<div class="card h-100 border-0 shadow-sm rounded-3 bg-light">
<div class="card-header bg-dark text-white fw-bold border-0 py-2 rounded-top-3">
{{ parentName }}
</div>
<div class="card-body p-3">
<div v-for="acc in accounts" :key="acc.id" class="mb-3">
<label class="form-label small fw-bold text-muted mb-1">{{ acc.name }}</label>
<div class="input-group input-group-sm">
<span class="input-group-text bg-white text-muted"></span>
<input type="number" class="form-control fw-bold" v-model="dailyEntries[acc.id].amount" placeholder="0.00" step="0.01">
</div>
<input type="text" class="form-control form-control-sm mt-1" v-model="dailyEntries[acc.id].notes" placeholder="Notes (optional)...">
</div>
</div>
</div>
</div>
</div>
<div class="text-end mt-4 pt-3 border-top">
<button @click="saveDailyData" class="btn btn-success btn-lg px-5 rounded-pill shadow fw-bold" :disabled="savingDaily">
<i class="fas fa-save me-2" :class="{ 'fa-spin': savingDaily }"></i>
{{ savingDaily ? 'Saving...' : 'Save Daily Record' }}
</button>
</div>
</div>
</div>
<!-- Transactions List Tab -->
<div v-if="activeTab === 'transactions'" class="animate-fade-in">
<div class="d-flex flex-wrap justify-content-between align-items-center mb-4 gap-3">
<h4 class="fw-bold mb-0 text-dark"><i class="fas fa-list text-primary me-2"></i> Transaction History</h4>
<div class="d-flex gap-2">
<input type="date" v-model="filters.date_from" class="form-control form-control-sm" title="From Date">
<input type="date" v-model="filters.date_to" class="form-control form-control-sm" title="To Date">
<button @click="fetchTransactions" class="btn btn-primary btn-sm px-3"><i class="fas fa-search"></i></button>
</div>
</div>
<div class="table-responsive bg-white rounded-3 shadow-sm border">
<table class="table table-hover align-middle mb-0 text-nowrap">
<thead class="bg-light text-muted small fw-bold text-uppercase">
<tr>
<th class="ps-3">Date</th>
<th>Account</th>
<th>Type</th>
<th>Amount</th>
<th>Notes</th>
<th class="text-end pe-3">Actions</th>
</tr>
</thead>
<tbody>
<tr v-if="loading.transactions" class="text-center">
<td colspan="6" class="py-4"><div class="spinner-border text-primary spinner-border-sm"></div> Loading...</td>
</tr>
<tr v-else-if="transactions.length === 0" class="text-center">
<td colspan="6" class="py-4 text-muted">No transactions found</td>
</tr>
<tr v-for="txn in transactions" :key="txn.id">
<td class="ps-3 fw-bold text-dark">{{ formatDate(txn.transaction_date) }}</td>
<td>
<div class="d-flex flex-column">
<span class="fw-bold">{{ txn.account?.name || 'Unknown' }}</span>
<span class="small text-muted">{{ txn.account?.parent?.name }}</span>
</div>
</td>
<td>
<span class="badge" :class="txn.flow === 'INCOME' ? 'bg-success' : 'bg-danger'">{{ txn.flow }}</span>
</td>
<td class="fw-bold" :class="txn.flow === 'INCOME' ? 'text-success' : 'text-danger'">
{{ txn.flow === 'INCOME' ? '+' : '-' }} {{ Number(txn.amount).toLocaleString(undefined, {minimumFractionDigits: 2}) }}
</td>
<td class="text-muted small text-truncate" style="max-width: 150px;" :title="txn.notes">{{ txn.notes || '-' }}</td>
<td class="text-end pe-3">
<button @click="deleteTransaction(txn.id)" class="btn btn-outline-danger btn-sm rounded-circle"><i class="fas fa-trash"></i></button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Monthly Matrix Tab -->
<div v-if="activeTab === 'monthly'" class="animate-fade-in">
<div class="d-flex flex-wrap justify-content-between align-items-center mb-4 gap-3">
<h4 class="fw-bold mb-0 text-dark"><i class="fas fa-table text-primary me-2"></i> Monthly Overview</h4>
<div class="d-flex gap-2 align-items-center">
<select v-model="reportMonth" class="form-select form-select-sm fw-bold border-primary shadow-sm">
<option v-for="(m, i) in months" :value="i+1" :key="i">{{ m }}</option>
</select>
<input type="number" v-model="reportYear" class="form-control form-control-sm fw-bold border-primary shadow-sm" style="width: 80px;">
<button @click="fetchMonthlyReport" class="btn btn-primary btn-sm px-3 fw-bold"><i class="fas fa-sync-alt" :class="{ 'fa-spin': loading.monthly }"></i> Load</button>
</div>
</div>
<div v-if="loading.monthly" class="text-center py-5">
<div class="spinner-border text-primary" role="status"></div>
<div class="mt-2 text-muted fw-bold">Generating Report...</div>
</div>
<div v-else>
<div v-for="topNode in accountTree" :key="topNode.id" class="mb-5 animate-slide-up">
<div class="d-flex justify-content-between align-items-center mb-2">
<h5 class="fw-black text-primary ls-tight mb-0">
<i class="fas fa-table me-2"></i> {{ topNode.name }}
</h5>
<div class="badge bg-soft-primary text-primary px-3 py-2 rounded-pill small fw-bold">
{{ reportMonthName }} {{ reportYear }}
</div>
</div>
<div class="table-responsive shadow-sm rounded border matrix-table-container mb-3">
<table class="table table-bordered table-sm mb-0 text-nowrap matrix-table" style="font-size: 0.82rem;">
<thead class="bg-dark text-white text-center align-middle sticky-top">
<tr>
<th rowspan="2" class="bg-primary text-white sticky-col" style="width: 60px; z-index: 5;">Date</th>
<template v-for="mid in topNode.children" :key="mid.id">
<th :colspan="countLeafs(mid)" class="bg-dark text-white border-white-10">{{ mid.name }}</th>
</template>
<th rowspan="2" class="bg-success text-white" style="width: 100px;">TOTAL</th>
</tr>
<tr>
<!-- Render Leaf Names -->
<template v-for="mid in topNode.children" :key="'sub_'+mid.id">
<template v-if="mid.children && mid.children.length > 0">
<th v-for="leaf in mid.children" :key="leaf.id" class="bg-secondary text-white fw-normal border-white-10" style="min-width: 80px;">
{{ leaf.name }}
</th>
</template>
<template v-else>
<th class="bg-secondary text-white fw-normal border-white-10" style="min-width: 80px;">{{ mid.name }}</th>
</template>
</template>
</tr>
</thead>
<tbody>
<tr v-for="day in matrixDays" :key="day" class="page-row">
<td class="text-center fw-bold bg-light sticky-col">{{ day }}</td>
<template v-for="mid in topNode.children" :key="'td_mid_'+mid.id">
<template v-if="mid.children && mid.children.length > 0">
<td v-for="leaf in mid.children" :key="'td_leaf_'+leaf.id" class="text-end px-2">
{{ formatAmount(matrixData[day][leaf.id]) }}
</td>
</template>
<template v-else>
<td class="text-end px-2">{{ formatAmount(matrixData[day][mid.id]) }}</td>
</template>
</template>
<td class="text-end fw-bold bg-soft-success text-success px-2">
{{ formatAmount(getRowTotalForCategory(day, topNode)) }}
</td>
</tr>
</tbody>
<tfoot class="bg-light fw-bold sticky-bottom">
<tr>
<td class="text-center text-primary sticky-col">TOTAL</td>
<template v-for="mid in topNode.children" :key="'tf_mid_'+mid.id">
<template v-if="mid.children && mid.children.length > 0">
<td v-for="leaf in mid.children" :key="'tf_leaf_'+leaf.id" class="text-end text-primary px-2">
{{ formatAmount(matrixTotals[leaf.id]) }}
</td>
</template>
<template v-else>
<td class="text-end text-primary px-2">{{ formatAmount(matrixTotals[mid.id]) }}</td>
</template>
</template>
<td class="text-end text-success px-2">
{{ formatAmount(getColumnTotalForCategory(topNode)) }}
</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue';
import axios from 'axios';
import { useUIStore } from '@/stores/ui';
import { useAuth } from '@/composables/Core/useAuth';
const uiStore = useUIStore();
uiStore.setPageTitle('Accounting Dashboard');
const { isUltimate, isSuperOperator, isOperator } = useAuth();
const isBig3 = computed(() => isUltimate.value || isSuperOperator.value || isOperator.value);
const activeTab = ref('daily');
const selectedDate = ref(new Date().toISOString().split('T')[0]);
const leafAccounts = ref([]);
const groupedLeafAccounts = ref({});
const dailyEntries = ref({});
const savingDaily = ref(false);
const transactions = ref([]);
const filters = ref({ date_from: '', date_to: '' });
const accountTree = ref([]);
const reportMonth = ref(new Date().getMonth() + 1);
const reportYear = ref(new Date().getFullYear());
const months = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
const matrixDays = ref(0);
const matrixData = ref({});
const matrixTotals = ref({});
const loading = ref({
daily: false,
transactions: false,
monthly: false,
tree: false
});
onMounted(() => {
fetchLeafAccounts();
fetchAccountTree();
});
const fetchLeafAccounts = async () => {
loading.value.daily = true;
try {
const res = await axios.post('/admin/accounting/leaf', {});
leafAccounts.value = res.data.data;
// Group them
const grouped = {};
const entries = {};
leafAccounts.value.forEach(acc => {
const parentName = acc.parent ? acc.parent.name : 'Uncategorized';
if (!grouped[parentName]) grouped[parentName] = [];
grouped[parentName].push(acc);
entries[acc.id] = { amount: '', notes: '' };
});
groupedLeafAccounts.value = grouped;
dailyEntries.value = entries;
// Once structure is ready, fetch today's data
await fetchDailyData();
} catch (error) {
console.error(error);
} finally {
loading.value.daily = false;
}
};
const fetchDailyData = async () => {
loading.value.daily = true;
try {
const res = await axios.post('/admin/accounting/daily', { date: selectedDate.value });
const existing = res.data.data;
// Reset entries first
Object.keys(dailyEntries.value).forEach(id => {
dailyEntries.value[id] = { amount: '', notes: '' };
});
// Fill with existing
Object.keys(existing).forEach(id => {
if(dailyEntries.value[id]) {
dailyEntries.value[id].amount = existing[id].amount;
dailyEntries.value[id].notes = existing[id].notes;
}
});
} catch (error) {
console.error(error);
} finally {
loading.value.daily = false;
}
};
const saveDailyData = async () => {
savingDaily.value = true;
try {
const res = await axios.post('/admin/accounting/daily/save', {
date: selectedDate.value,
entries: dailyEntries.value
});
if(res.data.success) {
if (window.toastr) window.toastr.success('Daily transactions saved successfully!');
}
} catch (error) {
console.error(error);
if (window.toastr) window.toastr.error('Failed to save transactions.');
} finally {
savingDaily.value = false;
}
};
const fetchTransactions = async () => {
loading.value.transactions = true;
try {
const res = await axios.post('/admin/accounting/transactions', filters.value);
transactions.value = res.data.data.data; // paginated
} catch (error) {
console.error(error);
} finally {
loading.value.transactions = false;
}
};
const deleteTransaction = async (id) => {
if(!confirm("Are you sure you want to delete this transaction?")) return;
try {
const res = await axios.post('/admin/accounting/transactions/delete', { id });
if(res.data.success) {
if (window.toastr) window.toastr.success('Transaction deleted');
fetchTransactions();
}
} catch (error) {
console.error(error);
}
};
const fetchAccountTree = async () => {
try {
const res = await axios.post('/admin/accounting/tree', {});
accountTree.value = res.data.data;
} catch (error) {
console.error(error);
}
};
const fetchMonthlyReport = async () => {
loading.value.monthly = true;
try {
const res = await axios.post('/admin/accounting/reports/monthly', {
month: reportMonth.value,
year: reportYear.value
});
matrixDays.value = res.data.days_in_month;
matrixData.value = res.data.matrix;
matrixTotals.value = res.data.column_totals;
} catch (error) {
console.error(error);
} finally {
loading.value.monthly = false;
}
};
const reportMonthName = computed(() => months[reportMonth.value - 1]);
// Utils for Matrix
const countLeafs = (node) => {
if (!node.children || node.children.length === 0) return 1;
let count = 0;
node.children.forEach(child => {
count += countLeafs(child);
});
return count;
};
const getLeafIds = (node) => {
if (!node.children || node.children.length === 0) return [node.id];
let ids = [];
node.children.forEach(child => {
ids = ids.concat(getLeafIds(child));
});
return ids;
};
const getRowTotalForCategory = (day, topNode) => {
const leafIds = getLeafIds(topNode);
let total = 0;
leafIds.forEach(id => {
total += parseFloat(matrixData.value[day][id] || 0);
});
return total;
};
const getColumnTotalForCategory = (topNode) => {
const leafIds = getLeafIds(topNode);
let total = 0;
leafIds.forEach(id => {
total += parseFloat(matrixTotals.value[id] || 0);
});
return total;
};
const formatAmount = (val) => {
if (!val || val == 0) return '-';
return Number(val).toLocaleString(undefined, {minimumFractionDigits: 2});
};
const formatDate = (dateString) => {
const d = new Date(dateString);
return d.toLocaleDateString();
};
</script>
<style scoped>
.bg-primary-gradient {
background: linear-gradient(135deg, #0d6efd 0%, #0a58ca 100%);
}
.matrix-table-container {
max-height: 65vh;
overflow: auto;
}
.matrix-table th, .matrix-table td {
vertical-align: middle;
}
.matrix-table .sticky-col {
position: sticky;
left: 0;
z-index: 2;
box-shadow: inset -1px 0 0 rgba(0,0,0,0.1);
}
.matrix-table .sticky-top {
position: sticky;
top: 0;
z-index: 3;
}
.matrix-table .sticky-bottom {
position: sticky;
bottom: 0;
z-index: 3;
box-shadow: inset 0 1px 0 rgba(0,0,0,0.1);
}
</style>