Files
wms-app/app/ac_dashboard/index.php
T

484 lines
20 KiB
PHP

<?php
session_start();
require '../config.php';
require '../include_header.php';
?>
<body>
<?php require '../include_topbar.php'; ?>
<?php require '../include_sidebar_ac.php'; ?>
<main id="content" class="content py-15">
<div class="container-fluid">
<!-- Page header -->
<div class="row">
<div class="col-12">
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center mb-6 gap-3">
<div>
<h1 class="fs-3 mb-1">Accounting Dashboard</h1>
<p class="mb-0">Financial overview from posted journal entries</p>
</div>
</div>
</div>
</div>
<!-- Summary cards -->
<div class="row g-5 mb-5">
<div class="col-lg-3 col-sm-6 col-12">
<div class="card p-4 bg-info bg-opacity-10 border border-info border-opacity-25 rounded-2" style="position:relative;">
<div class="d-flex gap-3">
<div class="icon-shape icon-md bg-info text-white rounded-2">
<i class="ti ti-trending-up fs-4"></i>
</div>
<div>
<h2 class="mb-3 fs-6">Revenue</h2>
<h3 class="fw-bold mb-0" id="stat_revenue">—</h3>
<p class="text-info mb-0 small">Posted entries</p>
</div>
</div>
<div class="card-loader-overlay" id="overlay_revenue">
<div class="spinner-border spinner-border-sm text-primary"></div>
</div>
</div>
</div>
<div class="col-lg-3 col-sm-6 col-12">
<div class="card p-4 bg-danger bg-opacity-10 border border-danger border-opacity-25 rounded-2" style="position:relative;">
<div class="d-flex gap-3">
<div class="icon-shape icon-md bg-danger text-white rounded-2">
<i class="ti ti-trending-down fs-4"></i>
</div>
<div>
<h2 class="mb-3 fs-6">Expenses</h2>
<h3 class="fw-bold mb-0" id="stat_expense">—</h3>
<p class="text-danger mb-0 small">Posted entries</p>
</div>
</div>
<div class="card-loader-overlay" id="overlay_expense">
<div class="spinner-border spinner-border-sm text-primary"></div>
</div>
</div>
</div>
<div class="col-lg-3 col-sm-6 col-12">
<div class="card p-4 bg-success bg-opacity-10 border border-success border-opacity-25 rounded-2" style="position:relative;">
<div class="d-flex gap-3">
<div class="icon-shape icon-md bg-success text-white rounded-2">
<i class="ti ti-calculator fs-4"></i>
</div>
<div>
<h2 class="mb-3 fs-6">Net Profit</h2>
<h3 class="fw-bold mb-0" id="stat_net">—</h3>
<p class="text-success mb-0 small">Revenue minus expenses</p>
</div>
</div>
<div class="card-loader-overlay" id="overlay_net">
<div class="spinner-border spinner-border-sm text-primary"></div>
</div>
</div>
</div>
<div class="col-lg-3 col-sm-6 col-12">
<div class="card p-4 bg-warning bg-opacity-10 border border-warning border-opacity-25 rounded-2" style="position:relative;">
<div class="d-flex gap-3">
<div class="icon-shape icon-md bg-warning text-white rounded-2">
<i class="ti ti-file-description fs-4"></i>
</div>
<div>
<h2 class="mb-3 fs-6">Journal Entries</h2>
<h3 class="fw-bold mb-0" id="stat_journals">—</h3>
<p class="text-warning mb-0 small">Posted entries</p>
</div>
</div>
<div class="card-loader-overlay" id="overlay_journals">
<div class="spinner-border spinner-border-sm text-primary"></div>
</div>
</div>
</div>
</div>
<!-- Posting window banner -->
<div class="row mb-5">
<div class="col-12">
<div class="card" style="position:relative;">
<div class="card-body py-3 d-flex align-items-center gap-3 flex-wrap">
<i class="ti ti-lock text-muted"></i>
<span id="posting_window_banner" class="small text-muted">Loading...</span>
<a href="<?php echo $server_url?>accounting/posting_window.php" class="ms-auto small">Manage Posting Window</a>
</div>
<div class="card-loader-overlay" id="overlay_lock">
<div class="spinner-border spinner-border-sm text-primary"></div>
</div>
</div>
</div>
</div>
<!-- Trend chart + Source breakdown -->
<div class="row g-5 mb-5">
<div class="col-lg-8">
<div class="card h-100" style="position:relative;">
<div class="card-header bg-white border-0 pb-0 pt-4 px-4">
<h6 class="mb-0 fw-semibold">Revenue vs Expenses <small class="text-muted fw-normal">(last 6 months)</small></h6>
</div>
<div class="card-body px-4 pb-4">
<canvas id="trend_chart" height="90"></canvas>
</div>
<div class="card-loader-overlay" id="overlay_trend">
<div class="spinner-border spinner-border-sm text-primary"></div>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card h-100" style="position:relative;">
<div class="card-header bg-white border-0 pb-0 pt-4 px-4">
<h6 class="mb-0 fw-semibold">Entries by Type</h6>
</div>
<div class="card-body d-flex flex-column gap-3 px-4 pb-4" id="by_source_list">
<div class="text-center text-muted small py-4">Loading...</div>
</div>
<div class="card-loader-overlay" id="overlay_source">
<div class="spinner-border spinner-border-sm text-primary"></div>
</div>
</div>
</div>
</div>
<!-- Recent journal entries -->
<div class="row">
<div class="col-12">
<div class="card overflow-hidden" style="position:relative;">
<div class="card-body p-4 d-flex align-items-center justify-content-between">
<h6 class="mb-0 fw-semibold">Recent Journal Entries</h6>
<a href="<?php echo $server_url?>journal/index.php" class="small">View All</a>
</div>
<div class="table-responsive">
<table class="table table-hover mb-0 table-centered">
<thead class="table-primary border-light">
<tr>
<th>Date</th>
<th>Period</th>
<th>Type</th>
<th>Reference</th>
<th>Description</th>
<th class="text-end">Debit</th>
<th class="text-end">Credit</th>
</tr>
</thead>
<tbody id="recent_tbody">
<tr><td colspan="7" class="text-center text-muted py-4 small">Loading...</td></tr>
</tbody>
</table>
</div>
<div class="card-loader-overlay" id="overlay_recent">
<div class="spinner-border spinner-border-sm text-primary"></div>
</div>
</div>
</div>
</div>
</div>
</main>
<?php require '../include_ending.php'; ?>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
<style>
/* Brief yellow flash when a card updates silently via WebSocket */
@keyframes card-flash {
0% { box-shadow: 0 0 0 3px rgba(250,204,21,0.8); }
100% { box-shadow: none; }
}
.card-flash { animation: card-flash 1s ease-out; }
</style>
<script>
var trend_chart_instance = null;
var source_labels = {
invoice: 'Invoice',
credit_note: 'Credit Note',
purchase_invoice: 'Purchase Invoice',
supplier_credit_note: 'Supplier Credit Note',
receipt: 'Receipt',
payment: 'Payment',
manual: 'Manual'
};
var source_badges = {
invoice: 'bg-success-subtle text-success',
credit_note: 'bg-warning-subtle text-warning',
purchase_invoice: 'bg-primary-subtle text-primary',
supplier_credit_note: 'bg-secondary-subtle text-secondary',
receipt: 'bg-info-subtle text-info',
payment: 'bg-danger-subtle text-danger',
manual: 'bg-dark-subtle text-dark'
};
function fmt_num(n) {
return Number(n).toLocaleString(undefined, {minimumFractionDigits:2, maximumFractionDigits:2});
}
// Flash a card briefly to signal a silent update happened
function flash_card(el) {
if (!el) return;
el.classList.remove('card-flash');
void el.offsetWidth; // force reflow so animation restarts
el.classList.add('card-flash');
setTimeout(function() { el.classList.remove('card-flash'); }, 1000);
}
// ── Individual load functions ─────────────────────────────────────────────
// Each accepts a `silent` flag:
// false (default) — show overlay spinner, blank value while loading
// true — keep existing value visible, flash card on update
// load_pl(silent, flash_revenue, flash_expense)
// One DB query — but only flash cards that actually changed.
// flash_revenue / flash_expense default to true when not passed.
function load_pl(silent, flash_revenue, flash_expense) {
if (flash_revenue === undefined) flash_revenue = true;
if (flash_expense === undefined) flash_expense = true;
if (!silent) {
$('#overlay_revenue, #overlay_expense, #overlay_net').show();
['stat_revenue', 'stat_expense', 'stat_net'].forEach(function(id) {
document.getElementById(id).textContent = '—';
});
}
ajax_request({
url: server_url + 'ac_dashboard/api/engine/pl.php',
autoPrepare: true, checkRequired: 0, noLoading: true, queueLock: false, action: 'read',
onSuccess: function(res) {
var d = res.output;
document.getElementById('stat_revenue').textContent = fmt_num(d.revenue);
document.getElementById('stat_expense').textContent = fmt_num(d.expense);
$('#overlay_revenue, #overlay_expense').hide();
var net = d.net_profit;
var net_el = document.getElementById('stat_net');
net_el.textContent = fmt_num(net);
var net_card = net_el.closest('.card');
if (net_card) {
net_card.className = net >= 0
? 'card p-4 bg-success bg-opacity-10 border border-success border-opacity-25 rounded-2'
: 'card p-4 bg-danger bg-opacity-10 border border-danger border-opacity-25 rounded-2';
var icon = net_card.querySelector('.icon-shape');
if (icon) icon.className = net >= 0
? 'icon-shape icon-md bg-success text-white rounded-2'
: 'icon-shape icon-md bg-danger text-white rounded-2';
var sub = net_card.querySelector('p');
if (sub) sub.className = net >= 0 ? 'text-success mb-0 small' : 'text-danger mb-0 small';
}
$('#overlay_net').hide();
// Flash only cards that actually changed
if (silent) {
if (flash_revenue) flash_card(document.getElementById('stat_revenue').closest('.card'));
if (flash_expense) flash_card(document.getElementById('stat_expense').closest('.card'));
flash_card(net_el.closest('.card')); // net profit always updates if either changed
}
}
});
}
function load_journals(silent) {
if (!silent) {
$('#overlay_journals').show();
document.getElementById('stat_journals').textContent = '—';
}
ajax_request({
url: server_url + 'ac_dashboard/api/engine/journals.php',
autoPrepare: true, checkRequired: 0, noLoading: true, queueLock: false, action: 'read',
onSuccess: function(res) {
document.getElementById('stat_journals').textContent = res.output.journal_count;
$('#overlay_journals').hide();
if (silent) flash_card(document.getElementById('stat_journals').closest('.card'));
}
});
}
function load_posting_window() {
$('#overlay_lock').show();
ajax_request({
url: server_url + 'ac_dashboard/api/engine/posting_window.php',
autoPrepare: true, checkRequired: 0, noLoading: true, queueLock: false, action: 'read',
onSuccess: function(res) { render_posting_window(res.output); }
});
}
function load_trend(silent) {
if (!silent) $('#overlay_trend').show();
ajax_request({
url: server_url + 'ac_dashboard/api/engine/trend.php',
autoPrepare: true, checkRequired: 0, noLoading: true, queueLock: false, action: 'read',
onSuccess: function(res) { render_trend(res.output.trend, silent); }
});
}
function load_by_source(silent) {
if (!silent) {
$('#overlay_source').show();
document.getElementById('by_source_list').innerHTML =
'<div class="text-center text-muted small py-4">Loading...</div>';
}
ajax_request({
url: server_url + 'ac_dashboard/api/engine/by_source.php',
autoPrepare: true, checkRequired: 0, noLoading: true, queueLock: false, action: 'read',
onSuccess: function(res) { render_by_source(res.output.by_source, silent); }
});
}
function load_recent(silent) {
if (!silent) {
$('#overlay_recent').show();
document.getElementById('recent_tbody').innerHTML =
'<tr><td colspan="7" class="text-center text-muted py-4 small">Loading...</td></tr>';
}
ajax_request({
url: server_url + 'ac_dashboard/api/engine/recent.php',
autoPrepare: true, checkRequired: 0, noLoading: true, queueLock: false, action: 'read',
onSuccess: function(res) { render_recent(res.output.recent, silent); }
});
}
// ── Full dashboard load (first load) ─────────────────────────────────────
function load_dashboard() {
load_pl(false);
load_journals(false);
load_posting_window();
load_trend(false);
load_by_source(false);
load_recent(false);
}
// ── Render functions ──────────────────────────────────────────────────────
function render_posting_window(window_data) {
var el = document.getElementById('posting_window_banner');
var from = window_data.open_from || '';
var to = window_data.open_to || '';
if (!from && !to) {
el.innerHTML = '<span class="text-success fw-semibold">No restrictions</span> — all dates are open for posting and inventory movement.';
} else {
var f = from ? '<strong>' + escape_html(from) + '</strong>' : '<span class="text-muted">no lower limit</span>';
var t = to ? '<strong>' + escape_html(to) + '</strong>' : '<span class="text-muted">no upper limit</span>';
el.innerHTML = 'Posting open from ' + f + ' to ' + t;
}
$('#overlay_lock').hide();
}
function render_trend(trend, silent) {
var labels = [], revenues = [], expenses = [];
var idx = {};
trend.forEach(function(r){ idx[r.period] = r; });
for (var i = 5; i >= 0; i--) {
var d = new Date();
d.setDate(1);
d.setMonth(d.getMonth() - i);
var m = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0');
labels.push(m);
revenues.push(idx[m] ? parseFloat(idx[m].revenue) || 0 : 0);
expenses.push(idx[m] ? parseFloat(idx[m].expense) || 0 : 0);
}
if (trend_chart_instance) trend_chart_instance.destroy();
var ctx = document.getElementById('trend_chart').getContext('2d');
trend_chart_instance = new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets: [
{ label: 'Revenue', data: revenues, backgroundColor: 'rgba(2,132,199,0.75)', borderRadius: 4 },
{ label: 'Expenses', data: expenses, backgroundColor: 'rgba(239,68,68,0.65)', borderRadius: 4 },
]
},
options: {
responsive: true,
plugins: { legend: { position: 'top' } },
scales: {
y: { beginAtZero: true, ticks: { callback: function(v){ return fmt_num(v); } } }
}
}
});
$('#overlay_trend').hide();
if (silent) flash_card(document.getElementById('trend_chart').closest('.card'));
}
function render_by_source(rows, silent) {
var el = document.getElementById('by_source_list');
if (!rows || rows.length === 0) {
el.innerHTML = '<div class="text-center text-muted small py-4">No entries found.</div>';
$('#overlay_source').hide();
return;
}
var total = rows.reduce(function(s, r){ return s + parseInt(r.cnt); }, 0);
var html = '';
rows.forEach(function(r) {
var pct = total > 0 ? Math.round(r.cnt / total * 100) : 0;
var label = source_labels[r.source_type] || r.source_type;
html += '<div>' +
'<div class="d-flex justify-content-between small mb-1">' +
'<span>' + escape_html(label) + '</span>' +
'<span class="text-muted">' + r.cnt + ' <span class="text-muted">(' + pct + '%)</span></span>' +
'</div>' +
'<div class="progress" style="height:6px;">' +
'<div class="progress-bar" style="width:' + pct + '%;background:#0284c7;"></div>' +
'</div>' +
'</div>';
});
el.innerHTML = html;
$('#overlay_source').hide();
if (silent) flash_card(el.closest('.card'));
}
function render_recent(rows, silent) {
var tbody = document.getElementById('recent_tbody');
if (!rows || rows.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-muted py-4 small">No journal entries found.</td></tr>';
$('#overlay_recent').hide();
return;
}
var html = '';
rows.forEach(function(r) {
var badge_cls = source_badges[r.source_type] || 'bg-light text-dark';
var label = source_labels[r.source_type] || r.source_type;
var jdate = r.journal_date || '—';
html += '<tr>' +
'<td class="small">' + escape_html(jdate) + '</td>' +
'<td class="small">' + escape_html(r.period) + '</td>' +
'<td><span class="badge ' + badge_cls + '">' + escape_html(label) + '</span></td>' +
'<td class="small">' + escape_html(r.reference || '—') + '</td>' +
'<td class="small text-muted">' + escape_html(r.description || '—') + '</td>' +
'<td class="small text-end">' + fmt_num(r.total_debit) + '</td>' +
'<td class="small text-end">' + fmt_num(r.total_credit) + '</td>' +
'</tr>';
});
tbody.innerHTML = html;
$('#overlay_recent').hide();
if (silent) flash_card(tbody.closest('.card'));
}
// ── Initial load ──────────────────────────────────────────────────────────
load_dashboard();
// ── Real-time: targeted silent updates per event payload ──────────────────
if (window._socket) {
window._socket.on('gl_posted', function(data) {
console.log('[dashboard] gl_posted', data);
if (data.has_revenue || data.has_expense) {
load_pl(true, data.has_revenue, data.has_expense); // only affected side flashes
load_trend(true); // trend = revenue vs expense chart, only relevant when those change
}
// Always update regardless of which accounts were hit
load_journals(true);
load_by_source(true);
load_recent(true);
});
}
</script>
</body>
</html>