- sign-in pages load only what they use (include_login_header.php, ajax_core.js) - every library, font and data file served from assets/vendor with pinned versions (VERSIONS.json); no CDN at runtime - Content-Security-Policy on app, sign-in and landing pages - remove httpbin Dropzone target and source-map references
484 lines
20 KiB
PHP
484 lines
20 KiB
PHP
<?php
|
|
require_once __DIR__ . '/../session.php';
|
|
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="<?php echo $server_url?>assets/vendor/chart.js/4.5.1/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>
|