313 lines
12 KiB
PHP
313 lines
12 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.php';?>
|
|
|
|
|
|
<!-- MAIN CONTENT -->
|
|
<main id="content" class="content py-15">
|
|
<div class="container-fluid">
|
|
|
|
<!-- Page header -->
|
|
<div class="row">
|
|
<div class="col-12">
|
|
<div class="mb-6 d-flex justify-content-between align-items-center">
|
|
<div>
|
|
<h1 class="fs-3 mb-1">Low Stock Products</h1>
|
|
<p class="mb-0 text-muted">Products whose current balance has fallen below minimum stock level</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Summary cards -->
|
|
<div class="row mb-5 g-5">
|
|
<div class="col-lg-4 col-12">
|
|
<div class="card border border-warning border-opacity-25">
|
|
<div class="card-body p-5">
|
|
<div class="d-flex justify-content-between align-items-center">
|
|
<span>Total Low Stock</span>
|
|
<span><i class="ti ti-alert-triangle fs-3 text-warning"></i></span>
|
|
</div>
|
|
<div class="d-flex flex-column mt-3">
|
|
<span class="fs-3 fw-bold mb-0 d-block text-warning" id="stat_total">—</span>
|
|
<small class="text-muted">Products need attention</small>
|
|
</div>
|
|
<div class="card-loader-overlay" id="loader_total">
|
|
<div class="spinner-border spinner-border-sm text-primary"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-lg-4 col-12">
|
|
<div class="card border border-danger border-opacity-25">
|
|
<div class="card-body p-5">
|
|
<div class="d-flex justify-content-between align-items-center">
|
|
<span>Critical Level</span>
|
|
<span><i class="ti ti-activity-heartbeat fs-3 text-danger"></i></span>
|
|
</div>
|
|
<div class="d-flex flex-column mt-3">
|
|
<span class="fs-3 fw-bold mb-0 d-block text-danger" id="stat_critical">—</span>
|
|
<small class="text-muted">Immediate restock needed</small>
|
|
</div>
|
|
<div class="card-loader-overlay" id="loader_critical">
|
|
<div class="spinner-border spinner-border-sm text-primary"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-lg-4 col-12">
|
|
<div class="card border border-secondary border-opacity-25">
|
|
<div class="card-body p-5">
|
|
<div class="d-flex justify-content-between align-items-center">
|
|
<span>Warning Level</span>
|
|
<span><i class="ti ti-box-seam fs-3 text-secondary"></i></span>
|
|
</div>
|
|
<div class="d-flex flex-column mt-3">
|
|
<span class="fs-3 fw-bold mb-0 d-block text-secondary" id="stat_warning">—</span>
|
|
<small class="text-muted">Restock soon</small>
|
|
</div>
|
|
<div class="card-loader-overlay" id="loader_warning">
|
|
<div class="spinner-border spinner-border-sm text-primary"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Filter bar -->
|
|
<div class="row mb-4 g-3 align-items-end">
|
|
<div class="col-md-4 col-12">
|
|
<label class="form-label mb-1">Filter by Status</label>
|
|
<select id="filter_status" class="form-select">
|
|
<option value="">All</option>
|
|
<option value="critical">Critical</option>
|
|
<option value="warning">Warning</option>
|
|
</select>
|
|
</div>
|
|
<div class="col-md-4 col-12">
|
|
<label class="form-label mb-1">Filter by Warehouse</label>
|
|
<select id="filter_warehouse" class="form-select">
|
|
<option value="">All Warehouses</option>
|
|
</select>
|
|
</div>
|
|
<div class="col-md-4 col-12">
|
|
<label class="form-label mb-1">Search Product</label>
|
|
<input type="text" id="filter_search" class="form-control" placeholder="Name or SKU…">
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Table -->
|
|
<div class="row g-5">
|
|
<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-5 mb-0">Low Stock Items</h2>
|
|
<small class="text-muted" id="result_count"></small>
|
|
</div>
|
|
<div class="table-responsive">
|
|
<table class="table table-hover mb-0 table-centered" id="low_stock">
|
|
<thead class="table-light">
|
|
<tr>
|
|
<th>Product</th>
|
|
<th>SKU</th>
|
|
<th>Warehouse</th>
|
|
<th>Balance</th>
|
|
<th>Min Stock</th>
|
|
<th>Reorder Point</th>
|
|
<th>Status</th>
|
|
<th>Action</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td colspan="8" class="text-center py-5 text-muted">
|
|
<div class="spinner-border spinner-border-sm me-2"></div>Loading…
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
<tfoot></tfoot>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</main>
|
|
|
|
<?php require "../include_ending.php";?>
|
|
|
|
<script>
|
|
// ── In-memory dataset ────────────────────────────────────────────────
|
|
var all_items = [];
|
|
|
|
|
|
// ── Fetch low-stock data from the report engine ──────────────────────
|
|
function retrieve_low_stock() {
|
|
|
|
return ajax_request({
|
|
url: "<?php echo $server_url?>dashboard/api/engine_report/low_stock.php",
|
|
autoPrepare: true,
|
|
checkRequired: 0,
|
|
action: 'read',
|
|
onSuccess: function(res) {
|
|
var out = res.output || {};
|
|
all_items = out.items || [];
|
|
|
|
// Summary cards
|
|
$('#stat_total').text(out.total_low ?? 0);
|
|
$('#stat_critical').text(out.total_critical ?? 0);
|
|
$('#stat_warning').text(out.total_warning ?? 0);
|
|
$('#loader_total, #loader_critical, #loader_warning').hide();
|
|
|
|
// Warehouse filter options: every active warehouse, not only those with low items
|
|
var wh_options = '<option value="">All Warehouses</option>';
|
|
$.each(out.warehouses || [], function(i, wh) {
|
|
wh_options += `<option value="${wh.id}">${wh.warehouse_name}</option>`;
|
|
});
|
|
$('#filter_warehouse').html(wh_options);
|
|
|
|
// Register dataset in alasql for paginated rendering
|
|
alasql('CREATE TABLE IF NOT EXISTS low_stock');
|
|
alasql.tables.low_stock.data = all_items;
|
|
|
|
change_page_low_stock(1);
|
|
}
|
|
});
|
|
|
|
}
|
|
|
|
|
|
// ── Apply filters, then paginate ─────────────────────────────────────
|
|
function change_page_low_stock(page_num) {
|
|
|
|
var offset = (page_num - 1) * prop_limit;
|
|
|
|
var status = $('#filter_status').val().trim().toLowerCase();
|
|
var warehouse = $('#filter_warehouse').val();
|
|
var search = $('#filter_search').val().trim().toLowerCase();
|
|
|
|
// Filter first — we need the full filtered set for correct pagination
|
|
var filtered = all_items.filter(function(item) {
|
|
if (status && item.status !== status) return false;
|
|
if (warehouse && String(item.warehouse_id) !== String(warehouse)) return false;
|
|
if (search) {
|
|
var hay = (item.product_name + ' ' + item.product_sku).toLowerCase();
|
|
if (hay.indexOf(search) === -1) return false;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
// Pagination footer reflects filtered count
|
|
var page = generate_pagination('low_stock', filtered.length);
|
|
$(`table#low_stock tfoot`).html(page);
|
|
|
|
$('#result_count').text(filtered.length + ' item(s)');
|
|
|
|
if (filtered.length === 0) {
|
|
$('table#low_stock > tbody').html(
|
|
`<tr><td colspan="8" class="text-center py-5 text-muted">
|
|
<i class="ti ti-mood-smile fs-3 d-block mb-2"></i>
|
|
No low-stock products found.
|
|
</td></tr>`
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Render only the current page slice
|
|
var page_data = filtered.slice(offset, offset + prop_limit);
|
|
|
|
var body = '';
|
|
$.each(page_data, function(i, item) {
|
|
|
|
var pct = item.reorder_point > 0 ? Math.round((item.balance / item.reorder_point) * 100) : 0;
|
|
var bar_cls = item.status === 'critical' ? 'bg-danger' : 'bg-warning';
|
|
|
|
var badge = item.status === 'critical' ?
|
|
'<span class="badge bg-danger rounded-pill">Critical</span>' :
|
|
'<span class="badge bg-secondary bg-opacity-10 text-dark rounded-pill">Warning</span>';
|
|
|
|
var balance_cls = item.status === 'critical' ? 'text-danger fw-semibold' : 'text-warning fw-semibold';
|
|
|
|
body += `<tr>
|
|
<td class="py-3">
|
|
<div class="d-flex flex-column">
|
|
<span>${item.product_name}</span>
|
|
<div class="progress mt-1" style="height:4px;width:80px;" title="${pct}% of reorder point">
|
|
<div class="progress-bar ${bar_cls}" style="width:${Math.min(pct,100)}%"></div>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td class="py-3 text-secondary">${item.product_sku}</td>
|
|
<td class="py-3">${item.warehouse_name}</td>
|
|
<td class="py-3 ${balance_cls}">
|
|
${item.balance} <span class="text-muted fw-normal small">${item.uom || 'pcs'}</span>
|
|
${item.cost_price > 0
|
|
? `<div class="text-muted small fw-normal">Value: ${format_number(item.balance * item.cost_price, 2)}</div>`
|
|
: ''}
|
|
</td>
|
|
<td class="py-3">${item.min_stock}</td>
|
|
<td class="py-3">${item.reorder_point}</td>
|
|
<td class="py-3">${badge}</td>
|
|
<td class="py-3">
|
|
<a href="<?php echo $server_url?>ics/manage_stock_in.php?sku=${encodeURIComponent(item.product_sku)}&name=${encodeURIComponent(item.product_name)}&qty=${Math.max(0, item.reorder_point - item.balance)}&wh=${item.warehouse_id}"
|
|
class="btn btn-sm btn-outline-primary">
|
|
<i class="ti ti-plus me-1"></i>Restock
|
|
</a>
|
|
</td>
|
|
</tr>`;
|
|
});
|
|
|
|
$('table#low_stock > tbody').html(body);
|
|
}
|
|
|
|
|
|
// ── Filter listeners ─────────────────────────────────────────────────
|
|
function debounce(fn, ms) {
|
|
var t;
|
|
return function() {
|
|
var args = arguments,
|
|
ctx = this;
|
|
clearTimeout(t);
|
|
t = setTimeout(function() {
|
|
fn.apply(ctx, args);
|
|
}, ms);
|
|
};
|
|
}
|
|
|
|
$('#filter_status, #filter_warehouse').on('change', function() {
|
|
change_page_low_stock(1); // reset to page 1 on filter change
|
|
});
|
|
|
|
$('#filter_search').on('input', debounce(function() {
|
|
change_page_low_stock(1);
|
|
}, 200));
|
|
|
|
|
|
// ── Boot ─────────────────────────────────────────────────────────────
|
|
$(async function() {
|
|
try {
|
|
await retrieve_low_stock();
|
|
} catch (e) {
|
|
console.error(e);
|
|
$('table#low_stock > tbody').html(
|
|
'<tr><td colspan="8" class="text-center py-5 text-danger">Failed to load data.</td></tr>'
|
|
);
|
|
}
|
|
});
|
|
</script>
|
|
|
|
</body>
|
|
|
|
</html>
|