489 lines
19 KiB
PHP
489 lines
19 KiB
PHP
<?php
|
|
session_start();
|
|
require '../config.php';
|
|
require '../include_header.php';
|
|
?>
|
|
|
|
<body>
|
|
<?php require '../include_topbar.php';?>
|
|
<?php require '../include_sidebar.php';?>
|
|
|
|
<main id="content" class="content py-15">
|
|
<div class="container-fluid">
|
|
|
|
<!-- Page header -->
|
|
<div class="row mb-5">
|
|
<div class="col-12">
|
|
<div class="d-flex flex-wrap justify-content-between align-items-center gap-3">
|
|
<div>
|
|
<h1 class="fs-4 mb-0">Stock Movement</h1>
|
|
</div>
|
|
<div class="d-flex align-items-center gap-2 flex-nowrap">
|
|
<select id="warehouse" class="form-select form-select-sm" style="min-width:140px;max-width:180px;">
|
|
<option value="">Warehouse...</option>
|
|
</select>
|
|
<input type="month" id="from_month" class="form-control form-control-sm" style="width:150px;">
|
|
<span class="text-muted small text-nowrap">to</span>
|
|
<input type="month" id="to_month" class="form-control form-control-sm" style="width:150px;">
|
|
<button class="btn btn-primary btn-sm text-nowrap" onclick="do_search();">
|
|
<i class="ti ti-search"></i> Search
|
|
</button>
|
|
<div class="dropdown">
|
|
<a href="#" role="button" class="btn btn-outline-secondary btn-sm text-nowrap" data-bs-toggle="dropdown" aria-expanded="false">
|
|
<i class="ti ti-download me-1"></i> Export
|
|
</a>
|
|
<div class="dropdown-menu dropdown-menu-end p-0" style="min-width: 160px;">
|
|
<div>
|
|
<div class="d-flex gap-3 align-items-center border-dashed border-bottom px-3 py-3">
|
|
<i class="ti ti-download"></i>
|
|
<div>
|
|
<h4 class="mb-0 small">Export</h4>
|
|
</div>
|
|
</div>
|
|
<div class="p-3 d-flex flex-column gap-1 small lh-lg">
|
|
<a href="javascript:;" onclick="export_report('csv')">
|
|
<span><i class="ti ti-file-type-csv me-2"></i>CSV</span>
|
|
</a>
|
|
<a href="javascript:;" onclick="export_report('xlsx')">
|
|
<span><i class="ti ti-file-type-xls me-2"></i>Excel</span>
|
|
</a>
|
|
<a href="javascript:;" onclick="export_report('pdf')">
|
|
<span><i class="ti ti-file-type-pdf me-2"></i>PDF</span>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Summary stats -->
|
|
<div class="row mb-4 g-3">
|
|
<div class="col-xl-3 col-md-6 col-12">
|
|
<div class="card">
|
|
<div class="card-body p-4 d-flex align-items-center gap-3">
|
|
<span class="icon-shape icon-sm bg-primary text-white rounded-2">
|
|
<i class="ti ti-list fs-5"></i>
|
|
</span>
|
|
<div>
|
|
<div class="fs-3 fw-bold mb-0" id="stat_transactions">—</div>
|
|
<div class="text-muted small">Total Transactions</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-xl-3 col-md-6 col-12">
|
|
<div class="card">
|
|
<div class="card-body p-4 d-flex align-items-center gap-3">
|
|
<span class="icon-shape icon-sm bg-success text-white rounded-2">
|
|
<i class="ti ti-package-import fs-5"></i>
|
|
</span>
|
|
<div>
|
|
<div class="fs-3 fw-bold mb-0 text-success" id="stat_in">—</div>
|
|
<div class="text-muted small">Total Stock In</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-xl-3 col-md-6 col-12">
|
|
<div class="card">
|
|
<div class="card-body p-4 d-flex align-items-center gap-3">
|
|
<span class="icon-shape icon-sm bg-danger text-white rounded-2">
|
|
<i class="ti ti-package-export fs-5"></i>
|
|
</span>
|
|
<div>
|
|
<div class="fs-3 fw-bold mb-0 text-danger" id="stat_out">—</div>
|
|
<div class="text-muted small">Total Stock Out</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-xl-3 col-md-6 col-12">
|
|
<div class="card">
|
|
<div class="card-body p-4 d-flex align-items-center gap-3">
|
|
<span class="icon-shape icon-sm bg-warning text-white rounded-2">
|
|
<i class="ti ti-arrows-diff fs-5"></i>
|
|
</span>
|
|
<div>
|
|
<div class="fs-3 fw-bold mb-0" id="stat_net">—</div>
|
|
<div class="text-muted small">Net Movement</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Table -->
|
|
<div class="row">
|
|
<div class="col-12">
|
|
<div class="card">
|
|
<div class="card-body p-5">
|
|
|
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
|
<h2 class="fs-4 mb-0">Movement Detail</h2>
|
|
<span id="result_summary" class="text-muted small"></span>
|
|
</div>
|
|
|
|
<div class="table-responsive">
|
|
<table class="table mb-0 text-nowrap table-hover table-centered table-sm" id="tbl_movement">
|
|
<thead class="table-light border-light">
|
|
<tr>
|
|
<th>Date</th>
|
|
<th>Type</th>
|
|
<th>SKU</th>
|
|
<th>Lot</th>
|
|
<th>Location</th>
|
|
<th>Contact</th>
|
|
<th class="text-end">Stock In</th>
|
|
<th class="text-end">Stock Out</th>
|
|
<th class="text-end">Running Total</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="tbl_body">
|
|
<tr>
|
|
<td colspan="9" class="text-center py-5 text-muted">
|
|
Select a warehouse and date range, then click Search.
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Lazy load sentinel + loader -->
|
|
<div id="lazy_sentinel" class="py-3 text-center text-muted small" style="display:none;">
|
|
<div id="lazy_loader"><i class="ti ti-loader-2 me-1"></i>Loading...</div>
|
|
<div id="lazy_done" style="display:none;">All products loaded.</div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</main>
|
|
|
|
<?php require "../include_ending.php";?>
|
|
|
|
<script>
|
|
// ── State ─────────────────────────────────────────────────────────────
|
|
var sku_queue = []; // ordered list of SKUs returned by summary call
|
|
var sku_index = 0; // next SKU to load
|
|
var is_loading = false;
|
|
var search_params = {}; // warehouse_id, from_month, to_month — fixed per search
|
|
|
|
var type_badge = {
|
|
'in': '<span class="badge bg-success">In</span>',
|
|
'out': '<span class="badge bg-danger">Out</span>',
|
|
'transfer': '<span class="badge bg-info text-dark">Transfer</span>',
|
|
};
|
|
|
|
// ── Default months ────────────────────────────────────────────────────
|
|
(function() {
|
|
var now = new Date();
|
|
var month = now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2, '0');
|
|
$('#from_month').val(month);
|
|
$('#to_month').val(month);
|
|
})();
|
|
|
|
|
|
// ── Warehouse dropdown ────────────────────────────────────────────────
|
|
function retrieve_warehouse() {
|
|
return ajax_request({
|
|
url: "<?php echo $server_url?>ics/api/engine/retrieve_warehouse.php",
|
|
autoPrepare: true, checkRequired: 0, action: 'read',
|
|
onSuccess: function(res) {
|
|
var options = '';
|
|
$.each(res.output, function(i, item) {
|
|
options += `<option value="${item.id}">${item.warehouse_name}</option>`;
|
|
});
|
|
$('#warehouse').html(options);
|
|
if (res.output && res.output.length > 0) {
|
|
$('#warehouse').val(res.output[0].id);
|
|
do_search();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
|
|
// ── Search — fetch summary, reset state, start lazy load ─────────────
|
|
function do_search() {
|
|
var warehouse_id = $('#warehouse').val();
|
|
var from_month = $('#from_month').val();
|
|
var to_month = $('#to_month').val();
|
|
if (!warehouse_id || !from_month || !to_month) return;
|
|
|
|
// Reset state
|
|
sku_queue = [];
|
|
sku_index = 0;
|
|
is_loading = false;
|
|
export_data = [];
|
|
search_params = { warehouse_id: warehouse_id, from_month: from_month, to_month: to_month };
|
|
|
|
// Reset UI
|
|
$('#tbl_body').html('<tr><td colspan="9" class="text-center py-4 text-muted">Loading…</td></tr>');
|
|
$('#lazy_sentinel').hide();
|
|
$('#lazy_done').hide();
|
|
$('#lazy_loader').show();
|
|
$('#result_summary').text('');
|
|
|
|
// Reset stats
|
|
$('#stat_transactions, #stat_in, #stat_out, #stat_net').text('—');
|
|
|
|
return ajax_request({
|
|
url: "<?php echo $server_url?>reports/api/engine_report/stock_movement.php",
|
|
autoPrepare: true, checkRequired: 0, action: 'read',
|
|
data: { warehouse_id: warehouse_id, from_month: from_month, to_month: to_month },
|
|
onSuccess: function(res) {
|
|
var d = res.output;
|
|
|
|
// Populate stats
|
|
$('#stat_transactions').text(d.total_transactions);
|
|
$('#stat_in').text(fmt(d.total_in));
|
|
$('#stat_out').text(fmt(d.total_out));
|
|
var net = d.net;
|
|
$('#stat_net')
|
|
.text((net >= 0 ? '+' : '') + fmt(net))
|
|
.removeClass('text-success text-danger')
|
|
.addClass(net >= 0 ? 'text-success' : 'text-danger');
|
|
|
|
if (!d.skus || d.skus.length === 0) {
|
|
$('#tbl_body').html('<tr><td colspan="9" class="text-center py-5 text-muted">No movement data found.</td></tr>');
|
|
return;
|
|
}
|
|
|
|
sku_queue = d.skus;
|
|
sku_index = 0;
|
|
$('#tbl_body').html('');
|
|
$('#result_summary').text(d.skus.length + ' product' + (d.skus.length > 1 ? 's' : ''));
|
|
$('#lazy_sentinel').show();
|
|
|
|
// Start loading — sentinel_is_visible() will keep pulling until window is filled
|
|
load_next_sku();
|
|
}
|
|
});
|
|
}
|
|
|
|
|
|
// ── Load one SKU group, then keep going if sentinel still visible ─────
|
|
function load_next_sku() {
|
|
if (is_loading || sku_index >= sku_queue.length) {
|
|
if (sku_index >= sku_queue.length) {
|
|
$('#lazy_loader').hide();
|
|
$('#lazy_done').show();
|
|
}
|
|
return;
|
|
}
|
|
|
|
is_loading = true;
|
|
var sku = sku_queue[sku_index];
|
|
|
|
return ajax_request({
|
|
url: "<?php echo $server_url?>reports/api/engine_report/stock_movement_sku.php",
|
|
autoPrepare: true, checkRequired: 0, action: 'read',
|
|
queueLock: false,
|
|
data: {
|
|
warehouse_id : search_params.warehouse_id,
|
|
product_sku : sku,
|
|
from_month : search_params.from_month,
|
|
to_month : search_params.to_month,
|
|
},
|
|
onSuccess: function(res) {
|
|
append_sku_group(res.output);
|
|
sku_index++;
|
|
is_loading = false;
|
|
|
|
if (sku_index >= sku_queue.length) {
|
|
$('#lazy_loader').hide();
|
|
$('#lazy_done').show();
|
|
return;
|
|
}
|
|
|
|
// If sentinel is still within the viewport (or above it),
|
|
// the table hasn't filled the window yet — keep loading immediately.
|
|
if (sentinel_is_visible()) {
|
|
load_next_sku();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
|
|
// ── Returns true if the sentinel is visible in the current viewport ───
|
|
function sentinel_is_visible() {
|
|
var el = document.getElementById('lazy_sentinel');
|
|
var rect = el.getBoundingClientRect();
|
|
return rect.top <= (window.innerHeight || document.documentElement.clientHeight);
|
|
}
|
|
|
|
|
|
// ── Render one SKU group into the table ───────────────────────────────
|
|
function append_sku_group(d) {
|
|
var bf = d.brought_forward;
|
|
var rows = d.rows;
|
|
|
|
// Populate export_data — brought forward row first, then transactions
|
|
export_data.push({
|
|
sku: d.sku, product: d.product_name, date: 'Brought Forward', type: '',
|
|
lot: '', location: '', contact: '',
|
|
stock_in: bf.in > 0 ? bf.in : '', stock_out: bf.out > 0 ? bf.out : '',
|
|
running_total: bf.balance,
|
|
});
|
|
$.each(rows, function(i, row) {
|
|
export_data.push({
|
|
sku: row.product_sku,
|
|
product: d.product_name,
|
|
date: row.date,
|
|
type: row.type,
|
|
lot: row.lot_number || '',
|
|
location: row.zone + '-' + row.aisle + '-' + row.rack,
|
|
contact: row.contact_name || '',
|
|
stock_in: row.stock_in > 0 ? row.stock_in : '',
|
|
stock_out: row.stock_out > 0 ? row.stock_out : '',
|
|
running_total: row.running_total,
|
|
});
|
|
});
|
|
|
|
// SKU header row
|
|
var header = `<tr class="table-secondary">
|
|
<td colspan="9" class="py-2 fw-semibold">
|
|
<code>${d.sku}</code>
|
|
<span class="text-muted fw-normal ms-2">${d.product_name}</span>
|
|
</td>
|
|
</tr>`;
|
|
|
|
// Brought-forward row
|
|
var bf_in_val = bf.in > 0 ? fmt(bf.in) : '<span class="text-muted">—</span>';
|
|
var bf_out_val = bf.out > 0 ? fmt(bf.out) : '<span class="text-muted">—</span>';
|
|
var bf_bal_cls = bf.balance >= 0 ? 'text-success' : 'text-danger';
|
|
var bf_row = `<tr class="table-light text-muted fst-italic">
|
|
<td class="py-2" colspan="6">Brought Forward</td>
|
|
<td class="py-2 text-end text-success">${bf_in_val}</td>
|
|
<td class="py-2 text-end text-danger">${bf_out_val}</td>
|
|
<td class="py-2 text-end fw-semibold ${bf_bal_cls}">${fmt(bf.balance)}</td>
|
|
</tr>`;
|
|
|
|
// Transaction rows
|
|
var body = '';
|
|
if (rows.length === 0) {
|
|
body = `<tr>
|
|
<td colspan="9" class="text-center py-2 text-muted fst-italic">No transactions in range.</td>
|
|
</tr>`;
|
|
} else {
|
|
$.each(rows, function(i, row) {
|
|
var in_val = row.stock_in > 0 ? fmt(row.stock_in) : '<span class="text-muted">—</span>';
|
|
var out_val = row.stock_out > 0 ? fmt(row.stock_out) : '<span class="text-muted">—</span>';
|
|
var rt_cls = row.running_total >= 0 ? 'text-success' : 'text-danger';
|
|
body += `<tr>
|
|
<td class="py-2">${row.date}</td>
|
|
<td class="py-2">${type_badge[row.type] || row.type}</td>
|
|
<td class="py-2"><code>${row.product_sku}</code></td>
|
|
<td class="py-2">${row.lot_number || '<span class="text-muted">—</span>'}</td>
|
|
<td class="py-2">${row.zone}-${row.aisle}-${row.rack}</td>
|
|
<td class="py-2">${row.contact_name || '<span class="text-muted">—</span>'}</td>
|
|
<td class="py-2 text-end text-success">${in_val}</td>
|
|
<td class="py-2 text-end text-danger">${out_val}</td>
|
|
<td class="py-2 text-end fw-semibold ${rt_cls}">${fmt(row.running_total)}</td>
|
|
</tr>`;
|
|
});
|
|
}
|
|
|
|
$('#tbl_body').append(header + bf_row + body);
|
|
}
|
|
|
|
|
|
// ── Intersection Observer for lazy load ───────────────────────────────
|
|
var observer = new IntersectionObserver(function(entries) {
|
|
if (entries[0].isIntersecting && !is_loading && sku_index < sku_queue.length) {
|
|
load_next_sku();
|
|
}
|
|
}, { rootMargin: '200px' });
|
|
|
|
observer.observe(document.getElementById('lazy_sentinel'));
|
|
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────
|
|
function fmt(n) {
|
|
return parseFloat(n || 0).toLocaleString(undefined, {
|
|
minimumFractionDigits: 2, maximumFractionDigits: 2
|
|
});
|
|
}
|
|
|
|
|
|
// ── Export ───────────────────────────────────────────────────────────
|
|
// export_data holds all rows accumulated across all loaded SKU groups.
|
|
// It is populated by append_sku_group() alongside the table render.
|
|
var export_data = [];
|
|
|
|
function export_report(format) {
|
|
|
|
if (export_data.length === 0) {
|
|
alert('No data to export. Please search first.');
|
|
return;
|
|
}
|
|
|
|
var warehouse = $('#warehouse option:selected').text();
|
|
var from = $('#from_month').val();
|
|
var to = $('#to_month').val();
|
|
var filename = 'stock_movement_' + warehouse + '_' + from + '_' + to;
|
|
|
|
if (format === 'csv') {
|
|
alasql('SELECT * INTO CSV("' + filename + '.csv", {headers:true}) FROM ?', [export_data]);
|
|
|
|
} else if (format === 'xlsx') {
|
|
alasql('SELECT * INTO XLSX("' + filename + '.xlsx", {headers:true}) FROM ?', [export_data]);
|
|
|
|
} else if (format === 'pdf') {
|
|
var { jsPDF } = window.jspdf;
|
|
var doc = new jsPDF({ orientation: 'landscape' });
|
|
|
|
doc.setFontSize(12);
|
|
doc.text('Stock Movement — ' + warehouse + ' (' + from + ' to ' + to + ')', 14, 15);
|
|
|
|
var headers = [['SKU', 'Product', 'Date', 'Type', 'Lot', 'Location', 'Contact', 'Stock In', 'Stock Out', 'Running Total']];
|
|
var rows = export_data.map(function(r) {
|
|
return [
|
|
r.sku || '',
|
|
r.product || '',
|
|
r.date || '',
|
|
r.type || '',
|
|
r.lot || '',
|
|
r.location || '',
|
|
r.contact || '',
|
|
r.stock_in || '',
|
|
r.stock_out || '',
|
|
r.running_total || '',
|
|
];
|
|
});
|
|
|
|
doc.autoTable({
|
|
head: headers,
|
|
body: rows,
|
|
startY: 22,
|
|
styles: { fontSize: 7 },
|
|
headStyles: { fillColor: [41, 128, 185] },
|
|
});
|
|
|
|
doc.save(filename + '.pdf');
|
|
}
|
|
}
|
|
|
|
|
|
|
|
$(async function() {
|
|
try { await retrieve_warehouse(); } catch(e) { console.log(e); }
|
|
});
|
|
</script>
|
|
|
|
<!-- SheetJS — required by AlaSQL for Excel (.xlsx) export -->
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script>
|
|
|
|
<!-- jsPDF + autoTable — for PDF export on stock movement report -->
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf-autotable/3.8.2/jspdf.plugin.autotable.min.js"></script>
|
|
|
|
</body>
|
|
</html>
|