512 lines
21 KiB
Vue
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>
|