Lot, Serial, ExpiryDate
This commit is contained in:
+122
-11
@@ -202,12 +202,12 @@ $(function () {
|
||||
|
||||
})
|
||||
|
||||
// Allow HTML rendering in dropdown
|
||||
.data("ui-autocomplete")._renderItem = function (ul, item) {
|
||||
return $("<li>")
|
||||
.append($("<div>").html(item.label))
|
||||
.appendTo(ul);
|
||||
};
|
||||
// Allow HTML rendering in dropdown
|
||||
.data("ui-autocomplete")._renderItem = function (ul, item) {
|
||||
return $("<li>")
|
||||
.append($("<div>").html(item.label))
|
||||
.appendTo(ul);
|
||||
};
|
||||
|
||||
// Add this ↓
|
||||
$el.on('blur', function () {
|
||||
@@ -220,6 +220,117 @@ $(function () {
|
||||
|
||||
});
|
||||
|
||||
|
||||
/** =========================
|
||||
* LOT SEARCH AUTOCOMPLETE
|
||||
* ========================= */
|
||||
$('input.lot_search').each(function (i, el) {
|
||||
|
||||
var $el = $(el);
|
||||
$el.autocomplete({
|
||||
|
||||
source: function (request, response) {
|
||||
|
||||
if ($el.data('xhr')) {
|
||||
$el.data('xhr').abort();
|
||||
}
|
||||
|
||||
let q = prepare_form_data(0, true);
|
||||
q['keyword'] = request.term;
|
||||
q['product_sku'] = $('input#product_sku').attr('secondary') || $('input#product_sku').val();
|
||||
let json = JSON.stringify(q);
|
||||
|
||||
var xhr = $.ajax({
|
||||
url: server_url + 'ics/api/engine/retrieve_lot.php',
|
||||
type: 'POST',
|
||||
dataType: 'json',
|
||||
data: { json: json },
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content')
|
||||
},
|
||||
success: function (res) {
|
||||
|
||||
var aca = [];
|
||||
|
||||
$.each(res.result, function (i, item) {
|
||||
var temp = "";
|
||||
temp += "<div class='lot-result-item' style='font-family:\"Poppins\",sans-serif;padding:4px 0;'>";
|
||||
temp += "<div style='font-size:10px;text-transform:uppercase;color:#E66239;font-weight:600;line-height:1.2;'>";
|
||||
temp += "Lot: " + item.lot_number;
|
||||
temp += "</div>";
|
||||
temp += "<div style='font-size:13px;font-weight:500;color:#171717;margin-top:2px;'>";
|
||||
temp += "Expiry: " + (item.expiry_date || 'N/A');
|
||||
temp += "</div>";
|
||||
temp += "</div>";
|
||||
|
||||
aca.push({
|
||||
label: temp,
|
||||
value: item.lot_number,
|
||||
data: item
|
||||
});
|
||||
});
|
||||
|
||||
response(aca);
|
||||
}
|
||||
});
|
||||
|
||||
$el.data('xhr', xhr);
|
||||
},
|
||||
|
||||
minLength: 0,
|
||||
delay: 300,
|
||||
|
||||
select: function (e, ui) {
|
||||
if (ui.item && ui.item.data) {
|
||||
|
||||
var lot = ui.item.data;
|
||||
|
||||
// Set lot_number value
|
||||
$(this)
|
||||
.val(lot.lot_number)
|
||||
.attr('secondary', lot.lot_number)
|
||||
.attr('data-existing', '1'); // flag: this is an existing lot
|
||||
|
||||
// Populate + lock expiry_date — existing lot, not editable
|
||||
if (typeof expiryPicker !== 'undefined' && expiryPicker) {
|
||||
expiryPicker.setDate(lot.expiry_date || '', true, 'Y-m-d');
|
||||
if (expiryPicker.altInput) { expiryPicker.altInput.disabled = true; }
|
||||
}
|
||||
$('#expiry_date').prop('disabled', true)
|
||||
.attr('title', 'Expiry date is locked for existing lots');
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
.data("ui-autocomplete")._renderItem = function (ul, item) {
|
||||
return $("<li>")
|
||||
.append($("<div>").html(item.label))
|
||||
.appendTo(ul);
|
||||
};
|
||||
|
||||
// On blur — restore value if user didn't pick from dropdown
|
||||
$el.on('blur', function () {
|
||||
var secondary = $(this).attr('secondary');
|
||||
if (secondary) {
|
||||
$(this).val(secondary);
|
||||
}
|
||||
});
|
||||
|
||||
// On manual edit — clear existing flag and unlock expiry_date
|
||||
$el.on('input', function () {
|
||||
$(this).removeAttr('data-existing');
|
||||
$('#expiry_date').prop('disabled', false).removeAttr('title');
|
||||
if (typeof expiryPicker !== 'undefined' && expiryPicker) {
|
||||
expiryPicker.clear();
|
||||
if (expiryPicker.altInput) { expiryPicker.altInput.disabled = false; }
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
@@ -306,20 +417,20 @@ function ajax_request(options) {
|
||||
return Promise.reject("validation_failed");
|
||||
}
|
||||
|
||||
if(options.data){
|
||||
if (options.data) {
|
||||
Object.entries(options.data).forEach(([key, value]) => {
|
||||
payloadJson[key] = value;
|
||||
});
|
||||
}
|
||||
|
||||
if(options.action){
|
||||
if (options.action) {
|
||||
// modify action
|
||||
if(options.action === 'manage'){
|
||||
options.action = (payloadJson['id'])?'update':'create';
|
||||
if (options.action === 'manage') {
|
||||
options.action = (payloadJson['id']) ? 'update' : 'create';
|
||||
}
|
||||
// add action to JSON
|
||||
payloadJson['action'] = options.action;
|
||||
}else{
|
||||
} else {
|
||||
isAjaxProcessing = false;
|
||||
return Promise.reject("please_define_action");
|
||||
}
|
||||
|
||||
@@ -11,6 +11,38 @@ class Reports
|
||||
$this->companyId = $companyId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the td_stock_<wh> table name for a given warehouse_id.
|
||||
* Returns null if warehouse not found.
|
||||
*/
|
||||
private function resolveWarehouseTable(int $warehouse_id): ?string
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT warehouse_name FROM md_warehouse
|
||||
WHERE company_id = :company_id AND id = :id AND status = 1"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->companyId, ':id' => $warehouse_id]);
|
||||
$name = $sth->fetchColumn();
|
||||
if (!$name) return null;
|
||||
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $name);
|
||||
return "td_stock_{$safe}";
|
||||
}
|
||||
|
||||
// These helper methods abstract away the common pattern of preparing, executing, and fetching results from the database.
|
||||
private function fetchScalar(string $sql)
|
||||
{
|
||||
$sth = $this->pdo->prepare($sql);
|
||||
$sth->execute([":company_id" => $this->companyId]);
|
||||
return $sth->fetchColumn();
|
||||
}
|
||||
|
||||
private function fetchAll(string $sql): array
|
||||
{
|
||||
$sth = $this->pdo->prepare($sql);
|
||||
$sth->execute([":company_id" => $this->companyId]);
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
/**
|
||||
* Master Data Summary:
|
||||
* - Total Categories: Count of all categories in md_product_category for the company.
|
||||
@@ -135,21 +167,408 @@ class Reports
|
||||
return $this->fetchAll($sql);
|
||||
}
|
||||
|
||||
// FETCH HELPERS
|
||||
// These helper methods abstract away the common pattern of preparing, executing, and fetching results from the database.
|
||||
private function fetchScalar(string $sql)
|
||||
/**
|
||||
* Low Stock Items
|
||||
*
|
||||
* Returns per-warehouse rows where balance has fallen to/below reorder_point.
|
||||
*
|
||||
* Status tiers:
|
||||
* critical — balance <= min_stock (safety floor breached)
|
||||
* warning — balance <= reorder_point (reorder trigger hit, still above min)
|
||||
*
|
||||
* Products with reorder_point = 0 are skipped (no threshold defined).
|
||||
*/
|
||||
public function getLowStockItems(): array
|
||||
{
|
||||
$sth = $this->pdo->prepare($sql);
|
||||
$sth->execute([":company_id" => $this->companyId]);
|
||||
return $sth->fetchColumn();
|
||||
$sql = "SELECT
|
||||
wb.warehouse_id,
|
||||
wb.product_sku,
|
||||
mw.warehouse_name,
|
||||
mp.product_name,
|
||||
mp.min_stock,
|
||||
mp.reorder_point,
|
||||
mp.product_image,
|
||||
ROUND(SUM(wb.total_in) - SUM(wb.total_out), 2) AS balance
|
||||
FROM warehouse_balance wb
|
||||
INNER JOIN md_product mp
|
||||
ON wb.company_id = mp.company_id
|
||||
AND wb.product_sku = mp.sku
|
||||
INNER JOIN md_warehouse mw
|
||||
ON wb.company_id = mw.company_id
|
||||
AND wb.warehouse_id = mw.id
|
||||
WHERE wb.company_id = :company_id
|
||||
AND mp.reorder_point > 0
|
||||
GROUP BY
|
||||
wb.warehouse_id, wb.product_sku, mw.warehouse_name,
|
||||
mp.product_name, mp.min_stock, mp.reorder_point, mp.product_image
|
||||
HAVING balance <= mp.reorder_point
|
||||
ORDER BY mp.product_name ASC, mw.warehouse_name ASC";
|
||||
|
||||
$rows = $this->fetchAll($sql);
|
||||
|
||||
$items = [];
|
||||
foreach ($rows as $row) {
|
||||
$balance = (float) $row['balance'];
|
||||
$min_stock = (float) $row['min_stock'];
|
||||
|
||||
$is_critical = ($balance <= $min_stock);
|
||||
|
||||
$items[] = [
|
||||
'warehouse_id' => (int) $row['warehouse_id'],
|
||||
'warehouse_name' => $row['warehouse_name'],
|
||||
'product_sku' => $row['product_sku'],
|
||||
'product_name' => $row['product_name'],
|
||||
'product_image' => $row['product_image'],
|
||||
'balance' => max(0, round($balance, 2)),
|
||||
'min_stock' => (float) $row['min_stock'],
|
||||
'reorder_point' => (float) $row['reorder_point'],
|
||||
'status' => $is_critical ? 'critical' : 'warning',
|
||||
];
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
private function fetchAll(string $sql): array
|
||||
/**
|
||||
* Count of low-stock items at CRITICAL level (balance <= min_stock).
|
||||
*/
|
||||
public function getCriticalStockCount(): int
|
||||
{
|
||||
$sth = $this->pdo->prepare($sql);
|
||||
$sth->execute([":company_id" => $this->companyId]);
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
$items = $this->getLowStockItems();
|
||||
return count(array_filter($items, fn($i) => $i['status'] === 'critical'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Count of low-stock items at WARNING level (balance <= reorder_point, > min_stock).
|
||||
*/
|
||||
public function getWarningStockCount(): int
|
||||
{
|
||||
$items = $this->getLowStockItems();
|
||||
return count(array_filter($items, fn($i) => $i['status'] === 'warning'));
|
||||
}
|
||||
|
||||
// CONTACT SUMMARY
|
||||
public function getTotalContactCategory(): int
|
||||
{
|
||||
$sql = "SELECT COUNT(*)
|
||||
FROM md_contact_type
|
||||
WHERE company_id = :company_id";
|
||||
return (int) $this->fetchScalar($sql);
|
||||
}
|
||||
|
||||
public function getActiveContactCategory(): int
|
||||
{
|
||||
$sql = "SELECT COUNT(*)
|
||||
FROM md_contact_type
|
||||
WHERE company_id = :company_id
|
||||
AND `status` = 1";
|
||||
return (int) $this->fetchScalar($sql);
|
||||
}
|
||||
|
||||
public function getInactiveContactCategory(): int
|
||||
{
|
||||
$sql = "SELECT COUNT(*)
|
||||
FROM md_contact_type
|
||||
WHERE company_id = :company_id
|
||||
AND `status` = 0";
|
||||
return (int) $this->fetchScalar($sql);
|
||||
}
|
||||
|
||||
public function getTotalContact(): int
|
||||
{
|
||||
$sql = "SELECT COUNT(*)
|
||||
FROM md_contact
|
||||
WHERE company_id = :company_id";
|
||||
return (int) $this->fetchScalar($sql);
|
||||
}
|
||||
|
||||
// WAREHOUSE OVERVIEW
|
||||
public function getWarehouseCapacity(int $warehouse_id): int
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT COUNT(*) FROM md_rack
|
||||
WHERE company_id = :company_id AND warehouse = :warehouse_id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->companyId, ':warehouse_id' => $warehouse_id]);
|
||||
return (int) $sth->fetchColumn();
|
||||
}
|
||||
|
||||
public function getWarehouseSpaceUsed(int $warehouse_id): int
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT COUNT(*) FROM md_rack
|
||||
WHERE company_id = :company_id AND warehouse = :warehouse_id
|
||||
AND product_sku IS NOT NULL"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->companyId, ':warehouse_id' => $warehouse_id]);
|
||||
return (int) $sth->fetchColumn();
|
||||
}
|
||||
|
||||
public function getWarehouseBalance(int $warehouse_id): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT
|
||||
ROUND(COALESCE(SUM(total_in), 0), 2) AS total_in,
|
||||
ROUND(COALESCE(SUM(total_out), 0), 2) AS total_out
|
||||
FROM warehouse_balance
|
||||
WHERE company_id = :company_id AND warehouse_id = :warehouse_id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->companyId, ':warehouse_id' => $warehouse_id]);
|
||||
return $sth->fetch(PDO::FETCH_ASSOC) ?: ['total_in' => 0, 'total_out' => 0];
|
||||
}
|
||||
|
||||
public function getWarehouseLowStockCount(int $warehouse_id): int
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT wb.product_sku,
|
||||
ROUND(SUM(wb.total_in) - SUM(wb.total_out), 2) AS balance,
|
||||
mp.min_stock
|
||||
FROM warehouse_balance wb
|
||||
INNER JOIN md_product mp
|
||||
ON mp.company_id = wb.company_id
|
||||
AND mp.sku = wb.product_sku
|
||||
WHERE wb.company_id = :company_id
|
||||
AND wb.warehouse_id = :warehouse_id
|
||||
GROUP BY wb.product_sku, mp.min_stock"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->companyId, ':warehouse_id' => $warehouse_id]);
|
||||
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
$count = 0;
|
||||
foreach ($rows as $row) {
|
||||
if ((float)$row['balance'] < (float)$row['min_stock']) $count++;
|
||||
}
|
||||
return $count;
|
||||
}
|
||||
|
||||
public function getWarehouseStockMovement(int $warehouse_id): array
|
||||
{
|
||||
$table = $this->resolveWarehouseTable($warehouse_id);
|
||||
if (!$table) return ['labels' => [], 'stock_in' => [], 'stock_out' => []];
|
||||
|
||||
$now = new DateTime();
|
||||
$start = (clone $now)->modify('-12 months')->format('Y-m-d 00:00:00');
|
||||
$end = $now->format('Y-m-d 23:59:59');
|
||||
|
||||
$sth = $this->pdo->query(
|
||||
"SELECT DATE_FORMAT(date, '%b') AS label,
|
||||
DATE_FORMAT(date, '%Y-%m') AS sort_key,
|
||||
ROUND(SUM(`in`), 2) AS stock_in,
|
||||
ROUND(SUM(`out`), 2) AS stock_out
|
||||
FROM `{$table}`
|
||||
WHERE company_id = {$this->companyId}
|
||||
AND date >= '{$start}' AND date <= '{$end}'
|
||||
GROUP BY sort_key, label
|
||||
ORDER BY sort_key ASC"
|
||||
);
|
||||
$dataByMonth = [];
|
||||
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$dataByMonth[$row['sort_key']] = $row;
|
||||
}
|
||||
|
||||
$labels = []; $stock_in = []; $stock_out = [];
|
||||
for ($i = 11; $i >= 0; $i--) {
|
||||
$dt = (clone $now)->modify("-{$i} months");
|
||||
$key = $dt->format('Y-m');
|
||||
$labels[] = $dt->format('M');
|
||||
$stock_in[] = isset($dataByMonth[$key]) ? (float)$dataByMonth[$key]['stock_in'] : 0;
|
||||
$stock_out[] = isset($dataByMonth[$key]) ? (float)$dataByMonth[$key]['stock_out'] : 0;
|
||||
}
|
||||
|
||||
return ['labels' => $labels, 'stock_in' => $stock_in, 'stock_out' => $stock_out];
|
||||
}
|
||||
|
||||
public function getWarehouseStockTrend(int $warehouse_id): array
|
||||
{
|
||||
$table = $this->resolveWarehouseTable($warehouse_id);
|
||||
if (!$table) return ['labels' => [], 'balance' => []];
|
||||
|
||||
$now = new DateTime();
|
||||
$start = (clone $now)->modify('-12 months')->format('Y-m-d 00:00:00');
|
||||
$end = $now->format('Y-m-d 23:59:59');
|
||||
|
||||
// Prior balance before the 12-month window
|
||||
$sth = $this->pdo->query(
|
||||
"SELECT ROUND(COALESCE(SUM(`in`) - SUM(`out`), 0), 2) AS balance
|
||||
FROM `{$table}`
|
||||
WHERE company_id = {$this->companyId} AND date < '{$start}'"
|
||||
);
|
||||
$running = (float) $sth->fetchColumn();
|
||||
|
||||
$sth = $this->pdo->query(
|
||||
"SELECT DATE_FORMAT(date, '%Y-%m') AS sort_key,
|
||||
ROUND(SUM(`in`), 2) AS stock_in,
|
||||
ROUND(SUM(`out`), 2) AS stock_out
|
||||
FROM `{$table}`
|
||||
WHERE company_id = {$this->companyId}
|
||||
AND date >= '{$start}' AND date <= '{$end}'
|
||||
GROUP BY sort_key
|
||||
ORDER BY sort_key ASC"
|
||||
);
|
||||
$dataByMonth = [];
|
||||
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$dataByMonth[$row['sort_key']] = $row;
|
||||
}
|
||||
|
||||
$labels = []; $balance = [];
|
||||
for ($i = 11; $i >= 0; $i--) {
|
||||
$dt = (clone $now)->modify("-{$i} months");
|
||||
$key = $dt->format('Y-m');
|
||||
$running += isset($dataByMonth[$key])
|
||||
? (float)$dataByMonth[$key]['stock_in'] - (float)$dataByMonth[$key]['stock_out']
|
||||
: 0;
|
||||
$labels[] = $dt->format('M');
|
||||
$balance[] = round($running, 2);
|
||||
}
|
||||
|
||||
return ['labels' => $labels, 'balance' => $balance];
|
||||
}
|
||||
|
||||
public function getWarehouseActivity(int $warehouse_id): array
|
||||
{
|
||||
$table = $this->resolveWarehouseTable($warehouse_id);
|
||||
if (!$table) return [];
|
||||
|
||||
$sth = $this->pdo->query(
|
||||
"SELECT a.date, a.product_sku,
|
||||
a.`in` AS stock_in, a.`out` AS stock_out,
|
||||
a.type, a.description, b.product_name
|
||||
FROM `{$table}` a
|
||||
LEFT JOIN md_product b
|
||||
ON a.company_id = b.company_id AND a.product_sku = b.sku
|
||||
WHERE a.company_id = {$this->companyId}
|
||||
AND (a.`in` > 0 OR a.`out` > 0)
|
||||
ORDER BY a.date DESC, a.id DESC
|
||||
LIMIT 10"
|
||||
);
|
||||
|
||||
$activities = [];
|
||||
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$is_in = (float)$row['stock_in'] > 0;
|
||||
$qty = $is_in ? (float)$row['stock_in'] : (float)$row['stock_out'];
|
||||
$activities[] = [
|
||||
'product_name' => $row['product_name'] ?: $row['product_sku'],
|
||||
'sku' => $row['product_sku'],
|
||||
'direction' => $is_in ? 'in' : 'out',
|
||||
'qty' => round($qty, 2),
|
||||
'type' => $row['type'],
|
||||
'description' => $row['description'],
|
||||
'date' => $row['date'],
|
||||
];
|
||||
}
|
||||
return $activities;
|
||||
}
|
||||
|
||||
// EXPIRED / NEAR EXPIRY STOCK
|
||||
public function getExpiredStock(int $warehouse_id = 0): array
|
||||
{
|
||||
// Step 1: get warehouses from md_warehouse — fast, indexed, company-scoped
|
||||
// avoids hitting information_schema on every request
|
||||
// if warehouse_id is passed, restrict to that warehouse only
|
||||
$where_wh = $warehouse_id > 0 ? "AND id = {$warehouse_id}" : '';
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id, warehouse_name
|
||||
FROM md_warehouse
|
||||
WHERE company_id = :company_id
|
||||
AND status = 1
|
||||
{$where_wh}"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->companyId]);
|
||||
$warehouses = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
if (empty($warehouses)) return [];
|
||||
|
||||
// Step 2: build UNION only across known warehouse tables
|
||||
// warehouse_id (integer) carried through for a fast indexed JOIN later
|
||||
$unions = implode("\nUNION ALL\n", array_map(
|
||||
fn($wh) => "SELECT
|
||||
s.id,
|
||||
s.product_sku,
|
||||
s.lot_number,
|
||||
s.zone,
|
||||
s.aisle,
|
||||
s.rack,
|
||||
ROUND(s.`in`, 2) AS quantity,
|
||||
{$wh['id']} AS warehouse_id
|
||||
FROM `td_stock_{$wh['warehouse_name']}` s
|
||||
WHERE s.company_id = {$this->companyId}
|
||||
AND s.type = 'in'
|
||||
AND s.lot_number IS NOT NULL",
|
||||
$warehouses
|
||||
));
|
||||
|
||||
// Step 3: join md_lot by (company_id, product_sku, lot_number)
|
||||
// join md_warehouse by integer warehouse_id — no string concat, index-friendly
|
||||
$sql = "SELECT
|
||||
stock.id,
|
||||
stock.product_sku,
|
||||
stock.lot_number,
|
||||
stock.zone,
|
||||
stock.aisle,
|
||||
stock.rack,
|
||||
stock.quantity,
|
||||
l.expiry_date,
|
||||
DATEDIFF(l.expiry_date, CURDATE()) AS days_remaining,
|
||||
p.product_name,
|
||||
pc.category as category_name,
|
||||
mw.warehouse_name
|
||||
FROM ($unions) AS stock
|
||||
INNER JOIN md_lot l
|
||||
ON l.company_id = {$this->companyId}
|
||||
AND l.product_sku = stock.product_sku
|
||||
AND l.lot_number = stock.lot_number
|
||||
INNER JOIN md_product p
|
||||
ON p.company_id = {$this->companyId}
|
||||
AND p.sku = stock.product_sku
|
||||
LEFT JOIN md_product_category pc
|
||||
ON pc.company_id = {$this->companyId}
|
||||
AND pc.id = p.category
|
||||
INNER JOIN md_warehouse mw
|
||||
ON mw.company_id = {$this->companyId}
|
||||
AND mw.id = stock.warehouse_id
|
||||
WHERE l.expiry_date IS NOT NULL
|
||||
ORDER BY l.expiry_date ASC";
|
||||
|
||||
$sth = $this->pdo->prepare($sql);
|
||||
$sth->execute([]);
|
||||
|
||||
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
$items = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$days = (int) $row['days_remaining'];
|
||||
|
||||
if ($days < 0) {
|
||||
$status = 'expired';
|
||||
} elseif ($days <= 7) {
|
||||
$status = 'critical';
|
||||
} elseif ($days <= 14) {
|
||||
$status = 'warning';
|
||||
} elseif ($days <= 30) {
|
||||
$status = 'caution';
|
||||
} else {
|
||||
continue; // beyond 30 days — not relevant for this report
|
||||
}
|
||||
|
||||
$items[] = [
|
||||
'id' => (int) $row['id'],
|
||||
'product_sku' => $row['product_sku'],
|
||||
'product_name' => $row['product_name'],
|
||||
'category_name' => $row['category_name'],
|
||||
'warehouse_name' => $row['warehouse_name'],
|
||||
'lot_number' => $row['lot_number'],
|
||||
'zone' => $row['zone'],
|
||||
'aisle' => $row['aisle'],
|
||||
'rack' => $row['rack'],
|
||||
'quantity' => (float) $row['quantity'],
|
||||
'expiry_date' => $row['expiry_date'],
|
||||
'days_remaining' => $days,
|
||||
'status' => $status,
|
||||
];
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -20,6 +20,64 @@ class WarehouseManager {
|
||||
$this->company_id = $company_id;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Private helpers
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve the td_stock_<wh> table name for a given warehouse_id.
|
||||
* Returns null if warehouse not found or inactive.
|
||||
*/
|
||||
private function resolveWarehouseTable(int $warehouse_id): ?string
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT warehouse_name FROM md_warehouse
|
||||
WHERE company_id = :company_id AND id = :id AND status = 1"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':id' => $warehouse_id]);
|
||||
$name = $sth->fetchColumn();
|
||||
if (!$name) return null;
|
||||
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $name);
|
||||
return "td_stock_{$safe}";
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Range helper
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Convert a from/to range into an array of string values.
|
||||
* Supports:
|
||||
* - Numeric ranges: "1" → "10" → ["1","2",...,"10"]
|
||||
* - Alpha ranges: "A" → "D" → ["A","B","C","D"]
|
||||
*
|
||||
* @throws Exception if values are not pure numeric or single alpha characters
|
||||
*/
|
||||
private function rangeToArray(string $from, string $to): array
|
||||
{
|
||||
$from = trim($from);
|
||||
$to = trim($to);
|
||||
|
||||
// Numeric range
|
||||
if (is_numeric($from) && is_numeric($to)) {
|
||||
$f = (int) $from;
|
||||
$t = (int) $to;
|
||||
if ($f > $t) throw new Exception("Range start ($from) must be <= end ($to)");
|
||||
return array_map('strval', range($f, $t));
|
||||
}
|
||||
|
||||
// Single-letter alpha range
|
||||
if (ctype_alpha($from) && ctype_alpha($to)
|
||||
&& strlen($from) === 1 && strlen($to) === 1) {
|
||||
if (ord($from) > ord($to)) throw new Exception("Range start ($from) must be <= end ($to)");
|
||||
return range($from, $to); // PHP range() handles 'A' → 'Z' natively
|
||||
}
|
||||
|
||||
throw new Exception(
|
||||
"Aisle/rack values must be either numbers (e.g. 1–99) or single uppercase letters (e.g. A–Z). Got: '$from' → '$to'"
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Warehouse lookup
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
@@ -146,60 +204,60 @@ class WarehouseManager {
|
||||
|
||||
/**
|
||||
* Guard: reject the update if any occupied rack would be removed.
|
||||
* Works for both numeric and alpha aisle/rack values.
|
||||
*/
|
||||
private function validateRangeChange(int $storage_id, array $range): void {
|
||||
private function validateRangeChange(int $storage_id, array $range): void
|
||||
{
|
||||
// Build the valid sets from the new range
|
||||
$valid_aisles = $this->rangeToArray($range['aisle_from'], $range['aisle_to']);
|
||||
$valid_racks = $this->rangeToArray($range['rack_from'], $range['rack_to']);
|
||||
|
||||
$sql = "SELECT COUNT(*) FROM md_rack
|
||||
WHERE company_id = :company_id
|
||||
AND storage_id = :storage_id
|
||||
AND product_sku IS NOT NULL
|
||||
AND (
|
||||
CAST(aisle AS UNSIGNED) < :aisle_from OR
|
||||
CAST(aisle AS UNSIGNED) > :aisle_to OR
|
||||
CAST(rack AS UNSIGNED) < :rack_from OR
|
||||
CAST(rack AS UNSIGNED) > :rack_to
|
||||
)";
|
||||
|
||||
$sth = $this->pdo->prepare($sql);
|
||||
// Fetch all occupied racks under this storage_id
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT aisle, rack FROM md_rack
|
||||
WHERE company_id = :company_id
|
||||
AND storage_id = :storage_id
|
||||
AND product_sku IS NOT NULL"
|
||||
);
|
||||
$sth->execute([
|
||||
":company_id" => $this->company_id,
|
||||
":storage_id" => $storage_id,
|
||||
":aisle_from" => (int) $range['aisle_from'],
|
||||
":aisle_to" => (int) $range['aisle_to'],
|
||||
":rack_from" => (int) $range['rack_from'],
|
||||
":rack_to" => (int) $range['rack_to'],
|
||||
':company_id' => $this->company_id,
|
||||
':storage_id' => $storage_id,
|
||||
]);
|
||||
|
||||
if ($sth->fetchColumn() > 0) {
|
||||
throw new Exception("Cannot shrink range — some racks still have stock");
|
||||
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$a = trim($row['aisle']);
|
||||
$r = trim($row['rack']);
|
||||
|
||||
if (!in_array($a, $valid_aisles, true) || !in_array($r, $valid_racks, true)) {
|
||||
throw new Exception("Cannot shrink range — some racks still have stock");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert rack rows for the full range (IGNORE skips duplicates).
|
||||
* Supports both numeric (1-10) and alpha (A-Z) aisle/rack values.
|
||||
*/
|
||||
private function insertRacksInRange(int $storage_id, array $range): void {
|
||||
|
||||
private function insertRacksInRange(int $storage_id, array $range): void
|
||||
{
|
||||
$sql = "INSERT IGNORE INTO md_rack
|
||||
(company_id, warehouse, storage_id, zone, aisle, rack)
|
||||
VALUES
|
||||
(:company_id, :warehouse, :storage_id, :zone, :aisle, :rack)";
|
||||
$sth = $this->pdo->prepare($sql);
|
||||
|
||||
$aisle_from = (int) $range['aisle_from'];
|
||||
$aisle_to = (int) $range['aisle_to'];
|
||||
$rack_from = (int) $range['rack_from'];
|
||||
$rack_to = (int) $range['rack_to'];
|
||||
$aisles = $this->rangeToArray($range['aisle_from'], $range['aisle_to']);
|
||||
$racks = $this->rangeToArray($range['rack_from'], $range['rack_to']);
|
||||
|
||||
for ($a = $aisle_from; $a <= $aisle_to; $a++) {
|
||||
for ($r = $rack_from; $r <= $rack_to; $r++) {
|
||||
foreach ($aisles as $a) {
|
||||
foreach ($racks as $r) {
|
||||
$sth->execute([
|
||||
":company_id" => $this->company_id,
|
||||
":warehouse" => $range['warehouse'],
|
||||
":storage_id" => $storage_id,
|
||||
":zone" => $range['zone'],
|
||||
":aisle" => (string) $a,
|
||||
":rack" => (string) $r,
|
||||
":aisle" => $a,
|
||||
":rack" => $r,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -288,8 +346,103 @@ class WarehouseManager {
|
||||
*
|
||||
* @throws Exception if the rack is already occupied or doesn't exist.
|
||||
*/
|
||||
public function occupyRack($warehouse_id, $zone, $aisle, $rack, $product_sku, $td_stock_id): void {
|
||||
/**
|
||||
* Validate that the combination of product_sku + lot_number + serial_number
|
||||
* does not already occupy an active rack across all warehouses.
|
||||
*
|
||||
* "Active" means md_rack.td_stock_id is still linked (rack is occupied).
|
||||
* Once a rack is released (stock_out/transfer), the combo is free again.
|
||||
*
|
||||
* Rules:
|
||||
* - product_sku alone → allowed in multiple racks (normal stocking)
|
||||
* - product_sku + lot → allowed in multiple racks (lot spread across racks)
|
||||
* - product_sku + serial → must be unique (1 physical unit = 1 location)
|
||||
* - product_sku + lot + serial → must be unique
|
||||
*
|
||||
* @param string $product_sku
|
||||
* @param string|null $lot_number
|
||||
* @param string|null $serial_number
|
||||
* @param int $exclude_stock_id Skip this stock row (for edit flows)
|
||||
* @throws Exception if a duplicate active record is found
|
||||
*/
|
||||
public function validateStockUnique(
|
||||
string $product_sku,
|
||||
?string $lot_number = null,
|
||||
?string $serial_number = null,
|
||||
int $exclude_stock_id = 0
|
||||
): void {
|
||||
// Nothing to validate if neither lot nor serial is provided
|
||||
if (empty($lot_number) && empty($serial_number)) return;
|
||||
|
||||
$cid = $this->company_id;
|
||||
|
||||
// Discover all warehouses
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id, warehouse_name FROM md_warehouse
|
||||
WHERE company_id = :company_id AND status = 1"
|
||||
);
|
||||
$sth->execute([':company_id' => $cid]);
|
||||
$warehouses = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
if (empty($warehouses)) return;
|
||||
|
||||
foreach ($warehouses as $wh) {
|
||||
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $wh['warehouse_name']);
|
||||
$table = "td_stock_{$safe}";
|
||||
|
||||
// Check for any existing active stock_in row matching sku + lot + serial
|
||||
// "Active" = rack still occupied (EXISTS in md_rack with product_sku IS NOT NULL)
|
||||
// NOTE: called from inside occupyRack, AFTER INSERT but BEFORE md_rack is updated
|
||||
// so the current row's td_stock_id is not yet in md_rack — won't match itself
|
||||
$sql = "SELECT s.id
|
||||
FROM `{$table}` s
|
||||
WHERE s.company_id = :company_id
|
||||
AND s.product_sku = :product_sku
|
||||
AND s.type = 'in'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM md_rack r
|
||||
WHERE r.company_id = :company_id2
|
||||
AND r.td_stock_id = s.id
|
||||
AND r.product_sku IS NOT NULL
|
||||
)";
|
||||
|
||||
if (!empty($lot_number)) {
|
||||
$sql .= " AND s.lot_number = :lot_number";
|
||||
}
|
||||
if (!empty($serial_number)) {
|
||||
$sql .= " AND s.serial_number = :serial_number";
|
||||
}
|
||||
if ($exclude_stock_id > 0) {
|
||||
$sql .= " AND s.id != :exclude_id";
|
||||
}
|
||||
|
||||
$params = [
|
||||
':company_id' => $cid,
|
||||
':company_id2' => $cid,
|
||||
':product_sku' => $product_sku,
|
||||
];
|
||||
if (!empty($lot_number)) $params[':lot_number'] = $lot_number;
|
||||
if (!empty($serial_number)) $params[':serial_number'] = $serial_number;
|
||||
if ($exclude_stock_id > 0) $params[':exclude_id'] = $exclude_stock_id;
|
||||
|
||||
$sth = $this->pdo->prepare($sql);
|
||||
$sth->execute($params);
|
||||
|
||||
if ($sth->fetchColumn()) {
|
||||
$combo = implode(' / ', array_filter([
|
||||
$lot_number ? "lot: {$lot_number}" : null,
|
||||
$serial_number ? "serial: {$serial_number}" : null,
|
||||
]));
|
||||
throw new Exception(
|
||||
"Active stock already exists for product '{$product_sku}' [{$combo}] "
|
||||
. "in warehouse '{$wh['warehouse_name']}'. "
|
||||
. "Stock must be moved out before it can be received again."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function occupyRack($warehouse_id, $zone, $aisle, $rack, $product_sku, $td_stock_id): void {
|
||||
|
||||
$sql = "SELECT id, product_sku FROM md_rack
|
||||
WHERE company_id = :company_id
|
||||
@@ -319,6 +472,20 @@ class WarehouseManager {
|
||||
);
|
||||
}
|
||||
|
||||
// Validate sku + lot + serial uniqueness before occupying the rack
|
||||
// Fetch lot/serial from the td_stock row being linked
|
||||
$ctx = $this->getStockContext($warehouse_id, $td_stock_id);
|
||||
$row = $ctx['row'] ?? null;
|
||||
|
||||
if ($row) {
|
||||
$this->validateStockUnique(
|
||||
$product_sku,
|
||||
$row['lot_number'] ?? null,
|
||||
$row['serial_number'] ?? null,
|
||||
$td_stock_id // exclude self so edit flows don't block
|
||||
);
|
||||
}
|
||||
|
||||
// Single atomic UPDATE: state + log together
|
||||
$sql = "UPDATE md_rack
|
||||
SET product_sku = :product_sku,
|
||||
@@ -486,6 +653,213 @@ class WarehouseManager {
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Zone / Aisle / Rack resolution
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Zones with at least one empty rack — for stock_in destination.
|
||||
*/
|
||||
public function getZonesIn(int $warehouse_id): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT DISTINCT zone FROM md_rack
|
||||
WHERE company_id = :company_id
|
||||
AND warehouse = :warehouse
|
||||
AND product_sku IS NULL
|
||||
ORDER BY CAST(zone AS UNSIGNED), zone"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':warehouse' => $warehouse_id]);
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zones holding active stock for sku + lot + serial — for stock_out source.
|
||||
*/
|
||||
public function getZonesOut(
|
||||
int $warehouse_id,
|
||||
string $product_sku,
|
||||
?string $lot_number = null,
|
||||
?string $serial_number = null
|
||||
): array {
|
||||
$table = $this->resolveWarehouseTable($warehouse_id);
|
||||
if (!$table) return [];
|
||||
|
||||
[$lot_cond, $serial_cond, $params] = $this->buildLotSerialCondition(
|
||||
$warehouse_id, $product_sku, $lot_number, $serial_number
|
||||
);
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT DISTINCT r.zone
|
||||
FROM md_rack r
|
||||
WHERE r.company_id = :company_id
|
||||
AND r.warehouse = :warehouse
|
||||
AND r.product_sku = :product_sku
|
||||
AND r.td_stock_id IN (
|
||||
SELECT s.id FROM `{$table}` s
|
||||
WHERE s.company_id = :company_id2
|
||||
AND s.product_sku = :product_sku2
|
||||
AND s.type = 'in'
|
||||
{$lot_cond}
|
||||
{$serial_cond}
|
||||
)
|
||||
ORDER BY CAST(r.zone AS UNSIGNED), r.zone"
|
||||
);
|
||||
$sth->execute($params);
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aisles with at least one empty rack in a zone — for stock_in destination.
|
||||
*/
|
||||
public function getAislesIn(int $warehouse_id, string $zone): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT DISTINCT aisle FROM md_rack
|
||||
WHERE company_id = :company_id
|
||||
AND warehouse = :warehouse
|
||||
AND zone = :zone
|
||||
AND product_sku IS NULL
|
||||
ORDER BY CAST(aisle AS UNSIGNED), aisle"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':warehouse' => $warehouse_id,
|
||||
':zone' => $zone,
|
||||
]);
|
||||
return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'aisle');
|
||||
}
|
||||
|
||||
/**
|
||||
* Aisles holding active stock for sku + lot + serial — for stock_out source.
|
||||
*/
|
||||
public function getAislesOut(
|
||||
int $warehouse_id,
|
||||
string $zone,
|
||||
string $product_sku,
|
||||
?string $lot_number = null,
|
||||
?string $serial_number = null
|
||||
): array {
|
||||
$table = $this->resolveWarehouseTable($warehouse_id);
|
||||
if (!$table) return [];
|
||||
|
||||
[$lot_cond, $serial_cond, $params] = $this->buildLotSerialCondition(
|
||||
$warehouse_id, $product_sku, $lot_number, $serial_number
|
||||
);
|
||||
$params[':zone'] = $zone;
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT DISTINCT r.aisle
|
||||
FROM md_rack r
|
||||
WHERE r.company_id = :company_id
|
||||
AND r.warehouse = :warehouse
|
||||
AND r.zone = :zone
|
||||
AND r.product_sku = :product_sku
|
||||
AND r.td_stock_id IN (
|
||||
SELECT s.id FROM `{$table}` s
|
||||
WHERE s.company_id = :company_id2
|
||||
AND s.product_sku = :product_sku2
|
||||
AND s.type = 'in'
|
||||
{$lot_cond}
|
||||
{$serial_cond}
|
||||
)
|
||||
ORDER BY CAST(r.aisle AS UNSIGNED), r.aisle"
|
||||
);
|
||||
$sth->execute($params);
|
||||
return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'aisle');
|
||||
}
|
||||
|
||||
/**
|
||||
* Racks that are empty in a given aisle — for stock_in destination.
|
||||
*/
|
||||
public function getRacksIn(int $warehouse_id, string $zone, string $aisle): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT DISTINCT rack FROM md_rack
|
||||
WHERE company_id = :company_id
|
||||
AND warehouse = :warehouse
|
||||
AND zone = :zone
|
||||
AND aisle = :aisle
|
||||
AND product_sku IS NULL
|
||||
ORDER BY CAST(rack AS UNSIGNED), rack"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':warehouse' => $warehouse_id,
|
||||
':zone' => $zone,
|
||||
':aisle' => $aisle,
|
||||
]);
|
||||
return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'rack');
|
||||
}
|
||||
|
||||
/**
|
||||
* Racks holding active stock for sku + lot + serial — for stock_out source.
|
||||
*/
|
||||
public function getRacksOut(
|
||||
int $warehouse_id,
|
||||
string $zone,
|
||||
string $aisle,
|
||||
string $product_sku,
|
||||
?string $lot_number = null,
|
||||
?string $serial_number = null
|
||||
): array {
|
||||
$table = $this->resolveWarehouseTable($warehouse_id);
|
||||
if (!$table) return [];
|
||||
|
||||
[$lot_cond, $serial_cond, $params] = $this->buildLotSerialCondition(
|
||||
$warehouse_id, $product_sku, $lot_number, $serial_number
|
||||
);
|
||||
$params[':zone'] = $zone;
|
||||
$params[':aisle'] = $aisle;
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT DISTINCT r.rack
|
||||
FROM md_rack r
|
||||
WHERE r.company_id = :company_id
|
||||
AND r.warehouse = :warehouse
|
||||
AND r.zone = :zone
|
||||
AND r.aisle = :aisle
|
||||
AND r.product_sku = :product_sku
|
||||
AND r.td_stock_id IN (
|
||||
SELECT s.id FROM `{$table}` s
|
||||
WHERE s.company_id = :company_id2
|
||||
AND s.product_sku = :product_sku2
|
||||
AND s.type = 'in'
|
||||
{$lot_cond}
|
||||
{$serial_cond}
|
||||
)
|
||||
ORDER BY CAST(r.rack AS UNSIGNED), r.rack"
|
||||
);
|
||||
$sth->execute($params);
|
||||
return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'rack');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build lot/serial WHERE conditions and bound params for Out methods.
|
||||
*/
|
||||
private function buildLotSerialCondition(
|
||||
int $warehouse_id,
|
||||
string $product_sku,
|
||||
?string $lot_number = null,
|
||||
?string $serial_number = null
|
||||
): array {
|
||||
$lot_cond = !empty($lot_number) ? "AND s.lot_number = :lot_number" : "";
|
||||
$serial_cond = !empty($serial_number) ? "AND s.serial_number = :serial_number" : "";
|
||||
|
||||
$params = [
|
||||
':company_id' => $this->company_id,
|
||||
':warehouse' => $warehouse_id,
|
||||
':product_sku' => $product_sku,
|
||||
':company_id2' => $this->company_id,
|
||||
':product_sku2' => $product_sku,
|
||||
];
|
||||
if (!empty($lot_number)) $params[':lot_number'] = $lot_number;
|
||||
if (!empty($serial_number)) $params[':serial_number'] = $serial_number;
|
||||
|
||||
return [$lot_cond, $serial_cond, $params];
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Delete (soft-delete) operations
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
session_start();
|
||||
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require '../../../assets/utils/classes/Reports.php';
|
||||
|
||||
$report = new Reports($pdo2, $company_id);
|
||||
|
||||
$answer["total_category"] = $report->getTotalContactCategory();
|
||||
$answer["active_category"] = $report->getActiveContactCategory();
|
||||
$answer["inactive_category"] = $report->getInactiveContactCategory();
|
||||
$answer["total_contact"] = $report->getTotalContact();
|
||||
$answer["success"] = 1;
|
||||
exit(json_encode($answer));
|
||||
?>
|
||||
+184
-129
@@ -2,7 +2,6 @@
|
||||
session_start();
|
||||
require '../config.php';
|
||||
require '../include_header.php';
|
||||
|
||||
?>
|
||||
|
||||
<body>
|
||||
@@ -13,96 +12,87 @@
|
||||
<!-- MAIN CONTENT -->
|
||||
<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-5 gap-3">
|
||||
<div class="">
|
||||
<div>
|
||||
<h1 class="fs-3 mb-1">Contact</h1>
|
||||
<p class="mb-0">Manage your contact</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary stats -->
|
||||
<div class="row g-5 mb-5">
|
||||
<div class="col-xl-3 col-md-6 col-12">
|
||||
<div class="card">
|
||||
<div class="card-body p-6">
|
||||
<div class="d-flex align-items-start gap-3">
|
||||
<div class="icon-shape icon-lg bg-primary bg-opacity-10 text-primary rounded-2">
|
||||
<i class="ti ti-category fs-4 "></i>
|
||||
<i class="ti ti-category fs-4"></i>
|
||||
</div>
|
||||
<div class="">
|
||||
<div>
|
||||
<span>Total Categories</span>
|
||||
<h2 class="fw-bold mb-0">6</h2>
|
||||
<h2 class="fw-bold mb-0" id="stat_total_category">—</h2>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="col-xl-3 col-md-6 col-12">
|
||||
<div class="card">
|
||||
<div class="card-body p-6">
|
||||
<div class="d-flex align-items-start gap-3">
|
||||
<div class="icon-shape icon-lg bg-info bg-opacity-10 text-info rounded-2">
|
||||
<i class="ti ti-check fs-4 "></i>
|
||||
<i class="ti ti-check fs-4"></i>
|
||||
</div>
|
||||
<div class="">
|
||||
<div>
|
||||
<span>Active</span>
|
||||
<h2 class="fw-bold mb-0">5</h2>
|
||||
<h2 class="fw-bold mb-0" id="stat_active_category">—</h2>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="col-xl-3 col-md-6 col-12">
|
||||
<div class="card">
|
||||
<div class="card-body p-6">
|
||||
<div class="d-flex align-items-start gap-3">
|
||||
<div class="icon-shape icon-lg bg-warning bg-opacity-10 text-warning rounded-2">
|
||||
<i class="ti ti-x fs-4 "></i>
|
||||
<i class="ti ti-x fs-4"></i>
|
||||
</div>
|
||||
<div class="">
|
||||
<div>
|
||||
<span>Inactive</span>
|
||||
<h2 class="fw-bold mb-0">1</h2>
|
||||
<h2 class="fw-bold mb-0" id="stat_inactive_category">—</h2>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="col-xl-3 col-md-6 col-12">
|
||||
<div class="card">
|
||||
<div class="card-body p-6">
|
||||
<div class="d-flex align-items-start gap-3">
|
||||
<div class="icon-shape icon-lg bg-warning bg-opacity-10 text-warning rounded-2">
|
||||
<i class="ti ti-box-seam fs-4 "></i>
|
||||
<i class="ti ti-box-seam fs-4"></i>
|
||||
</div>
|
||||
<div class="">
|
||||
<div>
|
||||
<span>Total Contact</span>
|
||||
<h2 class="fw-bold mb-0">356</h2>
|
||||
<h2 class="fw-bold mb-0" id="stat_total_contact">—</h2>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<ul class="nav nav-pills mb-3 bg-light p-2 rounded-2 d-inline-flex" id="pills-tab" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="pills-contact_type-tab" data-bs-toggle="pill"
|
||||
@@ -115,6 +105,7 @@
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Contact Type tab -->
|
||||
<div class="row g-5 mb-5 tab-pane fade show active" id="pills-contact_type" role="tabpanel"
|
||||
aria-labelledby="pills-contact_type-tab">
|
||||
<div class="col-12">
|
||||
@@ -129,20 +120,23 @@
|
||||
<div>
|
||||
<a href="<?php echo $server_url?>contact/manage_contact_type.php" class="btn btn-primary">
|
||||
<span class="me-1"><i class="ti ti-plus"></i></span>
|
||||
<span>Add Contact Type</span></a>
|
||||
<span>Add Contact Type</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search bar -->
|
||||
<div class="col-lg-12 mb-5">
|
||||
<div class="position-relative">
|
||||
<input type="search" class="form-control ps-9" placeholder="Search by contact name">
|
||||
<input type="search" id="search_contact_type" class="form-control ps-9"
|
||||
placeholder="Search by contact type or description…">
|
||||
<span class="position-absolute top-25 ms-4">
|
||||
<i class="ti ti-search text-muted"></i>
|
||||
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="table-responsive">
|
||||
<table class="table mb-0 text-nowrap table-hover table-centered" id="contact_type">
|
||||
<thead class="table-primary border-light">
|
||||
@@ -153,26 +147,23 @@
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
<tbody>
|
||||
|
||||
</tbody>
|
||||
<tfoot class="">
|
||||
|
||||
</tfoot>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
<tfoot></tfoot>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Contacts tab -->
|
||||
<div class="row g-5 mb-5 tab-pane fade" id="pills-contact" role="tabpanel" aria-labelledby="pills-contact-tab">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body p-5">
|
||||
|
||||
<div class="mb-6 d-flex align-items-center justify-content-between">
|
||||
<div>
|
||||
<h3 class="mb-1 fs-4">Contacts</h3>
|
||||
@@ -181,10 +172,23 @@
|
||||
<div>
|
||||
<a href="<?php echo $server_url?>contact/manage_contact.php" class="btn btn-secondary">
|
||||
<span class="me-1"><i class="ti ti-plus"></i></span>
|
||||
<span>Add Contact</span></a>
|
||||
<span>Add Contact</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search bar -->
|
||||
<div class="col-lg-12 mb-5">
|
||||
<div class="position-relative">
|
||||
<input type="search" id="search_contact" class="form-control ps-9"
|
||||
placeholder="Search by name, tax ID, organization, or branch…">
|
||||
<span class="position-absolute top-25 ms-4">
|
||||
<i class="ti ti-search text-muted"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0 table-centered" id="contact">
|
||||
<thead class="table-secondary border-light">
|
||||
@@ -200,24 +204,27 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
<tfoot class="">
|
||||
|
||||
</tfoot>
|
||||
<tfoot></tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<?php require "../include_ending.php";?>
|
||||
|
||||
<script>
|
||||
// ── Current search terms ─────────────────────────────────────────────
|
||||
var search_term_contact_type = '';
|
||||
var search_term_contact = '';
|
||||
|
||||
|
||||
// ── Contact Type: fetch + render ─────────────────────────────────────
|
||||
function retrieve_contact_type() {
|
||||
|
||||
return ajax_request({
|
||||
@@ -228,57 +235,59 @@
|
||||
action: 'read',
|
||||
onSuccess: function(res) {
|
||||
|
||||
// Register the array as a table name
|
||||
alasql('CREATE TABLE IF NOT EXISTS contact_type');
|
||||
alasql.tables.contact_type.data = res.output || [];
|
||||
|
||||
var total_records = alasql.tables.contact_type.data.length;
|
||||
|
||||
var table_id = 'contact_type';
|
||||
|
||||
var page = generate_pagination(table_id,total_records);
|
||||
$(`table#${table_id} tfoot`).html(page);
|
||||
|
||||
// dynamic function name
|
||||
if (typeof window[`change_page_${table_id}`] === "function") {
|
||||
window[`change_page_${table_id}`](1);
|
||||
} else {
|
||||
console.error(`Function change_page_${table_id} does not exist.`);
|
||||
}
|
||||
change_page_contact_type(1);
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
function change_page_contact_type(page_num) {
|
||||
|
||||
var offset = (page_num - 1) * prop_limit;
|
||||
var kw = search_term_contact_type.toLowerCase();
|
||||
|
||||
var page_data = alasql(`SELECT * FROM contact_type LIMIT ${prop_limit} OFFSET ${offset}`);
|
||||
var filtered = kw ?
|
||||
alasql(`SELECT * FROM contact_type
|
||||
WHERE LOWER(contact_type) LIKE '%${kw}%'
|
||||
OR LOWER(description) LIKE '%${kw}%'`) :
|
||||
alasql(`SELECT * FROM contact_type`);
|
||||
|
||||
var body = ``;
|
||||
var page = generate_pagination('contact_type', filtered.length);
|
||||
$(`table#contact_type tfoot`).html(page);
|
||||
|
||||
if (filtered.length === 0) {
|
||||
$("table#contact_type > tbody").html(
|
||||
'<tr><td colspan="5" class="text-center py-5 text-muted">No contact types match your search.</td></tr>'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
var page_data = filtered.slice(offset, offset + prop_limit);
|
||||
|
||||
var body = '';
|
||||
$.each(page_data, function(key, item) {
|
||||
body += `<tr>
|
||||
<td class="py-3">${item['contact_type']}</td>
|
||||
<td class="py-3">${item['description']}</td>
|
||||
<td class="py-3">${item['status']}</td>
|
||||
<td class="py-3">
|
||||
${(item['status'])?'<span class="badge bg-success">Active</span>':'<span class="badge bg-secondary">Inactive</span>'}
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<a href="<?php echo $server_url?>contact/manage_contact_type.php?id=${item['id']}" class=""><i class="ti ti-eye fs-5"></i></a>
|
||||
<a href="javascript:;" class="link-danger" onclick="remove_contact_type(${item['id']},$(this))"><i class="ti ti-trash ms-2 fs-5"></i></a>
|
||||
</td>
|
||||
</tr>`;
|
||||
})
|
||||
<td class="py-3">${item['contact_type']}</td>
|
||||
<td class="py-3">${item['description']}</td>
|
||||
<td class="py-3">${item['contact_count'] ?? 0}</td>
|
||||
<td class="py-3">
|
||||
${(item['status'])?'<span class="badge bg-success">Active</span>':'<span class="badge bg-secondary">Inactive</span>'}
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<a href="<?php echo $server_url?>contact/manage_contact_type.php?id=${item['id']}" class=""><i class="ti ti-eye fs-5"></i></a>
|
||||
<a href="javascript:;" class="link-danger" onclick="remove_contact_type(${item['id']},$(this))"><i class="ti ti-trash ms-2 fs-5"></i></a>
|
||||
</td>
|
||||
</tr>`;
|
||||
});
|
||||
|
||||
$("table#contact_type > tbody").html(body);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ── Contacts: fetch + render ─────────────────────────────────────────
|
||||
function retrieve_contact() {
|
||||
|
||||
return ajax_request({
|
||||
@@ -288,60 +297,86 @@
|
||||
action: 'read',
|
||||
onSuccess: function(res) {
|
||||
|
||||
// Register the array as a table name
|
||||
alasql('CREATE TABLE IF NOT EXISTS contact');
|
||||
alasql.tables.contact.data = res.output || [];
|
||||
|
||||
var total_records = alasql.tables.contact.data.length;
|
||||
|
||||
var table_id = 'contact';
|
||||
|
||||
var page = generate_pagination(table_id,total_records);
|
||||
$(`table#${table_id} tfoot`).html(page);
|
||||
|
||||
// dynamic function name
|
||||
if (typeof window[`change_page_${table_id}`] === "function") {
|
||||
window[`change_page_${table_id}`](1);
|
||||
} else {
|
||||
console.error(`Function change_page_${table_id} does not exist.`);
|
||||
}
|
||||
change_page_contact(1);
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
function change_page_contact(page_num) {
|
||||
|
||||
var offset = (page_num - 1) * prop_limit;
|
||||
var kw = search_term_contact.toLowerCase();
|
||||
|
||||
var page_data = alasql(`SELECT * FROM contact LIMIT ${prop_limit} OFFSET ${offset}`);
|
||||
var filtered = kw ?
|
||||
alasql(`SELECT * FROM contact
|
||||
WHERE LOWER(contact_name) LIKE '%${kw}%'
|
||||
OR LOWER(tax_id) LIKE '%${kw}%'
|
||||
OR LOWER(organization) LIKE '%${kw}%'
|
||||
OR LOWER(branch) LIKE '%${kw}%'
|
||||
OR LOWER(billing_address) LIKE '%${kw}%'
|
||||
OR LOWER(shipping_address) LIKE '%${kw}%'`) :
|
||||
alasql(`SELECT * FROM contact`);
|
||||
|
||||
var body = ``;
|
||||
var page = generate_pagination('contact', filtered.length);
|
||||
$(`table#contact tfoot`).html(page);
|
||||
|
||||
if (filtered.length === 0) {
|
||||
$("table#contact > tbody").html(
|
||||
'<tr><td colspan="8" class="text-center py-5 text-muted">No contacts match your search.</td></tr>'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
var page_data = filtered.slice(offset, offset + prop_limit);
|
||||
|
||||
var body = '';
|
||||
$.each(page_data, function(key, item) {
|
||||
body += `<tr>
|
||||
<td class="py-3">${item['tax_id']}</td>
|
||||
<td class="py-3">${item['contact_name']}</td>
|
||||
<td class="py-3">${item['organization']}</td>
|
||||
<td class="py-3">${item['branch']}</td>
|
||||
<td class="py-3">${item['billing_address']}</td>
|
||||
<td class="py-3">${item['shipping_address']}</td>
|
||||
<td class="py-3">
|
||||
${(item['status'])?'<span class="badge bg-success">Active</span>':'<span class="badge bg-secondary">Inactive</span>'}
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<a href="<?php echo $server_url?>contact/manage_contact.php?id=${item['id']}" class=""><i class="ti ti-eye fs-5"></i></a>
|
||||
<a href="javascript:;" class="link-danger" onclick="remove_contact(${item['id']},$(this))"><i class="ti ti-trash ms-2 fs-5"></i></a>
|
||||
</td>
|
||||
</tr>`;
|
||||
})
|
||||
<td class="py-3">${item['tax_id']}</td>
|
||||
<td class="py-3">${item['contact_name']}</td>
|
||||
<td class="py-3">${item['organization']}</td>
|
||||
<td class="py-3">${item['branch']}</td>
|
||||
<td class="py-3">${item['billing_address']}</td>
|
||||
<td class="py-3">${item['shipping_address']}</td>
|
||||
<td class="py-3">
|
||||
${(item['status'])?'<span class="badge bg-success">Active</span>':'<span class="badge bg-secondary">Inactive</span>'}
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<a href="<?php echo $server_url?>contact/manage_contact.php?id=${item['id']}" class=""><i class="ti ti-eye fs-5"></i></a>
|
||||
<a href="javascript:;" class="link-danger" onclick="remove_contact(${item['id']},$(this))"><i class="ti ti-trash ms-2 fs-5"></i></a>
|
||||
</td>
|
||||
</tr>`;
|
||||
});
|
||||
|
||||
$("table#contact > tbody").html(body);
|
||||
|
||||
}
|
||||
|
||||
|
||||
function remove_contact_type(id,element) {
|
||||
// ── Stats ────────────────────────────────────────────────────────────
|
||||
function retrieve_stats() {
|
||||
|
||||
return ajax_request({
|
||||
url: "<?php echo $server_url?>contact/api/engine/contact_stats.php",
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
action: 'read',
|
||||
debugMode: 0,
|
||||
onSuccess: function(res) {
|
||||
$("#stat_total_category").text(res.total_category);
|
||||
$("#stat_active_category").text(res.active_category);
|
||||
$("#stat_inactive_category").text(res.inactive_category);
|
||||
$("#stat_total_contact").text(res.total_contact);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ── Remove handlers ──────────────────────────────────────────────────
|
||||
function remove_contact_type(id, element) {
|
||||
|
||||
return ajax_request({
|
||||
url: "<?php echo $server_url?>contact/api/engine/remove_contact_type.php",
|
||||
@@ -353,16 +388,13 @@
|
||||
},
|
||||
action: 'delete',
|
||||
onSuccess: function(res) {
|
||||
|
||||
element.parent().parent().remove();
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
function remove_contact(id,element) {
|
||||
function remove_contact(id, element) {
|
||||
|
||||
return ajax_request({
|
||||
url: "<?php echo $server_url?>contact/api/engine/remove_contact.php",
|
||||
@@ -374,16 +406,40 @@
|
||||
},
|
||||
action: 'delete',
|
||||
onSuccess: function(res) {
|
||||
|
||||
element.parent().parent().remove();
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
// ── Search: debounce + re-render ─────────────────────────────────────
|
||||
function debounce(fn, ms) {
|
||||
var t;
|
||||
return function() {
|
||||
var args = arguments,
|
||||
ctx = this;
|
||||
clearTimeout(t);
|
||||
t = setTimeout(function() {
|
||||
fn.apply(ctx, args);
|
||||
}, ms);
|
||||
};
|
||||
}
|
||||
|
||||
$('#search_contact_type').on('input', debounce(function() {
|
||||
search_term_contact_type = $(this).val().trim();
|
||||
change_page_contact_type(1); // reset to page 1 on new search
|
||||
}, 200));
|
||||
|
||||
$('#search_contact').on('input', debounce(function() {
|
||||
search_term_contact = $(this).val().trim();
|
||||
change_page_contact(1);
|
||||
}, 200));
|
||||
|
||||
|
||||
// ── Boot ─────────────────────────────────────────────────────────────
|
||||
$(async function() {
|
||||
try {
|
||||
await retrieve_stats();
|
||||
await retrieve_contact_type();
|
||||
await retrieve_contact();
|
||||
} catch (e) {
|
||||
@@ -391,20 +447,19 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Remember last active tab across page reloads
|
||||
$(document).ready(function() {
|
||||
// switching to nav-link from last leave
|
||||
$('button.nav-link').on('click', function() {
|
||||
nav_id = $(this).attr('id');
|
||||
var nav_id = $(this).attr('id');
|
||||
sessionStorage.setItem("contact_nav_id", nav_id);
|
||||
})
|
||||
let nav_id = sessionStorage.getItem("contact_nav_id");
|
||||
if(nav_id){
|
||||
});
|
||||
var nav_id = sessionStorage.getItem("contact_nav_id");
|
||||
if (nav_id) {
|
||||
$(`button#${nav_id}`).trigger('click');
|
||||
}
|
||||
})
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
session_start();
|
||||
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require '../../../assets/utils/classes/Reports.php';
|
||||
|
||||
$report = new Reports($pdo2, $company_id);
|
||||
|
||||
$items = $report->getLowStockItems();
|
||||
|
||||
$critical = 0;
|
||||
$warning = 0;
|
||||
foreach ($items as $item) {
|
||||
if ($item['status'] === 'critical') { $critical++; } else { $warning++; }
|
||||
}
|
||||
|
||||
$answer['output'] = [
|
||||
'items' => $items,
|
||||
'total_low' => count($items),
|
||||
'total_critical' => $critical,
|
||||
'total_warning' => $warning,
|
||||
];
|
||||
$answer['success'] = 1;
|
||||
exit(json_encode($answer));
|
||||
?>
|
||||
@@ -0,0 +1,311 @@
|
||||
<?php
|
||||
session_start();
|
||||
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 (derived from data)
|
||||
var warehouses = {};
|
||||
$.each(all_items, function(i, item) {
|
||||
warehouses[item.warehouse_id] = item.warehouse_name;
|
||||
});
|
||||
var wh_options = '<option value="">All Warehouses</option>';
|
||||
$.each(warehouses, function(id, name) {
|
||||
wh_options += `<option value="${id}">${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}</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>
|
||||
@@ -42,25 +42,45 @@
|
||||
|
||||
} else {
|
||||
|
||||
// Upsert md_lot if lot_number + expiry_date provided
|
||||
$lot_number = $data["lot_number"] ?: null;
|
||||
$expiry_date = $data["expiry_date"] ?: null;
|
||||
|
||||
if ($lot_number && $expiry_date) {
|
||||
$sth = $pdo->prepare("
|
||||
INSERT INTO md_lot (company_id, product_sku, lot_number, expiry_date)
|
||||
VALUES (:company_id, :product_sku, :lot_number, :expiry_date)
|
||||
ON DUPLICATE KEY UPDATE expiry_date = expiry_date
|
||||
");
|
||||
$sth->execute([
|
||||
":company_id" => $company_id,
|
||||
":product_sku" => $data["product_sku"],
|
||||
":lot_number" => $lot_number,
|
||||
":expiry_date" => $expiry_date,
|
||||
]);
|
||||
}
|
||||
|
||||
// INSERT new stock_in record
|
||||
$sql = "INSERT INTO `$table`
|
||||
(uuid, company_id, `date`, product_sku, `in`, zone, aisle, rack, contact_id, `description`, `log`, `type`)
|
||||
(uuid, company_id, `date`, product_sku, `in`, zone, aisle, rack, contact_id, `description`, `log`, `type`, `lot_number`, `serial_number`)
|
||||
VALUES
|
||||
(:uuid, :company_id, :date, :product_sku, :quantity, :zone, :aisle, :rack, :contact_id, :description, :log, 'in')";
|
||||
(:uuid, :company_id, :date, :product_sku, :quantity, :zone, :aisle, :rack, :contact_id, :description, :log, 'in', :lot_number, :serial_number)";
|
||||
|
||||
$sth = $pdo->prepare($sql);
|
||||
$sth->execute([
|
||||
":uuid" => $uuid,
|
||||
":company_id" => $company_id,
|
||||
":date" => date('Y-m-d H:i:s'),
|
||||
":product_sku" => $data["product_sku"],
|
||||
":quantity" => $data["quantity"],
|
||||
":zone" => $data["zone"],
|
||||
":aisle" => $data["aisle"],
|
||||
":rack" => $data["rack"],
|
||||
":contact_id" => (int)$data["contact_id"],
|
||||
":description" => $data["description"],
|
||||
":log" => json_encode($table_log),
|
||||
":uuid" => $uuid,
|
||||
":company_id" => $company_id,
|
||||
":date" => date('Y-m-d H:i:s'),
|
||||
":product_sku" => $data["product_sku"],
|
||||
":quantity" => $data["quantity"],
|
||||
":zone" => $data["zone"],
|
||||
":aisle" => $data["aisle"],
|
||||
":rack" => $data["rack"],
|
||||
":contact_id" => (int)$data["contact_id"],
|
||||
":description" => $data["description"],
|
||||
":log" => json_encode($table_log),
|
||||
":lot_number" => $lot_number,
|
||||
":serial_number" => $data["serial_number"] ?: null,
|
||||
]);
|
||||
|
||||
// Grab the newly-generated ID to link the rack
|
||||
|
||||
@@ -54,41 +54,60 @@
|
||||
);
|
||||
}
|
||||
|
||||
// Defensive check — UI cascade should already prevent mismatches
|
||||
// Defensive checks — UI cascade should already prevent mismatches
|
||||
if ($source_stock['product_sku'] !== $data["product_sku"]) {
|
||||
throw new Exception(
|
||||
"Rack holds {$source_stock['product_sku']}, not {$data['product_sku']}."
|
||||
);
|
||||
}
|
||||
|
||||
// If user selected a specific lot, verify it matches the rack's stock
|
||||
if (!empty($data["lot_number"]) && $source_stock['lot_number'] !== $data["lot_number"]) {
|
||||
throw new Exception(
|
||||
"Rack holds lot '{$source_stock['lot_number']}', not '{$data['lot_number']}'."
|
||||
);
|
||||
}
|
||||
|
||||
// If user selected a specific serial, verify it matches the rack's stock
|
||||
if (!empty($data["serial_number"]) && $source_stock['serial_number'] !== $data["serial_number"]) {
|
||||
throw new Exception(
|
||||
"Rack holds serial '{$source_stock['serial_number']}', not '{$data['serial_number']}'."
|
||||
);
|
||||
}
|
||||
|
||||
// Quantity comes from the source stock_in row, NOT user input.
|
||||
// Under 1:1, taking from this rack = taking the full batch.
|
||||
$quantity = (int) $source_stock['in'];
|
||||
$ref_id = (int) $source_stock['id'];
|
||||
$quantity = (int) $source_stock['in'];
|
||||
$ref_id = (int) $source_stock['id'];
|
||||
$lot_number = $source_stock['lot_number'] ?? null;
|
||||
$serial_number = $source_stock['serial_number'] ?? null;
|
||||
|
||||
// INSERT new stock_out record.
|
||||
// lot_number and serial_number carried from source stock_in row.
|
||||
// ref_id links this stock_out row back to the stock_in batch it consumed.
|
||||
$sql = "INSERT INTO `$table`
|
||||
(uuid, company_id, `date`, product_sku, `out`, zone, aisle, rack,
|
||||
contact_id, `description`, `log`, `type`, ref_id)
|
||||
contact_id, `description`, `log`, `type`, ref_id, lot_number, serial_number)
|
||||
VALUES
|
||||
(:uuid, :company_id, :date, :product_sku, :quantity, :zone, :aisle, :rack,
|
||||
:contact_id, :description, :log, 'out', :ref_id)";
|
||||
:contact_id, :description, :log, 'out', :ref_id, :lot_number, :serial_number)";
|
||||
|
||||
$sth = $pdo->prepare($sql);
|
||||
$sth->execute([
|
||||
":uuid" => $uuid,
|
||||
":company_id" => $company_id,
|
||||
":date" => date('Y-m-d H:i:s'),
|
||||
":product_sku" => $data["product_sku"],
|
||||
":quantity" => $quantity,
|
||||
":zone" => $data["zone"],
|
||||
":aisle" => $data["aisle"],
|
||||
":rack" => $data["rack"],
|
||||
":contact_id" => (int)$data["contact_id"],
|
||||
":description" => $data["description"],
|
||||
":log" => json_encode($table_log),
|
||||
":ref_id" => $ref_id,
|
||||
":uuid" => $uuid,
|
||||
":company_id" => $company_id,
|
||||
":date" => date('Y-m-d H:i:s'),
|
||||
":product_sku" => $data["product_sku"],
|
||||
":quantity" => $quantity,
|
||||
":zone" => $data["zone"],
|
||||
":aisle" => $data["aisle"],
|
||||
":rack" => $data["rack"],
|
||||
":contact_id" => (int)$data["contact_id"],
|
||||
":description" => $data["description"],
|
||||
":log" => json_encode($table_log),
|
||||
":ref_id" => $ref_id,
|
||||
":lot_number" => $lot_number,
|
||||
":serial_number" => $serial_number,
|
||||
]);
|
||||
|
||||
// Release the rack — stock is now gone from this location.
|
||||
|
||||
@@ -117,7 +117,21 @@
|
||||
);
|
||||
}
|
||||
|
||||
$quantity = (int) $source_stock['in'];
|
||||
// Defensive lot/serial checks
|
||||
if (!empty($data["lot_number"]) && $source_stock['lot_number'] !== $data["lot_number"]) {
|
||||
throw new Exception(
|
||||
"Source rack holds lot '{$source_stock['lot_number']}', not '{$data['lot_number']}'."
|
||||
);
|
||||
}
|
||||
if (!empty($data["serial_number"]) && $source_stock['serial_number'] !== $data["serial_number"]) {
|
||||
throw new Exception(
|
||||
"Source rack holds serial '{$source_stock['serial_number']}', not '{$data['serial_number']}'."
|
||||
);
|
||||
}
|
||||
|
||||
$quantity = (int) $source_stock['in'];
|
||||
$lot_number = $source_stock['lot_number'] ?? null;
|
||||
$serial_number = $source_stock['serial_number'] ?? null;
|
||||
|
||||
// Resolve both td_stock_<wh> tables
|
||||
$from_ctx = $whMgmt->getStockContext($from_warehouse, 0);
|
||||
@@ -134,11 +148,11 @@
|
||||
$sql = "INSERT INTO `$from_table`
|
||||
(uuid, company_id, `date`, product_sku, `out`,
|
||||
ref_warehouse, zone, aisle, rack,
|
||||
contact_id, `description`, `log`, `type`)
|
||||
contact_id, `description`, `log`, `type`, lot_number, serial_number)
|
||||
VALUES
|
||||
(:uuid, :company_id, :date, :product_sku, :quantity,
|
||||
:ref_warehouse, :zone, :aisle, :rack,
|
||||
:contact_id, :description, :log, 'transfer')";
|
||||
:contact_id, :description, :log, 'transfer', :lot_number, :serial_number)";
|
||||
$pdo->prepare($sql)->execute([
|
||||
":uuid" => $transfer_uuid,
|
||||
":company_id" => $company_id,
|
||||
@@ -152,6 +166,8 @@
|
||||
":contact_id" => $contact_id,
|
||||
":description" => $description,
|
||||
":log" => json_encode($table_log),
|
||||
":lot_number" => $lot_number,
|
||||
":serial_number" => $serial_number,
|
||||
]);
|
||||
$from_stock_id = (int) $pdo->lastInsertId();
|
||||
|
||||
@@ -159,11 +175,11 @@
|
||||
$sql = "INSERT INTO `$to_table`
|
||||
(uuid, company_id, `date`, product_sku, `in`,
|
||||
ref_warehouse, ref_id, zone, aisle, rack,
|
||||
contact_id, `description`, `log`, `type`)
|
||||
contact_id, `description`, `log`, `type`, lot_number, serial_number)
|
||||
VALUES
|
||||
(:uuid, :company_id, :date, :product_sku, :quantity,
|
||||
:ref_warehouse, :ref_id, :zone, :aisle, :rack,
|
||||
:contact_id, :description, :log, 'transfer')";
|
||||
:contact_id, :description, :log, 'transfer', :lot_number, :serial_number)";
|
||||
$pdo->prepare($sql)->execute([
|
||||
":uuid" => $transfer_uuid,
|
||||
":company_id" => $company_id,
|
||||
@@ -178,6 +194,8 @@
|
||||
":contact_id" => $contact_id,
|
||||
":description" => $description,
|
||||
":log" => json_encode($table_log),
|
||||
":lot_number" => $lot_number,
|
||||
":serial_number" => $serial_number,
|
||||
]);
|
||||
$to_stock_id = (int) $pdo->lastInsertId();
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require '../../../assets/utils/classes/WarehouseManager.php';
|
||||
|
||||
// Returns active lots for a given product_sku
|
||||
// "Active" = EXISTS in an occupied md_rack (product_sku IS NOT NULL)
|
||||
$product_sku = $data['product_sku'] ?? '';
|
||||
|
||||
if (empty($product_sku)) {
|
||||
$answer['output'] = [];
|
||||
$answer['success'] = 1;
|
||||
exit(json_encode($answer));
|
||||
}
|
||||
|
||||
// Discover all warehouses
|
||||
$sth = $pdo2->prepare(
|
||||
"SELECT id, warehouse_name FROM md_warehouse
|
||||
WHERE company_id = :company_id AND status = 1"
|
||||
);
|
||||
$sth->execute([':company_id' => $company_id]);
|
||||
$warehouses = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$active_lots = [];
|
||||
|
||||
foreach ($warehouses as $wh) {
|
||||
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $wh['warehouse_name']);
|
||||
$table = "td_stock_{$safe}";
|
||||
|
||||
$sth = $pdo2->prepare(
|
||||
"SELECT DISTINCT s.lot_number, l.expiry_date
|
||||
FROM `{$table}` s
|
||||
LEFT JOIN md_lot l
|
||||
ON l.company_id = s.company_id
|
||||
AND l.product_sku = s.product_sku
|
||||
AND l.lot_number = s.lot_number
|
||||
WHERE s.company_id = :company_id
|
||||
AND s.product_sku = :product_sku
|
||||
AND s.type = 'in'
|
||||
AND s.lot_number IS NOT NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM md_rack r
|
||||
WHERE r.company_id = s.company_id
|
||||
AND r.td_stock_id = s.id
|
||||
AND r.product_sku IS NOT NULL
|
||||
)"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $company_id,
|
||||
':product_sku' => $product_sku,
|
||||
]);
|
||||
|
||||
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$key = $row['lot_number'];
|
||||
if (!isset($active_lots[$key])) {
|
||||
$active_lots[$key] = $row;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$answer['output'] = array_values($active_lots);
|
||||
$answer['success'] = 1;
|
||||
exit(json_encode($answer));
|
||||
?>
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
|
||||
// Returns active serials for a given product_sku + optional lot_number
|
||||
// "Active" = EXISTS in an occupied md_rack
|
||||
$product_sku = $data['product_sku'] ?? '';
|
||||
$lot_number = $data['lot_number'] ?? '';
|
||||
|
||||
if (empty($product_sku)) {
|
||||
$answer['output'] = [];
|
||||
$answer['success'] = 1;
|
||||
exit(json_encode($answer));
|
||||
}
|
||||
|
||||
// Discover all warehouses
|
||||
$sth = $pdo2->prepare(
|
||||
"SELECT id, warehouse_name FROM md_warehouse
|
||||
WHERE company_id = :company_id AND status = 1"
|
||||
);
|
||||
$sth->execute([':company_id' => $company_id]);
|
||||
$warehouses = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$active_serials = [];
|
||||
|
||||
foreach ($warehouses as $wh) {
|
||||
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $wh['warehouse_name']);
|
||||
$table = "td_stock_{$safe}";
|
||||
|
||||
$sql = "SELECT DISTINCT s.serial_number
|
||||
FROM `{$table}` s
|
||||
WHERE s.company_id = :company_id
|
||||
AND s.product_sku = :product_sku
|
||||
AND s.type = 'in'
|
||||
AND s.serial_number IS NOT NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM md_rack r
|
||||
WHERE r.company_id = s.company_id
|
||||
AND r.td_stock_id = s.id
|
||||
AND r.product_sku IS NOT NULL
|
||||
)";
|
||||
|
||||
$params = [
|
||||
':company_id' => $company_id,
|
||||
':product_sku' => $product_sku,
|
||||
];
|
||||
|
||||
if (!empty($lot_number)) {
|
||||
$sql .= " AND s.lot_number = :lot_number";
|
||||
$params[':lot_number'] = $lot_number;
|
||||
}
|
||||
|
||||
$sth = $pdo2->prepare($sql);
|
||||
$sth->execute($params);
|
||||
|
||||
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$active_serials[$row['serial_number']] = $row['serial_number'];
|
||||
}
|
||||
}
|
||||
|
||||
$answer['output'] = array_values($active_serials);
|
||||
$answer['success'] = 1;
|
||||
exit(json_encode($answer));
|
||||
?>
|
||||
@@ -1,64 +1,51 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require '../../../assets/utils/classes/WarehouseManager.php';
|
||||
|
||||
// Get warehouse and zone from the form
|
||||
$warehouse = isset($data['warehouse']) ? (int)$data['warehouse'] : 0;
|
||||
$zone = isset($data['zone']) ? $data['zone'] : '';
|
||||
$type = $data['type'] ?? '';
|
||||
$whMgmt = new WarehouseManager($pdo2, $company_id);
|
||||
|
||||
if (isset($data['warehouse_from']) && $data['type'] == "from") {
|
||||
$warehouse = (int)$data['warehouse_from'];
|
||||
$zone = $data['zone_from'];
|
||||
}
|
||||
if (isset($data['warehouse_to']) && $data['type'] == "to") {
|
||||
$warehouse = (int)$data['warehouse_to'];
|
||||
$zone = $data['zone_to'];
|
||||
// Resolve warehouse and zone
|
||||
$warehouse = (int)($data['warehouse'] ?? 0);
|
||||
$zone = $data['zone'] ?? '';
|
||||
if (isset($data['warehouse_from']) && $type === 'from') { $warehouse = (int)$data['warehouse_from']; $zone = $data['zone_from'] ?? $zone; }
|
||||
if (isset($data['warehouse_to']) && $type === 'to') { $warehouse = (int)$data['warehouse_to']; $zone = $data['zone_to'] ?? $zone; }
|
||||
|
||||
if ($type === 'to') {
|
||||
$output = $whMgmt->getAislesIn($warehouse, $zone);
|
||||
|
||||
} elseif ($type === 'from') {
|
||||
$output = $whMgmt->getAislesOut(
|
||||
$warehouse,
|
||||
$zone,
|
||||
$data['product_sku'] ?? '',
|
||||
$data['lot_number'] ?? null,
|
||||
$data['serial_number'] ?? null
|
||||
);
|
||||
|
||||
} else {
|
||||
$sth = $pdo2->prepare(
|
||||
"SELECT DISTINCT aisle FROM md_rack
|
||||
WHERE company_id = :company_id AND warehouse = :warehouse AND zone = :zone
|
||||
ORDER BY CAST(aisle AS UNSIGNED), aisle"
|
||||
);
|
||||
$sth->execute([':company_id' => $company_id, ':warehouse' => $warehouse, ':zone' => $zone]);
|
||||
$output = array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'aisle');
|
||||
}
|
||||
|
||||
// Filter aisles by operation type and optional SKU:
|
||||
// 'to' = destination → aisles with at least one empty rack
|
||||
// 'from' + sku → aisles currently holding that specific SKU
|
||||
// 'from' (no sku) → aisles with any occupied rack
|
||||
// other = all aisles in the zone (admin / read-only pages)
|
||||
$product_sku_filter = "";
|
||||
$params = [
|
||||
":company_id" => $company_id,
|
||||
":warehouse" => $warehouse,
|
||||
":zone" => $zone,
|
||||
];
|
||||
|
||||
if ($data["type"] == "to") {
|
||||
$product_sku_filter = "AND product_sku IS NULL";
|
||||
|
||||
} elseif ($data["type"] == "from") {
|
||||
if (!empty($data["product_sku"])) {
|
||||
$product_sku_filter = "AND product_sku = :product_sku";
|
||||
$params[":product_sku"] = $data["product_sku"];
|
||||
} else {
|
||||
$product_sku_filter = "AND product_sku IS NOT NULL";
|
||||
}
|
||||
// Edit mode
|
||||
if (!empty($data['id'])) {
|
||||
$sth = $pdo2->prepare(
|
||||
"SELECT DISTINCT aisle FROM md_rack
|
||||
WHERE company_id = :company_id AND warehouse = :warehouse AND zone = :zone
|
||||
ORDER BY CAST(aisle AS UNSIGNED), aisle"
|
||||
);
|
||||
$sth->execute([':company_id' => $company_id, ':warehouse' => $warehouse, ':zone' => $zone]);
|
||||
$output = array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'aisle');
|
||||
}
|
||||
|
||||
if(!empty($data["id"])){
|
||||
$product_sku_filter = "";
|
||||
unset($params[":product_sku"]);
|
||||
}
|
||||
|
||||
$sql = "SELECT DISTINCT aisle
|
||||
FROM md_rack
|
||||
WHERE
|
||||
company_id = :company_id AND
|
||||
warehouse = :warehouse AND
|
||||
`zone` = :zone
|
||||
$product_sku_filter
|
||||
ORDER BY CAST(aisle AS UNSIGNED), aisle";
|
||||
|
||||
$sth = $pdo2->prepare($sql);
|
||||
$sth->execute($params);
|
||||
db_check($sth, $answer);
|
||||
|
||||
// Return flat array (matches original response shape for the frontend)
|
||||
$answer["output"] = array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'aisle');
|
||||
$answer["success"] = 1;
|
||||
$answer['output'] = $output;
|
||||
$answer['success'] = 1;
|
||||
exit(json_encode($answer));
|
||||
?>
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
session_start();
|
||||
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
|
||||
$sql = "SELECT *
|
||||
FROM md_lot
|
||||
WHERE
|
||||
company_id = :company_id AND
|
||||
product_sku = :product_sku AND
|
||||
lot_number LIKE :keyword
|
||||
ORDER BY lot_number ASC
|
||||
LIMIT 50";
|
||||
|
||||
$sth = $pdo2->prepare($sql);
|
||||
$sth->execute([
|
||||
":company_id" => $company_id,
|
||||
":product_sku" => $data["product_sku"] ?? '',
|
||||
":keyword" => '%'.$data["keyword"].'%'
|
||||
]);
|
||||
db_check($sth, $answer);
|
||||
|
||||
$answer["result"] = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
$answer["success"] = 1;
|
||||
exit(json_encode($answer));
|
||||
?>
|
||||
@@ -1,69 +1,53 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require '../../../assets/utils/classes/WarehouseManager.php';
|
||||
|
||||
// Get warehouse, zone, and aisle from the form
|
||||
$warehouse = (int)$data['warehouse'];
|
||||
$zone = $data['zone'];
|
||||
$aisle = $data['aisle'];
|
||||
$type = $data['type'] ?? '';
|
||||
$whMgmt = new WarehouseManager($pdo2, $company_id);
|
||||
|
||||
if (isset($data['warehouse_from']) && $data['type'] == "from") {
|
||||
$warehouse = (int)$data['warehouse_from'];
|
||||
$zone = $data['zone_from'];
|
||||
$aisle = $data['aisle_from'];
|
||||
}
|
||||
if (isset($data['warehouse_to']) && $data['type'] == "to") {
|
||||
$warehouse = (int)$data['warehouse_to'];
|
||||
$zone = $data['zone_to'];
|
||||
$aisle = $data['aisle_to'];
|
||||
// Resolve warehouse, zone and aisle
|
||||
$warehouse = (int)($data['warehouse'] ?? 0);
|
||||
$zone = $data['zone'] ?? '';
|
||||
$aisle = $data['aisle'] ?? '';
|
||||
if (isset($data['warehouse_from']) && $type === 'from') { $warehouse = (int)$data['warehouse_from']; $zone = $data['zone_from'] ?? $zone; $aisle = $data['aisle_from'] ?? $aisle; }
|
||||
if (isset($data['warehouse_to']) && $type === 'to') { $warehouse = (int)$data['warehouse_to']; $zone = $data['zone_to'] ?? $zone; $aisle = $data['aisle_to'] ?? $aisle; }
|
||||
|
||||
if ($type === 'to') {
|
||||
$output = $whMgmt->getRacksIn($warehouse, $zone, $aisle);
|
||||
|
||||
} elseif ($type === 'from') {
|
||||
$output = $whMgmt->getRacksOut(
|
||||
$warehouse,
|
||||
$zone,
|
||||
$aisle,
|
||||
$data['product_sku'] ?? '',
|
||||
$data['lot_number'] ?? null,
|
||||
$data['serial_number'] ?? null
|
||||
);
|
||||
|
||||
} else {
|
||||
$sth = $pdo2->prepare(
|
||||
"SELECT DISTINCT rack FROM md_rack
|
||||
WHERE company_id = :company_id AND warehouse = :warehouse AND zone = :zone AND aisle = :aisle
|
||||
ORDER BY CAST(rack AS UNSIGNED), rack"
|
||||
);
|
||||
$sth->execute([':company_id' => $company_id, ':warehouse' => $warehouse, ':zone' => $zone, ':aisle' => $aisle]);
|
||||
$output = array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'rack');
|
||||
}
|
||||
|
||||
// Filter racks by operation type and optional SKU:
|
||||
// 'to' = destination → empty racks only (product_sku IS NULL)
|
||||
// 'from' + sku → racks currently holding that specific SKU
|
||||
// 'from' (no sku) → any occupied rack (product_sku IS NOT NULL)
|
||||
// other = all racks in the aisle (admin / read-only pages)
|
||||
$product_sku_filter = "";
|
||||
$params = [
|
||||
":company_id" => $company_id,
|
||||
":warehouse" => $warehouse,
|
||||
":zone" => $zone,
|
||||
":aisle" => $aisle,
|
||||
];
|
||||
|
||||
if ($data["type"] == "to") {
|
||||
$product_sku_filter = "AND product_sku IS NULL";
|
||||
|
||||
} elseif ($data["type"] == "from") {
|
||||
if (!empty($data["product_sku"])) {
|
||||
$product_sku_filter = "AND product_sku = :product_sku";
|
||||
$params[":product_sku"] = $data["product_sku"];
|
||||
} else {
|
||||
$product_sku_filter = "AND product_sku IS NOT NULL";
|
||||
}
|
||||
// Edit mode
|
||||
if (!empty($data['id'])) {
|
||||
$sth = $pdo2->prepare(
|
||||
"SELECT DISTINCT rack FROM md_rack
|
||||
WHERE company_id = :company_id AND warehouse = :warehouse AND zone = :zone AND aisle = :aisle
|
||||
ORDER BY CAST(rack AS UNSIGNED), rack"
|
||||
);
|
||||
$sth->execute([':company_id' => $company_id, ':warehouse' => $warehouse, ':zone' => $zone, ':aisle' => $aisle]);
|
||||
$output = array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'rack');
|
||||
}
|
||||
|
||||
if(!empty($data["id"])){
|
||||
$product_sku_filter = "";
|
||||
unset($params[":product_sku"]);
|
||||
}
|
||||
|
||||
$sql = "SELECT DISTINCT rack
|
||||
FROM md_rack
|
||||
WHERE
|
||||
company_id = :company_id AND
|
||||
warehouse = :warehouse AND
|
||||
`zone` = :zone AND
|
||||
aisle = :aisle
|
||||
$product_sku_filter
|
||||
ORDER BY CAST(rack AS UNSIGNED), rack";
|
||||
|
||||
$sth = $pdo2->prepare($sql);
|
||||
$sth->execute($params);
|
||||
db_check($sth, $answer);
|
||||
|
||||
// Return flat array (matches original response shape for the frontend)
|
||||
$answer["output"] = array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'rack');
|
||||
$answer["success"] = 1;
|
||||
$answer['output'] = $output;
|
||||
$answer['success'] = 1;
|
||||
exit(json_encode($answer));
|
||||
?>
|
||||
@@ -25,7 +25,7 @@
|
||||
|
||||
$table = "td_stock_" . $warehouse;
|
||||
|
||||
$sql = "SELECT a.* , a.in as quantity, b.contact_name, c.product_name
|
||||
$sql = "SELECT a.* , a.in as quantity, b.contact_name, c.product_name, d.expiry_date
|
||||
FROM `{$table}` a
|
||||
LEFT JOIN md_contact b ON
|
||||
a.company_id = b.company_id and
|
||||
@@ -33,6 +33,10 @@
|
||||
LEFT JOIN md_product c ON
|
||||
a.company_id = c.company_id and
|
||||
a.product_sku = c.sku
|
||||
LEFT JOIN md_lot d ON
|
||||
a.company_id = d.company_id and
|
||||
a.product_sku = d.product_sku and
|
||||
a.lot_number = d.lot_number
|
||||
WHERE
|
||||
a.company_id = :company_id and
|
||||
a.id = :id ";
|
||||
|
||||
@@ -1,59 +1,52 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require '../../../assets/utils/classes/WarehouseManager.php';
|
||||
|
||||
// Get warehouse from the form
|
||||
$warehouse = (int)$data['warehouse'];
|
||||
$type = $data['type'] ?? '';
|
||||
$whMgmt = new WarehouseManager($pdo2, $company_id);
|
||||
|
||||
if(isset($data['warehouse_from'])&&$data['type']=="from"){
|
||||
$warehouse = (int)$data['warehouse_from'];
|
||||
}
|
||||
if(isset($data['warehouse_to'])&&$data['type']=="to"){
|
||||
$warehouse = (int)$data['warehouse_to'];
|
||||
// Resolve warehouse — supports warehouse, warehouse_from, warehouse_to
|
||||
$warehouse = (int)($data['warehouse'] ?? 0);
|
||||
if (isset($data['warehouse_from']) && $type === 'from') $warehouse = (int)$data['warehouse_from'];
|
||||
if (isset($data['warehouse_to']) && $type === 'to') $warehouse = (int)$data['warehouse_to'];
|
||||
|
||||
if ($type === 'to') {
|
||||
// Stock_in destination — empty racks only
|
||||
$output = $whMgmt->getZonesIn($warehouse);
|
||||
|
||||
} elseif ($type === 'from') {
|
||||
// Stock_out source — filter by sku + lot + serial
|
||||
$output = $whMgmt->getZonesOut(
|
||||
$warehouse,
|
||||
$data['product_sku'] ?? '',
|
||||
$data['lot_number'] ?? null,
|
||||
$data['serial_number'] ?? null
|
||||
);
|
||||
|
||||
} else {
|
||||
// Admin / read-only — all zones in the warehouse
|
||||
$sth = $pdo2->prepare(
|
||||
"SELECT DISTINCT zone FROM md_rack
|
||||
WHERE company_id = :company_id AND warehouse = :warehouse
|
||||
ORDER BY CAST(zone AS UNSIGNED), zone"
|
||||
);
|
||||
$sth->execute([':company_id' => $company_id, ':warehouse' => $warehouse]);
|
||||
$output = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
// Filter zones by operation type and optional SKU:
|
||||
// 'to' = destination → zones with at least one empty rack
|
||||
// 'from' + sku → zones currently holding that specific SKU
|
||||
// 'from' (no sku) → zones with any occupied rack
|
||||
// other = all zones in the warehouse (admin / read-only pages)
|
||||
$product_sku_filter = "";
|
||||
$params = [
|
||||
":company_id" => $company_id,
|
||||
":warehouse" => $warehouse,
|
||||
];
|
||||
|
||||
if ($data["type"] == "to") {
|
||||
$product_sku_filter = "AND product_sku IS NULL";
|
||||
|
||||
} elseif ($data["type"] == "from") {
|
||||
if (!empty($data["product_sku"])) {
|
||||
$product_sku_filter = "AND product_sku = :product_sku";
|
||||
$params[":product_sku"] = $data["product_sku"];
|
||||
} else {
|
||||
$product_sku_filter = "AND product_sku IS NOT NULL";
|
||||
}
|
||||
// Edit mode — return all zones regardless of occupancy
|
||||
if (!empty($data['id'])) {
|
||||
$sth = $pdo2->prepare(
|
||||
"SELECT DISTINCT zone FROM md_rack
|
||||
WHERE company_id = :company_id AND warehouse = :warehouse
|
||||
ORDER BY CAST(zone AS UNSIGNED), zone"
|
||||
);
|
||||
$sth->execute([':company_id' => $company_id, ':warehouse' => $warehouse]);
|
||||
$output = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
if(!empty($data["id"])){
|
||||
$product_sku_filter = "";
|
||||
unset($params[":product_sku"]);
|
||||
}
|
||||
|
||||
$sql = "SELECT DISTINCT zone
|
||||
FROM md_rack
|
||||
WHERE
|
||||
company_id = :company_id AND
|
||||
warehouse = :warehouse
|
||||
$product_sku_filter
|
||||
ORDER BY CAST(zone AS UNSIGNED), zone";
|
||||
|
||||
$sth = $pdo2->prepare($sql);
|
||||
$sth->execute($params);
|
||||
db_check($sth, $answer);
|
||||
|
||||
// Frontend expects objects with a `zone` property — keep that shape.
|
||||
$answer["output"] = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
$answer["success"] = 1;
|
||||
$answer['output'] = $output;
|
||||
$answer['success'] = 1;
|
||||
exit(json_encode($answer));
|
||||
?>
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
session_start();
|
||||
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require '../../../assets/utils/classes/Reports.php';
|
||||
|
||||
$report = new Reports($pdo2, $company_id);
|
||||
|
||||
$items = $report->getExpiredStock();
|
||||
|
||||
$expired = 0;
|
||||
$critical = 0;
|
||||
$warning = 0;
|
||||
$caution = 0;
|
||||
|
||||
foreach ($items as $item) {
|
||||
if ($item['status'] === 'expired') { $expired++; }
|
||||
if ($item['status'] === 'critical') { $critical++; }
|
||||
if ($item['status'] === 'warning') { $warning++; }
|
||||
if ($item['status'] === 'caution') { $caution++; }
|
||||
}
|
||||
|
||||
$answer['output'] = [
|
||||
'items' => $items,
|
||||
'expired' => $expired,
|
||||
'critical' => $critical,
|
||||
'warning' => $warning,
|
||||
'caution' => $caution,
|
||||
];
|
||||
$answer['success'] = 1;
|
||||
exit(json_encode($answer));
|
||||
?>
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
session_start();
|
||||
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require '../../../assets/utils/classes/Reports.php';
|
||||
|
||||
$warehouse_id = (int)($data['warehouse'] ?? 0);
|
||||
|
||||
if (!$warehouse_id) {
|
||||
$answer['success'] = 0;
|
||||
$answer['message'] = 'Warehouse is required';
|
||||
exit(json_encode($answer));
|
||||
}
|
||||
|
||||
$report = new Reports($pdo2, $company_id);
|
||||
$balance = $report->getWarehouseBalance($warehouse_id);
|
||||
|
||||
$answer['output'] = [
|
||||
'total_capacity' => $report->getWarehouseCapacity($warehouse_id),
|
||||
'space_used' => $report->getWarehouseSpaceUsed($warehouse_id),
|
||||
'total_in' => $balance['total_in'],
|
||||
'total_out' => $balance['total_out'],
|
||||
'low_stock' => $report->getWarehouseLowStockCount($warehouse_id),
|
||||
'stock_movement' => $report->getWarehouseStockMovement($warehouse_id),
|
||||
'stock_trend' => $report->getWarehouseStockTrend($warehouse_id),
|
||||
'activities' => $report->getWarehouseActivity($warehouse_id),
|
||||
];
|
||||
$answer['success'] = 1;
|
||||
exit(json_encode($answer));
|
||||
?>
|
||||
@@ -1,351 +0,0 @@
|
||||
<?php
|
||||
session_start();
|
||||
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">
|
||||
<div class="row ">
|
||||
<div class="col-12">
|
||||
<div class="mb-6 ">
|
||||
<h1 class="fs-3 mb-1">Expired / Near Expiry Products
|
||||
</h1>
|
||||
<p class="mb-0">Monitor and manage products approaching or past expiry dates</p>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-5 g-5">
|
||||
<div class="col-xl-3 col-md-6 col-12">
|
||||
<div class="card bg-primary bg-opacity-10 border-primary border-opacity-25">
|
||||
<div class="card-body p-5">
|
||||
<div class="">
|
||||
|
||||
<span class="icon-shape icon-md bg-primary text-white rounded-2">
|
||||
<i class="ti ti-trash fs-4"></i>
|
||||
</span>
|
||||
</div>
|
||||
<div class="d-flex flex-column mt-3">
|
||||
<span class="fs-2 fw-bold mb-0 d-block">3</span>
|
||||
<span class="fs-5">Expired Products</span>
|
||||
|
||||
<small class="text-muted">
|
||||
Requires immediate action</small>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xl-3 col-md-6 col-12">
|
||||
<div class="card bg-primary bg-opacity-10 border-primary border-opacity-25">
|
||||
<div class="card-body p-5">
|
||||
<div class="">
|
||||
|
||||
<span class="icon-shape icon-md bg-primary text-white rounded-2">
|
||||
<i class="ti ti-alert-triangle fs-4"></i>
|
||||
</span>
|
||||
</div>
|
||||
<div class="d-flex flex-column mt-3">
|
||||
<span class="fs-2 fw-bold mb-0 d-block">2</span>
|
||||
<span class="fs-5">Critical (≤7 days)</span>
|
||||
|
||||
<small class="text-muted">
|
||||
Expiring within a week</small>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
<div class="col-xl-3 col-md-6 col-12">
|
||||
<div class="card bg-primary bg-opacity-10 border-primary border-opacity-25">
|
||||
<div class="card-body p-5">
|
||||
<div class="">
|
||||
<span class="icon-shape icon-md bg-primary text-white rounded-2">
|
||||
<i class="ti ti-clock fs-4"></i>
|
||||
</span>
|
||||
</div>
|
||||
<div class="d-flex flex-column mt-3">
|
||||
<span class="fs-2 fw-bold mb-0 d-block">2</span>
|
||||
<span class="fs-5">Warning (8-14 days)</span>
|
||||
|
||||
<small class="text-muted">
|
||||
Expiring in 2 weeks</small>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="col-xl-3 col-md-6 col-12">
|
||||
<div class="card bg-primary bg-opacity-10 border-primary border-opacity-25">
|
||||
<div class="card-body p-5">
|
||||
<div class="">
|
||||
<span class="icon-shape icon-md bg-primary text-white rounded-2">
|
||||
<i class="ti ti-calendar-clock fs-4"></i>
|
||||
</span>
|
||||
</div>
|
||||
<div class="d-flex flex-column mt-3">
|
||||
<span class="fs-2 fw-bold mb-0 d-block">2</span>
|
||||
<span class="fs-5">Caution (15-30 days)</span>
|
||||
|
||||
<small class="text-muted">
|
||||
Monitor closely</small>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="row g-5 mb-5">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body p-5">
|
||||
<h2 class="fs-4 mb-4">Filter Products</h2>
|
||||
<div class="row g-5">
|
||||
<div class="col-lg-7">
|
||||
<div class="position-relative">
|
||||
<input type="search" class="form-control ps-9" placeholder="Search by product name or SKU">
|
||||
<span class="position-absolute top-25 ms-4">
|
||||
<i class="ti ti-search text-muted"></i>
|
||||
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-5">
|
||||
<div class="d-flex gap-3">
|
||||
<select class="form-select">
|
||||
<option selected>All Status</option>
|
||||
<option value="Expired">Expired</option>
|
||||
<option value="Critical">Critical</option>
|
||||
<option value="Warning">Warning</option>
|
||||
<option value="Caution">Caution</option>
|
||||
</select>
|
||||
<select class="form-select">
|
||||
<option selected>All Categories</option>
|
||||
<option value="Category 1">Category 1</option>
|
||||
<option value="Category 2">Category 2</option>
|
||||
<option value="Category 3">Category 3</option>
|
||||
</select>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="row g-5 mb-5">
|
||||
<div class="col-12">
|
||||
<div class="card ">
|
||||
<div class="card-body p-5">
|
||||
<div>
|
||||
<h2 class="fs-4 mb-2">Expired Products</h2>
|
||||
<p>Products that have passed their expiry date and need to be removed</p>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table mb-0 text-nowrap table-hover table-centered">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th class="py-3">Product Name</th>
|
||||
<th class="py-3">SKU</th>
|
||||
<th class="py-3">Category</th>
|
||||
<th class="py-3">Expiry Date</th>
|
||||
<th class="py-3">Days Overdue</th>
|
||||
<th class="py-3">Quantity</th>
|
||||
<th class="py-3">Status</th>
|
||||
<th class="py-3">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="py-3">Wireless Mouse</td>
|
||||
<td class="py-3">WM-001</td>
|
||||
<td class="py-3">Electronics</td>
|
||||
<td class="py-3">2025-11-15</td>
|
||||
<td class="py-3">47 days</td>
|
||||
<td class="py-3">12</td>
|
||||
<td class="py-3"><span class="badge bg-danger">Expired</span></td>
|
||||
<td class="py-3">
|
||||
<a href="#" class="link-danger"><i class="ti ti-trash ms-2 fs-5"></i></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-3">USB-C Cable</td>
|
||||
<td class="py-3">UC-002</td>
|
||||
<td class="py-3">Accessories</td>
|
||||
<td class="py-3">2025-12-01</td>
|
||||
<td class="py-3">31 days</td>
|
||||
<td class="py-3">8</td>
|
||||
<td class="py-3"><span class="badge bg-danger">Expired</span></td>
|
||||
<td class="py-3">
|
||||
<a href="#" class="link-danger"><i class="ti ti-trash ms-2 fs-5"></i></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-3">Keyboard</td>
|
||||
<td class="py-3">KB-004</td>
|
||||
<td class="py-3">Electronics</td>
|
||||
<td class="py-3">2025-12-20</td>
|
||||
<td class="py-3">12 days</td>
|
||||
<td class="py-3">5</td>
|
||||
<td class="py-3"><span class="badge bg-danger">Expired</span></td>
|
||||
<td class="py-3">
|
||||
<a href="#" class="link-danger"><i class="ti ti-trash ms-2 fs-5"></i></a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
|
||||
</table>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-5">
|
||||
<div class="col-12">
|
||||
<div class="card ">
|
||||
<div class="card-body p-5">
|
||||
<div>
|
||||
<h2 class="fs-4 mb-2">Near Expiry Products</h2>
|
||||
<p>Products approaching their expiry date that may need attention</p>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table mb-0 text-nowrap table-hover table-centered">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th class="py-3">Product Name</th>
|
||||
<th class="py-3">SKU</th>
|
||||
<th class="py-3">Category</th>
|
||||
<th class="py-3">Expiry Date</th>
|
||||
<th class="py-3">Days Remaining</th>
|
||||
<th class="py-3">Quantity</th>
|
||||
<th>Status</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="py-3">Laptop Stand</td>
|
||||
<td class="py-3">LS-003</td>
|
||||
<td class="py-3">Furniture</td>
|
||||
<td class="py-3">2026-01-05</td>
|
||||
<td class="py-3 text-primary">4 days</td>
|
||||
<td class="py-3">15</td>
|
||||
<td class="py-3"><span class="badge bg-primary">Critical (≤7 days)</span></td>
|
||||
<td class="py-3">
|
||||
<a href="#" class="btn btn-light">Mark For Sale</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-3">Monitor</td>
|
||||
<td class="py-3"> MN-005</td>
|
||||
<td class="py-3">Electronics</td>
|
||||
<td class="py-3">2026-01-08</td>
|
||||
<td class="py-3">7 days</td>
|
||||
<td class="py-3">10</td>
|
||||
<td class="py-3"><span class="badge bg-primary">Critical (≤7 days)</span></td>
|
||||
<td class="py-3">
|
||||
<a href="#" class="btn btn-light">Mark For Sale</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-3">Desk Lamp</td>
|
||||
<td class="py-3">DL-006</td>
|
||||
<td class="py-3">Furniture</td>
|
||||
<td class="py-3">2026-01-12</td>
|
||||
<td class="py-3">11 days</td>
|
||||
<td class="py-3">20</td>
|
||||
<td class="py-3"><span class="badge bg-warning">Warning (8-14 days)</span></td>
|
||||
<td class="py-3">
|
||||
<a href="#" class="btn btn-light">Mark For Sale</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-3">Webcam HD</td>
|
||||
<td class="py-3">WC-007</td>
|
||||
<td class="py-3">Electronics</td>
|
||||
<td class="py-3">2026-01-18</td>
|
||||
<td class="py-3">17 days</td>
|
||||
<td class="py-3">18</td>
|
||||
<td class="py-3"><span class="badge bg-warning">Caution (15-30 days)</span></td>
|
||||
<td class="py-3">
|
||||
<a href="#" class="btn btn-light">Mark For Sale</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-3">HDMI Cable</td>
|
||||
<td class="py-3">HD-008</td>
|
||||
<td class="py-3">Accessories</td>
|
||||
<td class="py-3">2026-01-25</td>
|
||||
<td class="py-3">24 days</td>
|
||||
<td class="py-3">30</td>
|
||||
<td class="py-3"><span class="badge bg-warning">Caution (15-30 days)</span></td>
|
||||
<td class="py-3">
|
||||
<a href="#" class="btn btn-light">Mark For Sale</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
|
||||
</table>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<footer class="text-center py-2 mt-6 text-secondary ">
|
||||
<p class="mb-0">Copyright © 2026 InApp Inventory Dashboard. Developed by <a href="https://codescandy.com/"
|
||||
target="_blank" class="text-primary">CodesCandy</a> </p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="position-fixed end-0 bottom-0 m-4">
|
||||
<a href="https://codescandy.com/template/inventory-admin-dashboard-template/" class="btn btn-dark"
|
||||
target="_blank"> Download Now</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<?php require "../include_ending.php";?>
|
||||
|
||||
<!-- Bootstrap JS -->
|
||||
|
||||
|
||||
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,362 @@
|
||||
<?php
|
||||
session_start();
|
||||
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="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">Expired / Near Expiry Products</h1>
|
||||
<p class="mb-0">Monitor and manage products approaching or past expiry dates</p>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<label class="mb-0 text-nowrap fw-semibold">Warehouse</label>
|
||||
<select id="warehouse" class="form-select" style="min-width:200px;" required>
|
||||
<option value="">Select warehouse...</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main content -->
|
||||
<div id="overview_content">
|
||||
|
||||
<!-- Summary stats -->
|
||||
<div class="row mb-5 g-5">
|
||||
<div class="col-xl-3 col-md-6 col-12">
|
||||
<div class="card bg-danger bg-opacity-10 border-danger border-opacity-25">
|
||||
<div class="card-body p-5">
|
||||
<span class="icon-shape icon-md bg-danger text-white rounded-2">
|
||||
<i class="ti ti-trash fs-4"></i>
|
||||
</span>
|
||||
<div class="d-flex flex-column mt-3">
|
||||
<span class="fs-2 fw-bold mb-0 d-block" id="stat_expired">—</span>
|
||||
<span class="fs-5">Expired Products</span>
|
||||
<small class="text-muted">Requires immediate action</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xl-3 col-md-6 col-12">
|
||||
<div class="card bg-danger bg-opacity-10 border-danger border-opacity-25">
|
||||
<div class="card-body p-5">
|
||||
<span class="icon-shape icon-md bg-danger text-white rounded-2">
|
||||
<i class="ti ti-alert-triangle fs-4"></i>
|
||||
</span>
|
||||
<div class="d-flex flex-column mt-3">
|
||||
<span class="fs-2 fw-bold mb-0 d-block" id="stat_critical">—</span>
|
||||
<span class="fs-5">Critical (≤7 days)</span>
|
||||
<small class="text-muted">Expiring within a week</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xl-3 col-md-6 col-12">
|
||||
<div class="card bg-warning bg-opacity-10 border-warning border-opacity-25">
|
||||
<div class="card-body p-5">
|
||||
<span class="icon-shape icon-md bg-warning text-white rounded-2">
|
||||
<i class="ti ti-clock fs-4"></i>
|
||||
</span>
|
||||
<div class="d-flex flex-column mt-3">
|
||||
<span class="fs-2 fw-bold mb-0 d-block" id="stat_warning">—</span>
|
||||
<span class="fs-5">Warning (8–14 days)</span>
|
||||
<small class="text-muted">Expiring in 2 weeks</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xl-3 col-md-6 col-12">
|
||||
<div class="card bg-warning bg-opacity-10 border-warning border-opacity-25">
|
||||
<div class="card-body p-5">
|
||||
<span class="icon-shape icon-md bg-warning text-white rounded-2">
|
||||
<i class="ti ti-calendar-clock fs-4"></i>
|
||||
</span>
|
||||
<div class="d-flex flex-column mt-3">
|
||||
<span class="fs-2 fw-bold mb-0 d-block" id="stat_caution">—</span>
|
||||
<span class="fs-5">Caution (15–30 days)</span>
|
||||
<small class="text-muted">Monitor closely</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter -->
|
||||
<div class="row g-5 mb-5">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body p-5">
|
||||
<h2 class="fs-4 mb-4">Filter Products</h2>
|
||||
<div class="row g-5">
|
||||
<div class="col-lg-7">
|
||||
<div class="position-relative">
|
||||
<input type="search" id="search_expired" class="form-control ps-9"
|
||||
placeholder="Search by product name, SKU, or lot number…">
|
||||
<span class="position-absolute top-25 ms-4">
|
||||
<i class="ti ti-search text-muted"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-5">
|
||||
<select class="form-select" id="filter_status">
|
||||
<option value="">All Status</option>
|
||||
<option value="expired">Expired</option>
|
||||
<option value="critical">Critical</option>
|
||||
<option value="warning">Warning</option>
|
||||
<option value="caution">Caution</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expired table -->
|
||||
<div class="row g-5 mb-5">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body p-5">
|
||||
<h2 class="fs-4 mb-2">Expired Products</h2>
|
||||
<p class="mb-4">Products that have passed their expiry date and need to be removed</p>
|
||||
<div class="table-responsive">
|
||||
<table class="table mb-0 text-nowrap table-hover table-centered" id="tbl_expired">
|
||||
<thead class="table-danger border-light">
|
||||
<tr>
|
||||
<th>Product</th>
|
||||
<th>SKU</th>
|
||||
<th>Lot Number</th>
|
||||
<th>Location</th>
|
||||
<th>Expiry Date</th>
|
||||
<th>Days Overdue</th>
|
||||
<th>Quantity</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Near expiry table -->
|
||||
<div class="row g-5 mb-5">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body p-5">
|
||||
<h2 class="fs-4 mb-2">Near Expiry Products</h2>
|
||||
<p class="mb-4">Products approaching their expiry date that may need attention</p>
|
||||
<div class="table-responsive">
|
||||
<table class="table mb-0 text-nowrap table-hover table-centered" id="tbl_near_expiry">
|
||||
<thead class="table-warning border-light">
|
||||
<tr>
|
||||
<th>Product</th>
|
||||
<th>SKU</th>
|
||||
<th>Lot Number</th>
|
||||
<th>Location</th>
|
||||
<th>Expiry Date</th>
|
||||
<th>Days Remaining</th>
|
||||
<th>Quantity</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /overview_content -->
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<?php require "../include_ending.php";?>
|
||||
|
||||
<script>
|
||||
var all_items = [];
|
||||
var search_term = '';
|
||||
var filter_status = '';
|
||||
|
||||
|
||||
// ── Fetch + populate ─────────────────────────────────────────────────
|
||||
function retrieve_expired_stock() {
|
||||
|
||||
var warehouse_id = $('#warehouse').val();
|
||||
if (!warehouse_id) return;
|
||||
|
||||
return ajax_request({
|
||||
url: "<?php echo $server_url?>ics/api/engine_report/expired_stock.php",
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
action: 'read',
|
||||
debugMode: 0,
|
||||
onSuccess: function(res) {
|
||||
|
||||
var data = res.output;
|
||||
|
||||
$('#stat_expired').text(data.expired);
|
||||
$('#stat_critical').text(data.critical);
|
||||
$('#stat_warning').text(data.warning);
|
||||
$('#stat_caution').text(data.caution);
|
||||
|
||||
all_items = data.items || [];
|
||||
render_tables();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ── Render both tables from filtered data ────────────────────────────
|
||||
function render_tables() {
|
||||
|
||||
var kw = search_term.toLowerCase();
|
||||
var fs = filter_status;
|
||||
|
||||
var filtered = all_items.filter(function(item) {
|
||||
var match_search = !kw ||
|
||||
item.product_name.toLowerCase().includes(kw) ||
|
||||
item.product_sku.toLowerCase().includes(kw) ||
|
||||
(item.lot_number || '').toLowerCase().includes(kw);
|
||||
var match_status = !fs || item.status === fs;
|
||||
return match_search && match_status;
|
||||
});
|
||||
|
||||
render_expired(filtered.filter(function(i) { return i.status === 'expired'; }));
|
||||
render_near_expiry(filtered.filter(function(i) { return i.status !== 'expired'; }));
|
||||
}
|
||||
|
||||
|
||||
function status_badge(status) {
|
||||
var map = {
|
||||
expired: '<span class="badge bg-danger">Expired</span>',
|
||||
critical: '<span class="badge bg-danger">Critical (≤7 days)</span>',
|
||||
warning: '<span class="badge bg-warning text-dark">Warning (8–14 days)</span>',
|
||||
caution: '<span class="badge bg-warning text-dark">Caution (15–30 days)</span>',
|
||||
};
|
||||
return map[status] || '';
|
||||
}
|
||||
|
||||
|
||||
function render_expired(items) {
|
||||
if (items.length === 0) {
|
||||
$('table#tbl_expired tbody').html(
|
||||
'<tr><td colspan="8" class="text-center py-5 text-muted">No expired products.</td></tr>'
|
||||
);
|
||||
return;
|
||||
}
|
||||
var body = '';
|
||||
$.each(items, function(i, item) {
|
||||
body += `<tr>
|
||||
<td class="py-3">${item.product_name}</td>
|
||||
<td class="py-3">${item.product_sku}</td>
|
||||
<td class="py-3">${item.lot_number || '<span class="text-muted">—</span>'}</td>
|
||||
<td class="py-3">${item.zone}-${item.aisle}-${item.rack}</td>
|
||||
<td class="py-3">${item.expiry_date}</td>
|
||||
<td class="py-3 text-danger fw-semibold">${Math.abs(item.days_remaining)} days</td>
|
||||
<td class="py-3">${item.quantity}</td>
|
||||
<td class="py-3">${status_badge(item.status)}</td>
|
||||
</tr>`;
|
||||
});
|
||||
$('table#tbl_expired tbody').html(body);
|
||||
}
|
||||
|
||||
|
||||
function render_near_expiry(items) {
|
||||
if (items.length === 0) {
|
||||
$('table#tbl_near_expiry tbody').html(
|
||||
'<tr><td colspan="8" class="text-center py-5 text-muted">No near-expiry products.</td></tr>'
|
||||
);
|
||||
return;
|
||||
}
|
||||
var body = '';
|
||||
$.each(items, function(i, item) {
|
||||
body += `<tr>
|
||||
<td class="py-3">${item.product_name}</td>
|
||||
<td class="py-3">${item.product_sku}</td>
|
||||
<td class="py-3">${item.lot_number || '<span class="text-muted">—</span>'}</td>
|
||||
<td class="py-3">${item.zone}-${item.aisle}-${item.rack}</td>
|
||||
<td class="py-3">${item.expiry_date}</td>
|
||||
<td class="py-3 text-warning fw-semibold">${item.days_remaining} days</td>
|
||||
<td class="py-3">${item.quantity}</td>
|
||||
<td class="py-3">${status_badge(item.status)}</td>
|
||||
</tr>`;
|
||||
});
|
||||
$('table#tbl_near_expiry tbody').html(body);
|
||||
}
|
||||
|
||||
|
||||
// ── Search + filter ──────────────────────────────────────────────────
|
||||
function debounce(fn, ms) {
|
||||
var t;
|
||||
return function() { clearTimeout(t); t = setTimeout(fn, ms); };
|
||||
}
|
||||
|
||||
$('#search_expired').on('input', debounce(function() {
|
||||
search_term = $(this).val().trim();
|
||||
render_tables();
|
||||
}, 200));
|
||||
|
||||
$('#filter_status').on('change', function() {
|
||||
filter_status = $(this).val();
|
||||
render_tables();
|
||||
});
|
||||
|
||||
|
||||
// ── Load 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 option = '';
|
||||
$.each(res.output, function(key, item) {
|
||||
option += `<option value='${item.id}'>${item.warehouse_name}</option>`;
|
||||
});
|
||||
$('#warehouse').html(option);
|
||||
|
||||
// Auto-select first warehouse on page load
|
||||
if (res.output && res.output.length > 0) {
|
||||
$('#warehouse').val(res.output[0].id);
|
||||
retrieve_expired_stock();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ── Boot ─────────────────────────────────────────────────────────────
|
||||
$(async function() {
|
||||
try {
|
||||
await retrieve_warehouse();
|
||||
} catch(e) {
|
||||
console.log(e);
|
||||
}
|
||||
});
|
||||
|
||||
$('#warehouse').on('change', function() {
|
||||
retrieve_expired_stock();
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,203 +0,0 @@
|
||||
<?php
|
||||
session_start();
|
||||
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">
|
||||
<div class="row ">
|
||||
<div class="col-12">
|
||||
<div class="mb-6 d-flex justify-content-between align-items-center">
|
||||
<h1 class="fs-3 mb-0">Low Stock Products
|
||||
</h1>
|
||||
<a href="#!" class="btn btn-primary">
|
||||
<span><i class="ti ti-plus"></i></span>
|
||||
<span>Restock All</span></a>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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">6</span>
|
||||
<small class="text-muted">
|
||||
Products need attention</small>
|
||||
</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">3</span>
|
||||
<small class="text-muted">
|
||||
Immediate restock needed</small>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="col-lg-4 col-12">
|
||||
<div class="card border border-success 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-success"></i>
|
||||
</span>
|
||||
</div>
|
||||
<div class="d-flex flex-column mt-3">
|
||||
<span class="fs-3 fw-bold mb-0 d-block text-success">3</span>
|
||||
<small class="text-muted">
|
||||
Restock soon</small>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="row g-5">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body p-5">
|
||||
<h2 class="mb-4 fs-5">Low Stock Items</h2>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0 table-centered">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Product</th>
|
||||
<th>SKU</th>
|
||||
<th>Current Stock</th>
|
||||
<th>Min Stock</th>
|
||||
<th>Warehouse</th>
|
||||
<th>Status</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="py-3">Product A</td>
|
||||
<td class="py-3 text-secondary">SKU-001</td>
|
||||
<td class="py-3 text-danger">5</td>
|
||||
<td class="py-3">20</td>
|
||||
<td class="py-3">Main Warehouse</td>
|
||||
<td class="py-3"><span class="badge bg-danger rounded-pill">Critical</span></td>
|
||||
<td class="py-3"><a href="#!" class="btn btn-sm btn-secondary">Restock</a></td>
|
||||
</tr>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-3">Product B</td>
|
||||
<td class="py-3 text-secondary">SKU-002</td>
|
||||
<td class="py-3 text-warning">12</td>
|
||||
<td class="py-3">25</td>
|
||||
<td class="py-3">Main Warehouse</td>
|
||||
<td class="py-3"><span
|
||||
class="badge bg-secondary bg-opacity-10 text-dark rounded-pill">Warning</span></td>
|
||||
<td class="py-3"><a href="#!" class="btn btn-sm btn-secondary">Restock</a></td>
|
||||
</tr>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-3">Product C</td>
|
||||
<td class="py-3 text-secondary">SKU-003</td>
|
||||
<td class="py-3 text-danger">3</td>
|
||||
<td class="py-3">15</td>
|
||||
<td class="py-3">Secondary Warehouse</td>
|
||||
<td class="py-3"><span class="badge bg-danger rounded-pill">Critical</span></td>
|
||||
<td class="py-3"><a href="#!" class="btn btn-sm btn-secondary">Restock</a></td>
|
||||
</tr>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-3">Product D</td>
|
||||
<td class="py-3 text-secondary">SKU-004</td>
|
||||
<td class="py-3 text-warning">18</td>
|
||||
<td class="py-3">30</td>
|
||||
<td class="py-3">Main Warehouse</td>
|
||||
<td class="py-3"><span
|
||||
class="badge bg-secondary bg-opacity-10 text-dark rounded-pill">Warning</span></td>
|
||||
<td class="py-3"><a href="#!" class="btn btn-sm btn-secondary">Restock</a></td>
|
||||
</tr>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-3">Product E</td>
|
||||
<td class="py-3 text-secondary">SKU-005</td>
|
||||
<td class="py-3 text-danger">2</td>
|
||||
<td class="py-3">10</td>
|
||||
<td class="py-3">Secondary Warehouse</td>
|
||||
<td class="py-3"><span class="badge bg-danger rounded-pill">Critical</span></td>
|
||||
<td class="py-3"><a href="#!" class="btn btn-sm btn-secondary">Restock</a></td>
|
||||
</tr>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-3">Product F</td>
|
||||
<td class="py-3 text-secondary">SKU-006</td>
|
||||
<td class="py-3 text-warning">8</td>
|
||||
<td class="py-3">20</td>
|
||||
<td class="py-3">Main Warehouse</td>
|
||||
<td class="py-3"><span
|
||||
class="badge bg-secondary bg-opacity-10 text-dark rounded-pill">Warning</span></td>
|
||||
<td class="py-3"><a href="#!" class="btn btn-sm btn-secondary">Restock</a></td>
|
||||
</tr>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Table rows will be dynamically populated here -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<?php require "../include_ending.php";?>
|
||||
|
||||
<!-- Bootstrap JS -->
|
||||
|
||||
|
||||
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -44,7 +44,23 @@
|
||||
<input type="number" class="form-control" id="quantity" placeholder="Enter quantity" required>
|
||||
</div>
|
||||
|
||||
<!-- Lot Number -->
|
||||
<div class="mb-3 col-lg-6">
|
||||
<label for="lot_number" class="form-label">Lot Number <span class="text-muted fw-normal">(optional)</span></label>
|
||||
<input type="text" class="form-control lot_search" id="lot_number" placeholder="Type to search or enter new lot..." autocomplete="off">
|
||||
</div>
|
||||
|
||||
<!-- Expiry Date (only relevant when lot_number is set) -->
|
||||
<div class="mb-3 col-lg-6">
|
||||
<label for="expiry_date" class="form-label">Expiry Date <span class="text-muted fw-normal">(optional)</span></label>
|
||||
<input type="text" class="form-control" id="expiry_date" placeholder="DD/MM/YYYY" autocomplete="off">
|
||||
</div>
|
||||
|
||||
<!-- Serial Number -->
|
||||
<div class="mb-3 col-lg-6">
|
||||
<label for="serial_number" class="form-label">Serial Number <span class="text-muted fw-normal">(optional)</span></label>
|
||||
<input type="text" class="form-control" id="serial_number" placeholder="Enter serial number">
|
||||
</div>
|
||||
|
||||
<!-- Warehouse -->
|
||||
<div class="mb-3 col-lg-6">
|
||||
@@ -79,7 +95,6 @@
|
||||
<input type="text" class="form-control contact_search" id="contact" placeholder="Enter contact name">
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Notes / Description -->
|
||||
<div class="mb-3 col-lg-12">
|
||||
<label for="description" class="form-label">Notes / Description</label>
|
||||
@@ -111,7 +126,10 @@
|
||||
checkRequired: 1,
|
||||
debugMode: 0,
|
||||
data: {
|
||||
"contact_id": $("#contact").attr('data-id')
|
||||
"contact_id": $("#contact").attr('data-id'),
|
||||
"lot_number": $("#lot_number").val(),
|
||||
"expiry_date": $("#expiry_date").val(),
|
||||
"serial_number": $("#serial_number").val()
|
||||
},
|
||||
action: 'manage',
|
||||
onSuccess: function(res) {
|
||||
@@ -271,6 +289,16 @@
|
||||
.attr('data-id', data.contact_id);
|
||||
|
||||
$('#product_name').val(data.product_name);
|
||||
$('#lot_number').val(data.lot_number || '');
|
||||
$('#serial_number').val(data.serial_number || '');
|
||||
if (data.lot_number) {
|
||||
if (expiryPicker) { expiryPicker.setDate(data.expiry_date || '', true, 'Y-m-d'); }
|
||||
$('#expiry_date').prop('disabled', true).attr('title', 'Expiry date is locked for existing lots')
|
||||
.val(data.expiry_date);
|
||||
if (expiryPicker && expiryPicker.altInput) { expiryPicker.altInput.disabled = true; }
|
||||
} else {
|
||||
if (expiryPicker) { expiryPicker.clear(); }
|
||||
}
|
||||
$('#product_sku').attr('secondary', data.product_sku);
|
||||
|
||||
$('#product_sku,#quantity').prop('disabled', true);
|
||||
@@ -291,16 +319,80 @@
|
||||
}
|
||||
|
||||
|
||||
// ── Prefill for restock flow (coming from low-stock-products.php) ────
|
||||
function prefill_from_restock() {
|
||||
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var sku = params.get('sku');
|
||||
var name = params.get('name');
|
||||
var qty = params.get('qty');
|
||||
var wh = params.get('wh');
|
||||
|
||||
// Not a restock flow — nothing to prefill
|
||||
if (!sku) return false;
|
||||
|
||||
// Product fields (locked — the point of restock is this specific SKU)
|
||||
$('#product_sku')
|
||||
.val(sku)
|
||||
.attr('secondary', sku)
|
||||
.prop('disabled', true);
|
||||
|
||||
$('#product_name')
|
||||
.val(name || '')
|
||||
.attr('secondary', name || '');
|
||||
// already disabled in markup
|
||||
|
||||
// Suggested quantity (prefilled value only; user can still edit)
|
||||
if (qty) {
|
||||
$('#quantity').val(qty);
|
||||
}
|
||||
|
||||
// Warehouse: lock it (can't restock to a different warehouse than the one that's low)
|
||||
if (wh) {
|
||||
$('#warehouse').val(wh).prop('disabled', true);
|
||||
|
||||
// Chain-load location selects for the locked warehouse.
|
||||
// Unlike edit mode, we DON'T preselect zone/aisle/rack — the user picks.
|
||||
retrieve_zone('to', function() {
|
||||
retrieve_aisle('to', function() {
|
||||
retrieve_rack('to');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
$(async function() {
|
||||
try {
|
||||
await retrieve_warehouse('to');
|
||||
await retrieve_for_edit();
|
||||
|
||||
<?php if(!empty($_GET["sku"])){ ?>
|
||||
prefill_from_restock();
|
||||
<?php } ?>
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Flatpickr: display dd/mm/yyyy, send yyyy-mm-dd to API ──────────
|
||||
var expiryPicker = flatpickr("#expiry_date", {
|
||||
dateFormat: "Y-m-d", // actual value stored in input (sent to API)
|
||||
altInput: true, // show a separate visible input
|
||||
altFormat: "d/m/Y", // visible format: 28/04/2026
|
||||
allowInput: true,
|
||||
onReady: function(_, __, fp) {
|
||||
// Apply Bootstrap form-control class to the alt input
|
||||
fp.altInput.classList.add("form-control");
|
||||
},
|
||||
onChange: function(_, dateStr) {
|
||||
// If lot is new (not existing), keep expiry_date unlocked
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
<?php if(empty($_GET["id"])){ ?>
|
||||
$("select#warehouse").change(function() {
|
||||
retrieve_zone('to');
|
||||
|
||||
+182
-96
@@ -2,22 +2,19 @@
|
||||
session_start();
|
||||
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">
|
||||
<div class="row ">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="mb-6">
|
||||
<h1 class="fs-3 mb-0">Stock Out</h1>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -25,9 +22,10 @@
|
||||
<div class="col-lg-8 col-12">
|
||||
<div class="card">
|
||||
<div class="card-body p-5">
|
||||
<h2 class="fs-5 mb-4">Add Stock</h2>
|
||||
<h2 class="fs-5 mb-4">Remove Stock</h2>
|
||||
<form class="row gx-5">
|
||||
<!-- Product Name -->
|
||||
|
||||
<!-- Step 1: Product -->
|
||||
<div class="mb-3 col-lg-3">
|
||||
<label for="product_sku" class="form-label">SKU</label>
|
||||
<input type="text" id="product_sku" class="form-control product_search"
|
||||
@@ -38,36 +36,50 @@
|
||||
<input type="text" id="product_name" class="form-control" autocomplete="off" disabled>
|
||||
</div>
|
||||
|
||||
<!-- SKU or Product Code -->
|
||||
<div class="mb-3 col-lg-6">
|
||||
|
||||
<!-- Step 2: Lot Number (active lots for selected SKU) -->
|
||||
<div class="mb-3 col-lg-3">
|
||||
<label for="lot_number" class="form-label">Lot Number <span class="text-muted fw-normal">(optional)</span></label>
|
||||
<select id="lot_number" class="form-select">
|
||||
<option value="">Select lot...</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Serial Number (active serials for selected SKU + lot) -->
|
||||
<div class="mb-3 col-lg-3">
|
||||
<label for="serial_number" class="form-label">Serial Number <span class="text-muted fw-normal">(optional)</span></label>
|
||||
<select id="serial_number" class="form-select">
|
||||
<option value="">Select serial...</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Warehouse -->
|
||||
<!-- Step 4: Warehouse -->
|
||||
<div class="mb-3 col-lg-6">
|
||||
<label for="warehouse" class="form-label">Warehouse</label>
|
||||
<select name="warehouse" id="warehouse" class="form-select" required></select>
|
||||
<select name="warehouse" id="warehouse" class="form-select" required disabled>
|
||||
<option value="">Select warehouse...</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Step 5: Zone -->
|
||||
<div class="mb-3 col-lg-6">
|
||||
<label for="zone" class="form-label">Zone</label>
|
||||
<select name="zone" id="zone" class="form-select" required>
|
||||
<select name="zone" id="zone" class="form-select" required disabled>
|
||||
<option value="">Please select zone</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Step 6: Aisle -->
|
||||
<div class="mb-3 col-lg-6">
|
||||
<label for="aisle" class="form-label">Aisle</label>
|
||||
<select name="aisle" id="aisle" class="form-select" required>
|
||||
<select name="aisle" id="aisle" class="form-select" required disabled>
|
||||
<option value="">Please select aisle</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Step 7: Rack -->
|
||||
<div class="mb-3 col-lg-6">
|
||||
<label for="rack" class="form-label">Rack</label>
|
||||
<select name="rack" id="rack" class="form-select" required>
|
||||
<select name="rack" id="rack" class="form-select" required disabled>
|
||||
<option value="">Please select rack</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -78,7 +90,6 @@
|
||||
<input type="text" class="form-control contact_search" id="contact" placeholder="Enter contact name">
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Notes / Description -->
|
||||
<div class="mb-3 col-lg-12">
|
||||
<label for="description" class="form-label">Notes / Description</label>
|
||||
@@ -87,15 +98,13 @@
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div class="mb-3 col-lg-12">
|
||||
<button type="submit" class="btn btn-primary" onclick="manage_stock_out();">Add Stock</button>
|
||||
<button type="submit" class="btn btn-primary" onclick="manage_stock_out();">Remove Stock</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -114,67 +123,121 @@
|
||||
},
|
||||
action: 'manage',
|
||||
onSuccess: function(res) {
|
||||
|
||||
window.location.href = "<?php echo $server_url?>ics/stock_out.php";
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
// ── Step 1: Product selected → load active lots ───────────────────────
|
||||
function retrieve_active_lot() {
|
||||
|
||||
function retrieve_warehouse(type) {
|
||||
var sku = $('#product_sku').attr('secondary') || $('#product_sku').val();
|
||||
if (!sku) return;
|
||||
|
||||
return ajax_request({
|
||||
url: "<?php echo $server_url?>ics/api/engine/retrieve_active_lot.php",
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
noLoading: true,
|
||||
action: 'read',
|
||||
data: { "product_sku": sku },
|
||||
onSuccess: function(res) {
|
||||
|
||||
var option = '<option value="">Select lot... (optional)</option>';
|
||||
$.each(res.output, function(i, item) {
|
||||
var label = item.lot_number;
|
||||
if (item.expiry_date) label += ' — exp: ' + item.expiry_date;
|
||||
option += `<option value="${item.lot_number}">${label}</option>`;
|
||||
});
|
||||
$('#lot_number').html(option).prop('disabled', false);
|
||||
$('#serial_number').html('<option value="">Select serial... (optional)</option>').prop('disabled', false);
|
||||
|
||||
// Reset downstream
|
||||
reset_location();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ── Step 2: Lot selected → load active serials ────────────────────────
|
||||
function retrieve_active_serial() {
|
||||
|
||||
var sku = $('#product_sku').attr('secondary') || $('#product_sku').val();
|
||||
if (!sku) return;
|
||||
|
||||
var lot = $('#lot_number').val();
|
||||
|
||||
return ajax_request({
|
||||
url: "<?php echo $server_url?>ics/api/engine/retrieve_active_serial.php",
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
noLoading: true,
|
||||
action: 'read',
|
||||
data: { "product_sku": sku, "lot_number": lot },
|
||||
onSuccess: function(res) {
|
||||
|
||||
var option = '<option value="">Select serial... (optional)</option>';
|
||||
$.each(res.output, function(i, serial) {
|
||||
option += `<option value="${serial}">${serial}</option>`;
|
||||
});
|
||||
$('#serial_number').html(option).prop('disabled', false);
|
||||
|
||||
// Reset downstream
|
||||
reset_location();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ── Step 4: Load warehouse filtered by SKU ────────────────────────────
|
||||
function retrieve_warehouse() {
|
||||
|
||||
return ajax_request({
|
||||
url: "<?php echo $server_url?>ics/api/engine/retrieve_warehouse.php",
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
data: {
|
||||
"type": type
|
||||
},
|
||||
noLoading: true,
|
||||
data: { "type": 'from' },
|
||||
action: 'read',
|
||||
onSuccess: function(res) {
|
||||
var option = `<option value="">Please select warehouse</option>`;
|
||||
var option = '<option value="">Select warehouse...</option>';
|
||||
$.each(res.output, function(key, item) {
|
||||
option += `<option value='${item.id}'>${item.warehouse_name}</option>`;
|
||||
})
|
||||
$(`select#warehouse`).html(option);
|
||||
});
|
||||
$('#warehouse').html(option).prop('disabled', false);
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function retrieve_zone(type,callback) {
|
||||
// ── Steps 5-7: Zone → Aisle → Rack ───────────────────────────────────
|
||||
function retrieve_zone(callback) {
|
||||
|
||||
return ajax_request({
|
||||
url: "<?php echo $server_url?>ics/api/engine/retrieve_zone.php",
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
noLoading: true,
|
||||
data : {
|
||||
"type": type
|
||||
data: {
|
||||
"type": 'from',
|
||||
"product_sku": $('#product_sku').attr('secondary') || $('#product_sku').val(),
|
||||
"lot_number": $('#lot_number').val(),
|
||||
"serial_number": $('#serial_number').val()
|
||||
},
|
||||
debugMode: 0,
|
||||
action: 'read',
|
||||
onSuccess: function(res) {
|
||||
var option = `<option value="">Please select zone</option>`;
|
||||
var option = '<option value="">Please select zone</option>';
|
||||
$.each(res.output, function(key, item) {
|
||||
option += `<option value='${item.zone}'>${item.zone}</option>`;
|
||||
})
|
||||
$(`select#zone`).html(option);
|
||||
|
||||
if (callback) callback(); // ← add this
|
||||
});
|
||||
$('#zone').html(option).prop('disabled', false);
|
||||
if (callback) callback();
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function retrieve_aisle(type, callback) {
|
||||
function retrieve_aisle(callback) {
|
||||
|
||||
return ajax_request({
|
||||
url: "<?php echo $server_url?>ics/api/engine/retrieve_aisle.php",
|
||||
@@ -182,25 +245,24 @@
|
||||
checkRequired: 0,
|
||||
noLoading: true,
|
||||
data: {
|
||||
"type": type
|
||||
"type": 'from',
|
||||
"product_sku": $('#product_sku').attr('secondary') || $('#product_sku').val(),
|
||||
"lot_number": $('#lot_number').val(),
|
||||
"serial_number": $('#serial_number').val()
|
||||
},
|
||||
action: 'read',
|
||||
onSuccess: function(res) {
|
||||
var option = `<option value="">Please select aisle</option>`;
|
||||
var option = '<option value="">Please select aisle</option>';
|
||||
$.each(res.output, function(key, item) {
|
||||
option += `<option value='${item}'>${item}</option>`;
|
||||
})
|
||||
$(`select#aisle`).html(option);
|
||||
|
||||
if (callback) callback(); // ← add this
|
||||
});
|
||||
$('#aisle').html(option).prop('disabled', false);
|
||||
if (callback) callback();
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function retrieve_rack(type, callback) {
|
||||
function retrieve_rack(callback) {
|
||||
|
||||
return ajax_request({
|
||||
url: "<?php echo $server_url?>ics/api/engine/retrieve_rack.php",
|
||||
@@ -208,24 +270,34 @@
|
||||
checkRequired: 0,
|
||||
noLoading: true,
|
||||
data: {
|
||||
"type": type
|
||||
"type": 'from',
|
||||
"product_sku": $('#product_sku').attr('secondary') || $('#product_sku').val(),
|
||||
"lot_number": $('#lot_number').val(),
|
||||
"serial_number": $('#serial_number').val()
|
||||
},
|
||||
action: 'read',
|
||||
onSuccess: function(res) {
|
||||
var option = `<option value="">Please select rack</option>`;
|
||||
var option = '<option value="">Please select rack</option>';
|
||||
$.each(res.output, function(key, item) {
|
||||
option += `<option value='${item}'>${item}</option>`;
|
||||
})
|
||||
$(`select#rack`).html(option);
|
||||
|
||||
if (callback) callback(); // ← add this
|
||||
});
|
||||
$('#rack').html(option).prop('disabled', false);
|
||||
if (callback) callback();
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
// ── Reset location dropdowns ──────────────────────────────────────────
|
||||
function reset_location() {
|
||||
$('#warehouse').html('<option value="">Select warehouse...</option>').prop('disabled', true);
|
||||
$('#zone').html('<option value="">Please select zone</option>').prop('disabled', true);
|
||||
$('#aisle').html('<option value="">Please select aisle</option>').prop('disabled', true);
|
||||
$('#rack').html('<option value="">Please select rack</option>').prop('disabled', true);
|
||||
}
|
||||
|
||||
|
||||
// ── Edit mode ─────────────────────────────────────────────────────────
|
||||
function retrieve_for_edit() {
|
||||
|
||||
<?php if(empty($_GET["id"])){ ?>
|
||||
@@ -242,24 +314,25 @@
|
||||
|
||||
var data = res.output;
|
||||
|
||||
// 1. Set warehouse (already populated from page load)
|
||||
$(`#warehouse`).val(<?php echo $_GET["wh"];?>).attr('disabled', true); // set warehouse BEFORE retrieve_zone
|
||||
// Lock lot/serial — not editable in edit mode
|
||||
$('#lot_number').html(`<option value="${data.lot_number || ''}">${data.lot_number || '—'}</option>`)
|
||||
.val(data.lot_number || '').prop('disabled', true);
|
||||
$('#serial_number').html(`<option value="${data.serial_number || ''}">${data.serial_number || '—'}</option>`)
|
||||
.val(data.serial_number || '').prop('disabled', true);
|
||||
|
||||
// 2. Retrieve zone (autoPrepare reads warehouse value)
|
||||
retrieve_zone('from', function() {
|
||||
$(`select#zone`).val(data.zone).attr('disabled', true); // set zone BEFORE retrieve_aisle
|
||||
$('#warehouse').val('<?php echo $_GET["wh"];?>').prop('disabled', true);
|
||||
|
||||
// 3. Retrieve aisle (autoPrepare reads zone value)
|
||||
retrieve_aisle('from', function() {
|
||||
$(`#aisle`).val(data.aisle).attr('disabled', true); // set aisle BEFORE retrieve_rack
|
||||
retrieve_zone(function() {
|
||||
$('#zone').val(data.zone).prop('disabled', true);
|
||||
|
||||
// 4. Retrieve rack (autoPrepare reads aisle value)
|
||||
retrieve_rack('from', function() {
|
||||
$(`#rack`).val(data.rack).attr('disabled', true);
|
||||
retrieve_aisle(function() {
|
||||
$('#aisle').val(data.aisle).prop('disabled', true);
|
||||
|
||||
retrieve_rack(function() {
|
||||
$('#rack').val(data.rack).prop('disabled', true);
|
||||
|
||||
// retrieve for edit
|
||||
$.each(data, function(key, item) {
|
||||
if (!['warehouse', 'zone', 'aisle', 'rack'].includes(key)) {
|
||||
if (!['warehouse', 'zone', 'aisle', 'rack', 'lot_number', 'serial_number'].includes(key)) {
|
||||
$(`#${key}`).val(item);
|
||||
}
|
||||
});
|
||||
@@ -270,50 +343,63 @@
|
||||
|
||||
$('#product_name').val(data.product_name);
|
||||
$('#product_sku').attr('secondary', data.product_sku);
|
||||
$('#product_sku').prop('disabled', true);
|
||||
|
||||
$('#product_sku,#quantity').prop('disabled', true);
|
||||
|
||||
$(`button[type=submit]`).text('Update');
|
||||
$(`button[type=reset]`).hide();
|
||||
|
||||
$('button[type=submit]').text('Update');
|
||||
$('button[type=reset]').hide();
|
||||
});
|
||||
});
|
||||
});
|
||||
// Set re
|
||||
|
||||
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ── Boot ─────────────────────────────────────────────────────────────
|
||||
$(async function() {
|
||||
try {
|
||||
await retrieve_warehouse('from');
|
||||
await retrieve_for_edit();
|
||||
} catch (e) {
|
||||
} catch(e) {
|
||||
console.log(e);
|
||||
}
|
||||
});
|
||||
|
||||
$(`#product_sku`).on('blur', function() {
|
||||
retrieve_warehouse('from');
|
||||
});
|
||||
|
||||
// ── Change handlers (create mode only) ───────────────────────────────
|
||||
<?php if(empty($_GET["id"])){ ?>
|
||||
$("select#warehouse").change(function() {
|
||||
retrieve_zone('from');
|
||||
|
||||
// SKU selected → load lots
|
||||
$('input#product_sku').on('blur', function() {
|
||||
var sku = $(this).attr('secondary');
|
||||
if (!sku) return;
|
||||
retrieve_active_lot();
|
||||
retrieve_warehouse();
|
||||
});
|
||||
|
||||
$("select#zone").change(function() {
|
||||
retrieve_aisle('from');
|
||||
// Lot selected → reload serials + location
|
||||
$('#lot_number').on('change', function() {
|
||||
retrieve_active_serial();
|
||||
retrieve_warehouse();
|
||||
});
|
||||
|
||||
$("select#aisle").change(function() {
|
||||
retrieve_rack('from');
|
||||
// Serial selected → reload location
|
||||
$('#serial_number').on('change', function() {
|
||||
retrieve_warehouse();
|
||||
});
|
||||
|
||||
// Warehouse selected → load zone
|
||||
$('#warehouse').on('change', function() {
|
||||
retrieve_zone();
|
||||
});
|
||||
|
||||
// Zone selected → load aisle
|
||||
$('#zone').on('change', function() {
|
||||
retrieve_aisle();
|
||||
});
|
||||
|
||||
// Aisle selected → load rack
|
||||
$('#aisle').on('change', function() {
|
||||
retrieve_rack();
|
||||
});
|
||||
|
||||
<?php } ?>
|
||||
|
||||
+238
-162
@@ -2,22 +2,19 @@
|
||||
session_start();
|
||||
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">
|
||||
<div class="row ">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="mb-6">
|
||||
<h1 class="fs-3 mb-0">Stock Transfer</h1>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -27,7 +24,8 @@
|
||||
<div class="card-body p-5">
|
||||
<h2 class="fs-5 mb-4">Move Stock</h2>
|
||||
<form class="row gx-5">
|
||||
<!-- Product Name -->
|
||||
|
||||
<!-- Step 1: Product -->
|
||||
<div class="mb-3 col-lg-3">
|
||||
<label for="product_sku" class="form-label">SKU</label>
|
||||
<input type="text" id="product_sku" class="form-control product_search"
|
||||
@@ -38,67 +36,81 @@
|
||||
<input type="text" id="product_name" class="form-control" autocomplete="off" disabled>
|
||||
</div>
|
||||
|
||||
<!-- SKU or Product Code -->
|
||||
<div class="mb-3 col-lg-6">
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<!-- Warehouse -->
|
||||
<!-- Step 2: Lot Number (active lots for selected SKU) -->
|
||||
<div class="mb-3 col-lg-3">
|
||||
<label for="warehouse" class="form-label">From Warehouse</label>
|
||||
<select name="warehouse" id="warehouse_from" role="from" class="form-select" required>
|
||||
<option value="">Please select warehouse</option>
|
||||
<label for="lot_number" class="form-label">Lot Number <span class="text-muted fw-normal">(optional)</span></label>
|
||||
<select id="lot_number" class="form-select">
|
||||
<option value="">Select lot...</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Serial Number (active serials for selected SKU + lot) -->
|
||||
<div class="mb-3 col-lg-3">
|
||||
<label for="zone" class="form-label">From Zone</label>
|
||||
<select name="zone" id="zone_from" role="from" class="form-select" required>
|
||||
<label for="serial_number" class="form-label">Serial Number <span class="text-muted fw-normal">(optional)</span></label>
|
||||
<select id="serial_number" class="form-select">
|
||||
<option value="">Select serial...</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-12"><hr class="my-2"></div>
|
||||
|
||||
<!-- From location -->
|
||||
<div class="col-12 mb-2">
|
||||
<span class="fw-semibold text-muted">From</span>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 col-lg-3">
|
||||
<label class="form-label">From Warehouse</label>
|
||||
<select name="warehouse" id="warehouse_from" role="from" class="form-select" required disabled>
|
||||
<option value="">Select warehouse...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3 col-lg-3">
|
||||
<label class="form-label">From Zone</label>
|
||||
<select name="zone" id="zone_from" role="from" class="form-select" required disabled>
|
||||
<option value="">Please select zone</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 col-lg-3">
|
||||
<label for="aisle" class="form-label">From Aisle</label>
|
||||
<select name="aisle" id="aisle_from" role="from" class="form-select" required>
|
||||
<label class="form-label">From Aisle</label>
|
||||
<select name="aisle" id="aisle_from" role="from" class="form-select" required disabled>
|
||||
<option value="">Please select aisle</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 col-lg-3">
|
||||
<label for="rack" class="form-label">From Rack</label>
|
||||
<select name="rack" id="rack_from" role="from" class="form-select" required>
|
||||
<label class="form-label">From Rack</label>
|
||||
<select name="rack" id="rack_from" role="from" class="form-select" required disabled>
|
||||
<option value="">Please select rack</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-12"><hr class="my-2"></div>
|
||||
|
||||
<!-- Warehouse -->
|
||||
<div class="mb-3 col-lg-3">
|
||||
<label for="warehouse" class="form-label">To Warehouse</label>
|
||||
<select name="warehouse" id="warehouse_to" role="to" class="form-select" required>
|
||||
<option value="">Please select warehouse</option>
|
||||
</select>
|
||||
<!-- To location -->
|
||||
<div class="col-12 mb-2">
|
||||
<span class="fw-semibold text-muted">To</span>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 col-lg-3">
|
||||
<label for="zone" class="form-label">To Zone</label>
|
||||
<label class="form-label">To Warehouse</label>
|
||||
<select name="warehouse" id="warehouse_to" role="to" class="form-select" required>
|
||||
<option value="">Select warehouse...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3 col-lg-3">
|
||||
<label class="form-label">To Zone</label>
|
||||
<select name="zone" id="zone_to" role="to" class="form-select" required>
|
||||
<option value="">Please select zone</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 col-lg-3">
|
||||
<label for="aisle" class="form-label">To Aisle</label>
|
||||
<label class="form-label">To Aisle</label>
|
||||
<select name="aisle" id="aisle_to" role="to" class="form-select" required>
|
||||
<option value="">Please select aisle</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 col-lg-3">
|
||||
<label for="rack" class="form-label">To Rack</label>
|
||||
<label class="form-label">To Rack</label>
|
||||
<select name="rack" id="rack_to" role="to" class="form-select" required>
|
||||
<option value="">Please select rack</option>
|
||||
</select>
|
||||
@@ -110,24 +122,21 @@
|
||||
<input type="text" class="form-control contact_search" id="contact" placeholder="Enter contact name">
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Notes / Description -->
|
||||
<div class="mb-3 col-lg-12">
|
||||
<label for="description" class="form-label">Notes / Description</label>
|
||||
<textarea class="form-control" id="description" rows="3" placeholder="Enter any notes"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<!-- Submit -->
|
||||
<div class="mb-3 col-lg-12">
|
||||
<button type="submit" class="btn btn-primary" onclick="manage_stock_transfer();">Add Stock</button>
|
||||
<button type="submit" class="btn btn-primary" onclick="manage_stock_transfer();">Transfer Stock</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -146,135 +155,189 @@
|
||||
},
|
||||
action: 'manage',
|
||||
onSuccess: function(res) {
|
||||
|
||||
window.location.href = "<?php echo $server_url?>ics/stock_transfer.php";
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
// ── Step 1: Product selected → load active lots + to-warehouses ───────
|
||||
function retrieve_active_lot() {
|
||||
|
||||
var sku = $('#product_sku').attr('secondary') || $('#product_sku').val();
|
||||
if (!sku) return;
|
||||
|
||||
return ajax_request({
|
||||
url: "<?php echo $server_url?>ics/api/engine/retrieve_active_lot.php",
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
noLoading: true,
|
||||
action: 'read',
|
||||
data: { "product_sku": sku },
|
||||
onSuccess: function(res) {
|
||||
var option = '<option value="">Select lot... (optional)</option>';
|
||||
$.each(res.output, function(i, item) {
|
||||
var label = item.lot_number;
|
||||
if (item.expiry_date) label += ' — exp: ' + item.expiry_date;
|
||||
option += `<option value="${item.lot_number}">${label}</option>`;
|
||||
});
|
||||
$('#lot_number').html(option).prop('disabled', false);
|
||||
$('#serial_number').html('<option value="">Select serial... (optional)</option>').prop('disabled', false);
|
||||
reset_from_location();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ── Step 2: Lot selected → load active serials ────────────────────────
|
||||
function retrieve_active_serial() {
|
||||
|
||||
var sku = $('#product_sku').attr('secondary') || $('#product_sku').val();
|
||||
if (!sku) return;
|
||||
|
||||
return ajax_request({
|
||||
url: "<?php echo $server_url?>ics/api/engine/retrieve_active_serial.php",
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
noLoading: true,
|
||||
action: 'read',
|
||||
data: {
|
||||
"product_sku": sku,
|
||||
"lot_number": $('#lot_number').val()
|
||||
},
|
||||
onSuccess: function(res) {
|
||||
var option = '<option value="">Select serial... (optional)</option>';
|
||||
$.each(res.output, function(i, serial) {
|
||||
option += `<option value="${serial}">${serial}</option>`;
|
||||
});
|
||||
$('#serial_number').html(option).prop('disabled', false);
|
||||
reset_from_location();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ── Warehouse loaders ─────────────────────────────────────────────────
|
||||
function retrieve_warehouse(type) {
|
||||
|
||||
return ajax_request({
|
||||
url: "<?php echo $server_url?>ics/api/engine/retrieve_warehouse.php",
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
data: {
|
||||
"type": type
|
||||
},
|
||||
noLoading: true,
|
||||
data: { "type": type },
|
||||
action: 'read',
|
||||
debugMode: 0,
|
||||
onSuccess: function(res) {
|
||||
var option = `<option value="">Please select warehouse</option>`;
|
||||
var option = '<option value="">Select warehouse...</option>';
|
||||
$.each(res.output, function(key, item) {
|
||||
option += `<option value='${item.id}'>${item.warehouse_name}</option>`;
|
||||
})
|
||||
$(`select[name='warehouse'][role='${type}']`).html(option);
|
||||
if(!type) {
|
||||
$(`select[name='warehouse']`).html(option);
|
||||
}
|
||||
});
|
||||
$(`select[name='warehouse'][role='${type}']`).html(option).prop('disabled', false);
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ── Zone / Aisle / Rack — From (type=from, filtered by sku+lot+serial) ─
|
||||
function retrieve_zone(type, callback) {
|
||||
|
||||
var extra = {};
|
||||
if (type === 'from') {
|
||||
extra = {
|
||||
"product_sku": $('#product_sku').attr('secondary') || $('#product_sku').val(),
|
||||
"lot_number": $('#lot_number').val(),
|
||||
"serial_number": $('#serial_number').val()
|
||||
};
|
||||
}
|
||||
|
||||
return ajax_request({
|
||||
url: "<?php echo $server_url?>ics/api/engine/retrieve_zone.php",
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
data: {
|
||||
"type": type
|
||||
},
|
||||
noLoading: true,
|
||||
debugMode: 0,
|
||||
data: Object.assign({ "type": type }, extra),
|
||||
action: 'read',
|
||||
onSuccess: function(res) {
|
||||
var option = `<option value="">Please select zone</option>`;
|
||||
var option = '<option value="">Please select zone</option>';
|
||||
$.each(res.output, function(key, item) {
|
||||
option += `<option value='${item.zone}'>${item.zone}</option>`;
|
||||
})
|
||||
$(`select[name='zone'][role='${type}']`).html(option);
|
||||
|
||||
if(!type) {
|
||||
$(`select[name='zone']`).html(option);
|
||||
}
|
||||
|
||||
if (callback) callback(); // ← add this
|
||||
});
|
||||
$(`select[name='zone'][role='${type}']`).html(option).prop('disabled', false);
|
||||
if (callback) callback();
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function retrieve_aisle(type, callback) {
|
||||
|
||||
var extra = {};
|
||||
if (type === 'from') {
|
||||
extra = {
|
||||
"product_sku": $('#product_sku').attr('secondary') || $('#product_sku').val(),
|
||||
"lot_number": $('#lot_number').val(),
|
||||
"serial_number": $('#serial_number').val()
|
||||
};
|
||||
}
|
||||
|
||||
return ajax_request({
|
||||
url: "<?php echo $server_url?>ics/api/engine/retrieve_aisle.php",
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
data: {
|
||||
"type": type
|
||||
},
|
||||
noLoading: true,
|
||||
data: Object.assign({ "type": type }, extra),
|
||||
action: 'read',
|
||||
debugMode: 0,
|
||||
onSuccess: function(res) {
|
||||
var option = `<option value="">Please select aisle</option>`;
|
||||
var option = '<option value="">Please select aisle</option>';
|
||||
$.each(res.output, function(key, item) {
|
||||
option += `<option value='${item}'>${item}</option>`;
|
||||
})
|
||||
$(`select[name='aisle'][role='${type}']`).html(option);
|
||||
|
||||
if(!type) {
|
||||
$(`select[name='aisle']`).html(option);
|
||||
}
|
||||
|
||||
if (callback) callback(); // ← add this
|
||||
});
|
||||
$(`select[name='aisle'][role='${type}']`).html(option).prop('disabled', false);
|
||||
if (callback) callback();
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function retrieve_rack(type, callback) {
|
||||
|
||||
var extra = {};
|
||||
if (type === 'from') {
|
||||
extra = {
|
||||
"product_sku": $('#product_sku').attr('secondary') || $('#product_sku').val(),
|
||||
"lot_number": $('#lot_number').val(),
|
||||
"serial_number": $('#serial_number').val()
|
||||
};
|
||||
}
|
||||
|
||||
return ajax_request({
|
||||
url: "<?php echo $server_url?>ics/api/engine/retrieve_rack.php",
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
data: {
|
||||
"type": type
|
||||
},
|
||||
noLoading: true,
|
||||
data: Object.assign({ "type": type }, extra),
|
||||
action: 'read',
|
||||
onSuccess: function(res) {
|
||||
var option = `<option value="">Please select rack</option>`;
|
||||
var option = '<option value="">Please select rack</option>';
|
||||
$.each(res.output, function(key, item) {
|
||||
option += `<option value='${item}'>${item}</option>`;
|
||||
})
|
||||
$(`select[name='rack'][role='${type}']`).html(option);
|
||||
|
||||
if(!type) {
|
||||
$(`select[name='rack']`).html(option);
|
||||
}
|
||||
|
||||
if (callback) callback(); // ← add this
|
||||
});
|
||||
$(`select[name='rack'][role='${type}']`).html(option).prop('disabled', false);
|
||||
if (callback) callback();
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
// ── Reset from-location dropdowns ─────────────────────────────────────
|
||||
function reset_from_location() {
|
||||
$('#warehouse_from').html('<option value="">Select warehouse...</option>').prop('disabled', true);
|
||||
$('#zone_from').html('<option value="">Please select zone</option>').prop('disabled', true);
|
||||
$('#aisle_from').html('<option value="">Please select aisle</option>').prop('disabled', true);
|
||||
$('#rack_from').html('<option value="">Please select rack</option>').prop('disabled', true);
|
||||
}
|
||||
|
||||
|
||||
// ── Edit mode ─────────────────────────────────────────────────────────
|
||||
function retrieve_for_edit() {
|
||||
|
||||
<?php if(empty($_GET["id"])){ ?>
|
||||
@@ -289,54 +352,43 @@
|
||||
action: 'read',
|
||||
onSuccess: function(res) {
|
||||
|
||||
var type = 'from';
|
||||
var warehouse = res.output.ref.ref_warehouse;
|
||||
var data = res.output;
|
||||
var data = res.output;
|
||||
var from_wh = data.ref.ref_warehouse;
|
||||
var to_wh = data.ref_warehouse;
|
||||
|
||||
/**
|
||||
* From warehouse
|
||||
*/
|
||||
$(`select[name='warehouse'][role=${type}]`).val(warehouse).attr('disabled', true);
|
||||
// Lock lot/serial
|
||||
$('#lot_number').html(`<option value="${data.lot_number || ''}">${data.lot_number || '—'}</option>`)
|
||||
.val(data.lot_number || '').prop('disabled', true);
|
||||
$('#serial_number').html(`<option value="${data.serial_number || ''}">${data.serial_number || '—'}</option>`)
|
||||
.val(data.serial_number || '').prop('disabled', true);
|
||||
|
||||
retrieve_zone(type, function() {
|
||||
$(`select[name='zone'][role=${type}]`).val(data.zone).attr('disabled', true);
|
||||
// From side
|
||||
$("select[name='warehouse'][role='from']").val(from_wh).prop('disabled', true);
|
||||
retrieve_zone('from', function() {
|
||||
$("select[name='zone'][role='from']").val(data.zone).prop('disabled', true);
|
||||
retrieve_aisle('from', function() {
|
||||
$("select[name='aisle'][role='from']").val(data.aisle).prop('disabled', true);
|
||||
retrieve_rack('from', function() {
|
||||
$("select[name='rack'][role='from']").val(data.rack).prop('disabled', true);
|
||||
|
||||
retrieve_aisle(type, function() {
|
||||
$(`select[name='aisle'][role=${type}]`).val(data.aisle).attr('disabled', true);
|
||||
|
||||
retrieve_rack(type, function() {
|
||||
$(`select[name='rack'][role=${type}]`).val(data.rack).attr('disabled', true);
|
||||
|
||||
type = 'to';
|
||||
warehouse = res.output.ref_warehouse;
|
||||
data = res.output.ref;
|
||||
|
||||
/**
|
||||
* To warehouse
|
||||
*/
|
||||
$(`select[name='warehouse'][role=${type}]`).val(warehouse).attr('disabled', true);
|
||||
|
||||
retrieve_zone(type, function() {
|
||||
$(`select[name='zone'][role=${type}]`).val(data.zone).attr('disabled', true);
|
||||
|
||||
retrieve_aisle(type, function() {
|
||||
$(`select[name='aisle'][role=${type}]`).val(data.aisle).attr('disabled',true);
|
||||
|
||||
retrieve_rack(type, function() {
|
||||
$(`select[name='rack'][role=${type}]`).val(data.rack).attr('disabled',true);
|
||||
// To side
|
||||
$("select[name='warehouse'][role='to']").val(to_wh).prop('disabled', true);
|
||||
retrieve_zone('to', function() {
|
||||
$("select[name='zone'][role='to']").val(data.ref.zone).prop('disabled', true);
|
||||
retrieve_aisle('to', function() {
|
||||
$("select[name='aisle'][role='to']").val(data.ref.aisle).prop('disabled', true);
|
||||
retrieve_rack('to', function() {
|
||||
$("select[name='rack'][role='to']").val(data.ref.rack).prop('disabled', true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
var data = res.output;
|
||||
|
||||
// retrieve for edit
|
||||
// Common fields
|
||||
$.each(data, function(key, item) {
|
||||
if (!['warehouse', 'zone', 'aisle', 'rack'].includes(key)) {
|
||||
if (!['warehouse', 'zone', 'aisle', 'rack', 'lot_number', 'serial_number', 'ref'].includes(key)) {
|
||||
$(`#${key}`).val(item);
|
||||
}
|
||||
});
|
||||
@@ -346,52 +398,76 @@
|
||||
.attr('data-id', data.contact_id);
|
||||
|
||||
$('#product_name').val(data.product_name);
|
||||
$('#product_sku').attr('secondary', data.product_sku);
|
||||
|
||||
$('#product_sku,#quantity').prop('disabled', true);
|
||||
|
||||
$(`button[type=submit]`).text('Update');
|
||||
$(`button[type=reset]`).hide();
|
||||
$('#product_sku').attr('secondary', data.product_sku).prop('disabled', true);
|
||||
|
||||
$('button[type=submit]').text('Update');
|
||||
$('button[type=reset]').hide();
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ── Boot ─────────────────────────────────────────────────────────────
|
||||
$(async function() {
|
||||
try {
|
||||
await retrieve_warehouse('from');
|
||||
await retrieve_warehouse('to');
|
||||
await retrieve_for_edit();
|
||||
} catch (e) {
|
||||
} catch(e) {
|
||||
console.log(e);
|
||||
}
|
||||
});
|
||||
|
||||
$(`#product_sku`).on('blur', function() {
|
||||
$(async function() {
|
||||
try {
|
||||
await retrieve_warehouse('from');
|
||||
await retrieve_warehouse('to');
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// ── Change handlers (create mode only) ───────────────────────────────
|
||||
<?php if(empty($_GET["id"])){ ?>
|
||||
$("select[name='warehouse']").change(function() {
|
||||
retrieve_zone($(this).attr('role'));
|
||||
|
||||
// SKU selected → load lots + from-warehouse
|
||||
$('input#product_sku').on('blur', function() {
|
||||
var sku = $(this).attr('secondary');
|
||||
if (!sku) return;
|
||||
retrieve_active_lot();
|
||||
retrieve_warehouse('from');
|
||||
});
|
||||
|
||||
$("select[name='zone']").change(function() {
|
||||
retrieve_aisle($(this).attr('role'));
|
||||
// Lot selected → reload serials + reset from-location
|
||||
$('#lot_number').on('change', function() {
|
||||
retrieve_active_serial();
|
||||
retrieve_warehouse('from');
|
||||
});
|
||||
|
||||
$("select[name='aisle']").change(function() {
|
||||
retrieve_rack($(this).attr('role'));
|
||||
// Serial selected → reload from-warehouse
|
||||
$('#serial_number').on('change', function() {
|
||||
retrieve_warehouse('from');
|
||||
});
|
||||
|
||||
// From warehouse → from zone
|
||||
$("select[name='warehouse'][role='from']").on('change', function() {
|
||||
retrieve_zone('from');
|
||||
});
|
||||
|
||||
// From zone → from aisle
|
||||
$("select[name='zone'][role='from']").on('change', function() {
|
||||
retrieve_aisle('from');
|
||||
});
|
||||
|
||||
// From aisle → from rack
|
||||
$("select[name='aisle'][role='from']").on('change', function() {
|
||||
retrieve_rack('from');
|
||||
});
|
||||
|
||||
// To warehouse → to zone
|
||||
$("select[name='warehouse'][role='to']").on('change', function() {
|
||||
retrieve_zone('to');
|
||||
});
|
||||
|
||||
// To zone → to aisle
|
||||
$("select[name='zone'][role='to']").on('change', function() {
|
||||
retrieve_aisle('to');
|
||||
});
|
||||
|
||||
// To aisle → to rack
|
||||
$("select[name='aisle'][role='to']").on('change', function() {
|
||||
retrieve_rack('to');
|
||||
});
|
||||
|
||||
<?php } ?>
|
||||
|
||||
@@ -48,6 +48,8 @@
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Product</th>
|
||||
<th>Lot Number</th>
|
||||
<th>Serial Number</th>
|
||||
<th>Quantity</th>
|
||||
<th>Zone</th>
|
||||
<th>Aisle</th>
|
||||
@@ -124,6 +126,8 @@
|
||||
body += `<tr>
|
||||
<td class="py-3">${format_date(item["date"])}</td>
|
||||
<td class="py-3">${item["product_sku"]}: ${item['product_name']}</td>
|
||||
<td class="py-3">${item['lot_number'] || '<span class="text-muted">—</span>'}</td>
|
||||
<td class="py-3">${item['serial_number'] || '<span class="text-muted">—</span>'}</td>
|
||||
<td class="py-3">${item['quantity']}</td>
|
||||
<td class="py-3">${item['zone']}</td>
|
||||
<td class="py-3">${item['aisle']}</td>
|
||||
|
||||
@@ -48,6 +48,8 @@
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Product</th>
|
||||
<th>Lot Number</th>
|
||||
<th>Serial Number</th>
|
||||
<th>Quantity</th>
|
||||
<th>Zone</th>
|
||||
<th>Aisle</th>
|
||||
@@ -124,6 +126,8 @@
|
||||
body += `<tr>
|
||||
<td class="py-3">${format_date(item["date"])}</td>
|
||||
<td class="py-3">${item["product_sku"]}: ${item['product_name']}</td>
|
||||
<td class="py-3">${item['lot_number'] || '<span class="text-muted">—</span>'}</td>
|
||||
<td class="py-3">${item['serial_number'] || '<span class="text-muted">—</span>'}</td>
|
||||
<td class="py-3">${item['quantity']}</td>
|
||||
<td class="py-3">${item['zone']}</td>
|
||||
<td class="py-3">${item['aisle']}</td>
|
||||
|
||||
+197
-355
@@ -2,183 +2,151 @@
|
||||
session_start();
|
||||
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">
|
||||
<div class="row ">
|
||||
<div class="col-12">
|
||||
<div class="mb-6">
|
||||
<h1 class="fs-3 mb-0" style="float: left;">Stock Overview</h1>
|
||||
<div class="d-flex gap-2" style="float: right;">
|
||||
<div class="mb-3 col-lg-6">
|
||||
<input class="form-control" value='Warehouse' disabled>
|
||||
</div>
|
||||
<div class="mb-3 col-lg-6">
|
||||
<select name="warehouse" id="warehouse" class="form-select" required></select>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<!-- 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">Stock Overview</h1>
|
||||
<p class="mb-0">Warehouse performance and stock summary</p>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<label class="mb-0 text-nowrap fw-semibold">Warehouse</label>
|
||||
<select id="warehouse" class="form-select" style="min-width:200px;" required>
|
||||
<option value="">Select warehouse...</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-5 mb-5">
|
||||
<div class="col-xl-3 col-md-6 col-12">
|
||||
<div class="card" id="capacity">
|
||||
<div class="card-body p-5">
|
||||
<div class="d-flex justify-content-between">
|
||||
<div>
|
||||
<span class="mb-4 d-block">Total Capacity</span>
|
||||
<h3 class="fw-bold mb-0 text-info">0</h3>
|
||||
<p class="mb-0 small text-secondary">Across all warehouses</p>
|
||||
</div>
|
||||
<div class="">
|
||||
|
||||
<!-- Main content -->
|
||||
<div id="overview_content">
|
||||
|
||||
<!-- Stat cards -->
|
||||
<div class="row g-5 mb-5">
|
||||
<div class="col-xl-3 col-md-6 col-12">
|
||||
<div class="card" style="position:relative;">
|
||||
<div class="card-body p-5">
|
||||
<div class="d-flex justify-content-between">
|
||||
<div>
|
||||
<span class="mb-4 d-block">Total Capacity</span>
|
||||
<h3 class="fw-bold mb-0 text-info" id="stat_capacity">—</h3>
|
||||
<p class="mb-0 small text-secondary" id="stat_capacity_sub">Racks in warehouse</p>
|
||||
</div>
|
||||
<i class="ti ti-box-seam fs-3 text-muted"></i>
|
||||
</div>
|
||||
</div>
|
||||
<!-- overlay -->
|
||||
<div class="card-loader-overlay">
|
||||
<div class="card-loader-overlay" id="overlay_capacity">
|
||||
<div class="spinner-border spinner-border-sm text-primary"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-xl-3 col-md-6 col-12">
|
||||
<div class="card " id="total_stock_in">
|
||||
<div class="card-body p-5">
|
||||
<div class="d-flex justify-content-between">
|
||||
<div>
|
||||
<span class="mb-4 d-block">Total Stock In</span>
|
||||
<h3 class="fw-bold mb-0 text-success">+456</h3>
|
||||
<p class="mb-0 small text-secondary">Unit added</p>
|
||||
</div>
|
||||
<div class="">
|
||||
<div class="col-xl-3 col-md-6 col-12">
|
||||
<div class="card" style="position:relative;">
|
||||
<div class="card-body p-5">
|
||||
<div class="d-flex justify-content-between">
|
||||
<div>
|
||||
<span class="mb-4 d-block">Total Stock In</span>
|
||||
<h3 class="fw-bold mb-0 text-success" id="stat_stock_in">—</h3>
|
||||
<p class="mb-0 small text-secondary">Units added</p>
|
||||
</div>
|
||||
<i class="ti ti-trending-up fs-3 text-success"></i>
|
||||
</div>
|
||||
</div>
|
||||
<!-- overlay -->
|
||||
<div class="card-loader-overlay">
|
||||
<div class="card-loader-overlay" id="overlay_stock_in">
|
||||
<div class="spinner-border spinner-border-sm text-primary"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
<div class="col-xl-3 col-md-6 col-12">
|
||||
<div class="card " id="total_stock_out">
|
||||
<div class="card-body p-5">
|
||||
<div class="d-flex justify-content-between">
|
||||
<div>
|
||||
<span class="mb-4 d-block">Total Stock Out</span>
|
||||
<h3 class="fw-bold mb-0 text-danger">-289</h3>
|
||||
<p class="mb-0 small text-secondary">Unit removed</p>
|
||||
</div>
|
||||
<div class="">
|
||||
<div class="col-xl-3 col-md-6 col-12">
|
||||
<div class="card" style="position:relative;">
|
||||
<div class="card-body p-5">
|
||||
<div class="d-flex justify-content-between">
|
||||
<div>
|
||||
<span class="mb-4 d-block">Total Stock Out</span>
|
||||
<h3 class="fw-bold mb-0 text-danger" id="stat_stock_out">—</h3>
|
||||
<p class="mb-0 small text-secondary">Units removed</p>
|
||||
</div>
|
||||
<i class="ti ti-trending-down fs-3 text-danger"></i>
|
||||
</div>
|
||||
</div>
|
||||
<!-- overlay -->
|
||||
<div class="card-loader-overlay">
|
||||
<div class="card-loader-overlay" id="overlay_stock_out">
|
||||
<div class="spinner-border spinner-border-sm text-primary"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
<div class="col-xl-3 col-md-6 col-12">
|
||||
<div class="card " id="low_stock">
|
||||
<div class="card-body p-5">
|
||||
<div class="d-flex justify-content-between">
|
||||
<div>
|
||||
<span class="mb-4 d-block">Low Stock Alerts</span>
|
||||
<h3 class="fw-bold mb-0 text-warning">12</h3>
|
||||
<p class="mb-0 small text-secondary">Items need attention</p>
|
||||
</div>
|
||||
<div class="">
|
||||
<div class="col-xl-3 col-md-6 col-12">
|
||||
<div class="card" style="position:relative;">
|
||||
<div class="card-body p-5">
|
||||
<div class="d-flex justify-content-between">
|
||||
<div>
|
||||
<span class="mb-4 d-block">Low Stock Alerts</span>
|
||||
<h3 class="fw-bold mb-0 text-warning" id="stat_low_stock">—</h3>
|
||||
<p class="mb-0 small text-secondary">Items need attention</p>
|
||||
</div>
|
||||
<i class="ti ti-alert-triangle fs-3 text-warning"></i>
|
||||
</div>
|
||||
</div>
|
||||
<!-- overlay -->
|
||||
<div class="card-loader-overlay">
|
||||
<div class="card-loader-overlay" id="overlay_low_stock">
|
||||
<div class="spinner-border spinner-border-sm text-primary"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-5 mb-5">
|
||||
<div class="col-lg-6 col-12">
|
||||
|
||||
<div class="card" id="stock_movement">
|
||||
<div class="card-body p-5">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
|
||||
<!-- Charts -->
|
||||
<div class="row g-5 mb-5">
|
||||
<div class="col-lg-6 col-12">
|
||||
<div class="card" style="position:relative;">
|
||||
<div class="card-body p-5">
|
||||
<h5 class="mb-0">Stock Movement</h5>
|
||||
|
||||
<div id="ics_stockMovementChart"></div>
|
||||
</div>
|
||||
<div>
|
||||
<div id="stockMovementChart"></div>
|
||||
</div>
|
||||
<!-- overlay -->
|
||||
<div class="card-loader-overlay">
|
||||
<div class="card-loader-overlay" id="overlay_movement">
|
||||
<div class="spinner-border spinner-border-sm text-primary"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="col-lg-6 col-12">
|
||||
|
||||
<div class="card" id="stock_trend">
|
||||
<div class="card-body p-5">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
|
||||
<div class="col-lg-6 col-12">
|
||||
<div class="card" style="position:relative;">
|
||||
<div class="card-body p-5">
|
||||
<h5 class="mb-0">Stock Level Trend</h5>
|
||||
|
||||
<div id="ics_stockLevelTrendChart"></div>
|
||||
</div>
|
||||
<div>
|
||||
<div id="stockLevelTrendChart"></div>
|
||||
</div>
|
||||
<!-- overlay -->
|
||||
<div class="card-loader-overlay">
|
||||
<div class="card-loader-overlay" id="overlay_trend">
|
||||
<div class="spinner-border spinner-border-sm text-primary"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card" id="stock_activity">
|
||||
<div class="card-body p-5">
|
||||
<h3 class="fs-5">Recent Stock Activity</h3>
|
||||
|
||||
<!-- overlay -->
|
||||
<div class="card-loader-overlay">
|
||||
<!-- Recent activity -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card" style="position:relative;">
|
||||
<div class="card-body p-5">
|
||||
<h3 class="fs-5 mb-4">Recent Stock Activity</h3>
|
||||
<div id="activity_list"></div>
|
||||
</div>
|
||||
<div class="card-loader-overlay" id="overlay_activity">
|
||||
<div class="spinner-border spinner-border-sm text-primary"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /overview_content -->
|
||||
|
||||
</div>
|
||||
</main>
|
||||
@@ -186,233 +154,120 @@
|
||||
<?php require "../include_ending.php";?>
|
||||
|
||||
<script>
|
||||
var stockMovementChart = null;
|
||||
var stockLevelTrendChart = null;
|
||||
|
||||
|
||||
function retrieve_warehouse_stats() {
|
||||
|
||||
return ajax_request({
|
||||
url: "<?php echo $server_url?>ics/api/engine_report/warehouse_stats.php",
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
queueLock: false, // Allow this request to be sent even if another request is processing
|
||||
action: 'read',
|
||||
onSuccess: function(res) {
|
||||
|
||||
$("#capacity h3").text(format_number(res.total_capacity, 2));
|
||||
$("#capacity p").text("Accross " + format_number(res.total_warehouse) + " warehouses");
|
||||
$("#capacity .card-loader-overlay").hide();
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
// ── Show all overlays before fetching ────────────────────────────────
|
||||
function show_overlays() {
|
||||
$('#overlay_capacity, #overlay_stock_in, #overlay_stock_out, #overlay_low_stock, #overlay_movement, #overlay_trend, #overlay_activity').show();
|
||||
}
|
||||
|
||||
|
||||
function retrieve_warehouse_balance() {
|
||||
// ── Load all overview data in a single call ───────────────────────────
|
||||
function retrieve_overview() {
|
||||
|
||||
var warehouse_id = $('#warehouse').val();
|
||||
if (!warehouse_id) return;
|
||||
|
||||
show_overlays();
|
||||
|
||||
return ajax_request({
|
||||
url: "<?php echo $server_url?>ics/api/engine_report/warehouse_balance.php",
|
||||
url: "<?php echo $server_url?>ics/api/engine_report/warehouse_overview.php",
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
debugMode: 0,
|
||||
queueLock: false, // Allow this request to be sent even if another request is processing
|
||||
action: 'read',
|
||||
onSuccess: function(res) {
|
||||
|
||||
$("#total_stock_in h3").text(format_number(res.output.total_in, 2));
|
||||
$("#total_stock_in p").text("Accross " + format_number(res.output.total_warehouse) + " warehouses");
|
||||
$("#total_stock_in .card-loader-overlay").hide();
|
||||
|
||||
$("#total_stock_out h3").text(format_number(res.output.total_out, 2));
|
||||
$("#total_stock_out p").text("Accross " + format_number(res.output.total_warehouse) + " warehouses");
|
||||
$("#total_stock_out .card-loader-overlay").hide();
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
function retrieve_product_stats() {
|
||||
|
||||
return ajax_request({
|
||||
url: "<?php echo $server_url?>ics/api/engine_report/product_stats.php",
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
debugMode: 0,
|
||||
queueLock: false, // Allow this request to be sent even if another request is processing
|
||||
action: 'read',
|
||||
onSuccess: function(res) {
|
||||
|
||||
$("#low_stock h3").text(format_number(res.low_stock, 2));
|
||||
$("#low_stock .card-loader-overlay").hide();
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
function retrieve_stock_movement() {
|
||||
|
||||
return ajax_request({
|
||||
url: "<?php echo $server_url?>ics/api/engine_report/stock_movement.php",
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
debugMode: 0,
|
||||
queueLock: false, // Allow this request to be sent even if another request is processing
|
||||
action: 'read',
|
||||
onSuccess: function(res) {
|
||||
onSuccess: async function(res) {
|
||||
|
||||
var d = res.output;
|
||||
|
||||
// Clear previous chart safely
|
||||
if (stockMovementChart && typeof stockMovementChart.destroy === 'function') {
|
||||
stockMovementChart.destroy();
|
||||
// ── Stats ──────────────────────────────────────────────────────
|
||||
$('#stat_capacity').text(format_number(d.total_capacity));
|
||||
$('#stat_capacity_sub').text(format_number(d.space_used) + ' occupied');
|
||||
$('#stat_stock_in').text(format_number(d.total_in, 2));
|
||||
$('#stat_stock_out').text(format_number(d.total_out, 2));
|
||||
$('#stat_low_stock').text(format_number(d.low_stock));
|
||||
|
||||
$('#overlay_capacity, #overlay_stock_in, #overlay_stock_out, #overlay_low_stock').hide();
|
||||
|
||||
// ── Stock Movement chart ───────────────────────────────────────
|
||||
var mv = d.stock_movement;
|
||||
if (stockMovementChart) {
|
||||
await stockMovementChart.updateOptions({ xaxis: { categories: mv.labels || [] } }, false, false);
|
||||
await stockMovementChart.updateSeries([
|
||||
{ name: 'Stock In', data: mv.stock_in || [] },
|
||||
{ name: 'Stock Out', data: mv.stock_out || [] }
|
||||
]);
|
||||
} else {
|
||||
// First load: clear any chart rendered by main.js
|
||||
$("#stockMovementChart").empty();
|
||||
// Empty container to destroy any instance pre-rendered by main.js
|
||||
$('#ics_stockMovementChart').empty();
|
||||
stockMovementChart = new ApexCharts(document.querySelector('#ics_stockMovementChart'), {
|
||||
series: [
|
||||
{ name: 'Stock In', data: mv.stock_in || [] },
|
||||
{ name: 'Stock Out', data: mv.stock_out || [] }
|
||||
],
|
||||
chart: { type: 'bar', height: 350, toolbar: { show: false } },
|
||||
plotOptions: { bar: { horizontal: false, columnWidth: '55%', borderRadius: 5, borderRadiusApplication: 'end' } },
|
||||
colors: ['#00C951', '#E66239'],
|
||||
dataLabels: { enabled: false },
|
||||
stroke: { show: true, width: 2, colors: ['transparent'] },
|
||||
xaxis: { categories: mv.labels || [] },
|
||||
yaxis: { title: { text: 'Quantity' } },
|
||||
fill: { opacity: 1 },
|
||||
tooltip: { y: { formatter: function(a) { return Number(a).toLocaleString() + ' units'; } } }
|
||||
});
|
||||
await stockMovementChart.render();
|
||||
}
|
||||
$('#overlay_movement').hide();
|
||||
|
||||
var n = {
|
||||
series: [
|
||||
{ name: "Stock In", data: d.stock_in || [] },
|
||||
{ name: "Stock Out", data: d.stock_out || [] }
|
||||
],
|
||||
chart: { type: "bar", height: 350, toolbar: { show: !1 } },
|
||||
plotOptions: { bar: { horizontal: !1, columnWidth: "55%", borderRadius: 5, borderRadiusApplication: "end" } },
|
||||
colors: ["#E66239", "#00C951"],
|
||||
dataLabels: { enabled: !1 },
|
||||
stroke: { show: !0, width: 2, colors: ["transparent"] },
|
||||
xaxis: { categories: d.labels || [] },
|
||||
yaxis: { title: { text: "Quantity" } },
|
||||
fill: { opacity: 1 },
|
||||
tooltip: { y: { formatter: function(a) { return Number(a).toLocaleString() + " units" } } }
|
||||
};
|
||||
|
||||
stockMovementChart = new ApexCharts(document.querySelector("#stockMovementChart"), n);
|
||||
stockMovementChart.render();
|
||||
|
||||
$("#stockMovementChart").closest(".card").find(".card-loader-overlay").hide();
|
||||
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
function retrieve_stock_trend() {
|
||||
|
||||
return ajax_request({
|
||||
url: "<?php echo $server_url?>ics/api/engine_report/stock_trend.php",
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
debugMode: 0,
|
||||
queueLock: false, // Allow this request to be sent even if another request is processing
|
||||
action: 'read',
|
||||
onSuccess: function(res) {
|
||||
|
||||
var d = res.output;
|
||||
|
||||
// Clear previous chart safely
|
||||
if (stockLevelTrendChart && typeof stockLevelTrendChart.destroy === 'function') {
|
||||
stockLevelTrendChart.destroy();
|
||||
// ── Stock Trend chart ──────────────────────────────────────────
|
||||
var tr = d.stock_trend;
|
||||
if (stockLevelTrendChart) {
|
||||
await stockLevelTrendChart.updateOptions({ xaxis: { categories: tr.labels || [] } }, false, false);
|
||||
await stockLevelTrendChart.updateSeries([
|
||||
{ name: 'Stock Balance', data: tr.balance || [] }
|
||||
]);
|
||||
} else {
|
||||
$("#stockLevelTrendChart").empty();
|
||||
// Empty container to destroy any instance pre-rendered by main.js
|
||||
$('#ics_stockLevelTrendChart').empty();
|
||||
stockLevelTrendChart = new ApexCharts(document.querySelector('#ics_stockLevelTrendChart'), {
|
||||
series: [{ name: 'Stock Balance', data: tr.balance || [] }],
|
||||
chart: { height: 350, type: 'area', toolbar: { show: false } },
|
||||
colors: ['#E66239'],
|
||||
dataLabels: { enabled: false },
|
||||
stroke: { curve: 'smooth', width: 3 },
|
||||
fill: {
|
||||
type: 'gradient',
|
||||
gradient: { shadeIntensity: 1, inverseColors: false, opacityFrom: 0.45, opacityTo: 0.05, stops: [20, 100] }
|
||||
},
|
||||
xaxis: { categories: tr.labels || [], axisBorder: { show: false }, axisTicks: { show: false } },
|
||||
yaxis: { title: { text: 'Quantity' }, labels: { formatter: function(a) { return Number(a).toLocaleString(); } } },
|
||||
tooltip: { y: { formatter: function(a) { return Number(a).toLocaleString() + ' units'; } } },
|
||||
grid: { show: true, borderColor: '#e2e8f0' }
|
||||
});
|
||||
await stockLevelTrendChart.render();
|
||||
}
|
||||
$('#overlay_trend').hide();
|
||||
|
||||
var n = {
|
||||
series: [
|
||||
{ name: "Stock Balance", data: d.balance || [] }
|
||||
],
|
||||
chart: { height: 350, type: "area", toolbar: { show: !1 } },
|
||||
colors: ["#E66239"],
|
||||
dataLabels: { enabled: !1 },
|
||||
stroke: { curve: "smooth", width: 3 },
|
||||
fill: {
|
||||
type: "gradient",
|
||||
gradient: {
|
||||
shadeIntensity: 1,
|
||||
inverseColors: !1,
|
||||
opacityFrom: 0.45,
|
||||
opacityTo: 0.05,
|
||||
stops: [20, 100]
|
||||
}
|
||||
},
|
||||
xaxis: {
|
||||
categories: d.labels || [],
|
||||
axisBorder: { show: !1 },
|
||||
axisTicks: { show: !1 }
|
||||
},
|
||||
yaxis: {
|
||||
title: { text: "Quantity" },
|
||||
labels: {
|
||||
formatter: function(a) {
|
||||
return Number(a).toLocaleString();
|
||||
}
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
y: {
|
||||
formatter: function(a) {
|
||||
return Number(a).toLocaleString() + " units";
|
||||
}
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
show: !0,
|
||||
borderColor: "#e2e8f0"
|
||||
}
|
||||
};
|
||||
|
||||
stockLevelTrendChart = new ApexCharts(document.querySelector("#stockLevelTrendChart"), n);
|
||||
stockLevelTrendChart.render();
|
||||
|
||||
$("#stockLevelTrendChart").closest(".card").find(".card-loader-overlay").hide();
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
function retrieve_stock_activity() {
|
||||
|
||||
return ajax_request({
|
||||
url: "<?php echo $server_url?>ics/api/engine_report/stock_activity.php",
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
debugMode: 0,
|
||||
queueLock: false, // Allow this request to be sent even if another request is processing
|
||||
action: 'read',
|
||||
onSuccess: function(res) {
|
||||
|
||||
var d = res.output;
|
||||
var $container = $("#stock_activity .card-body");
|
||||
|
||||
// Keep the title, remove old activity rows
|
||||
$container.find(".stock-activity-item").remove();
|
||||
$container.find(".stock-activity-empty").remove();
|
||||
// ── Recent activity ────────────────────────────────────────────
|
||||
var $list = $('#activity_list');
|
||||
$list.empty();
|
||||
|
||||
if (!d.activities || d.activities.length === 0) {
|
||||
$container.append('<div class="stock-activity-empty text-center text-muted py-4">No recent activity</div>');
|
||||
$("#stock_activity .card-loader-overlay").hide();
|
||||
$list.html('<div class="text-center text-muted py-4">No recent activity</div>');
|
||||
$('#overlay_activity').hide();
|
||||
return;
|
||||
}
|
||||
|
||||
var html = '';
|
||||
|
||||
d.activities.forEach(function(item) {
|
||||
var isIn = item.direction === 'in';
|
||||
var colorCls = isIn ? 'success' : 'danger';
|
||||
var icon = isIn ? 'ti-trending-up' : 'ti-trending-down';
|
||||
var sign = isIn ? '+' : '-';
|
||||
var label = isIn ? 'Stock In' : 'Stock Out';
|
||||
var qty = Number(item.qty).toLocaleString();
|
||||
var timeAgo = time_ago(item.date);
|
||||
|
||||
html += '<div class="border-bottom d-flex justify-content-between align-items-center mb-4 pb-3 stock-activity-item">'
|
||||
html += '<div class="border-bottom d-flex justify-content-between align-items-center mb-4 pb-3">'
|
||||
+ '<div class="d-flex gap-3 align-items-center py-3">'
|
||||
+ '<div class="icon-shape icon-md bg-' + colorCls + ' bg-opacity-10 text-' + colorCls + ' rounded-2">'
|
||||
+ '<i class="ti ' + icon + '"></i>'
|
||||
@@ -423,87 +278,74 @@
|
||||
+ '</div>'
|
||||
+ '</div>'
|
||||
+ '<div class="d-flex flex-column align-items-end">'
|
||||
+ '<span class="fs-5 text-' + colorCls + '">' + sign + qty + '</span>'
|
||||
+ '<span class="small text-muted">' + timeAgo + '</span>'
|
||||
+ '<span class="fs-5 text-' + colorCls + '">' + sign + Number(item.qty).toLocaleString() + '</span>'
|
||||
+ '<span class="small text-muted">' + time_ago(item.date) + '</span>'
|
||||
+ '</div>'
|
||||
+ '</div>';
|
||||
});
|
||||
|
||||
$container.append(html);
|
||||
|
||||
$("#stock_activity .card-loader-overlay").hide();
|
||||
|
||||
$list.html(html);
|
||||
$('#overlay_activity').hide();
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
// ── Load warehouse dropdown ───────────────────────────────────────────
|
||||
function retrieve_warehouse() {
|
||||
|
||||
return ajax_request({
|
||||
url: "<?php echo $server_url?>inventory/api/engine/warehouse.php",
|
||||
url: "<?php echo $server_url?>ics/api/engine/retrieve_warehouse.php",
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
action: 'read',
|
||||
onSuccess: function(res) {
|
||||
var option = ``;
|
||||
var option = '';
|
||||
$.each(res.output, function(key, item) {
|
||||
option += `<option value='${item.id}'>${item.warehouse_name}</option>`;
|
||||
})
|
||||
$(`select#warehouse`).html(option);
|
||||
});
|
||||
$('#warehouse').html(option);
|
||||
|
||||
// Auto-select first warehouse on page load
|
||||
if (res.output && res.output.length > 0) {
|
||||
$('#warehouse').val(res.output[0].id);
|
||||
retrieve_overview();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ── Helper: escape HTML ────────────────────────────────────────────
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
|
||||
// ── Helper: time ago from datetime string ─────────────────────────
|
||||
function time_ago(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
var now = new Date();
|
||||
var past = new Date(dateStr.replace(/-/g, '/')); // cross-browser parse
|
||||
var diff = Math.floor((now - past) / 1000); // seconds
|
||||
|
||||
if (diff < 60) return 'Just now';
|
||||
if (diff < 3600) return Math.floor(diff / 60) + ' min ago';
|
||||
if (diff < 86400) return Math.floor(diff / 3600) + ' hour ago';
|
||||
if (diff < 2592000) return Math.floor(diff / 86400) + ' day ago';
|
||||
if (diff < 31536000) return Math.floor(diff / 2592000) + ' month ago';
|
||||
var diff = Math.floor((new Date() - new Date(dateStr.replace(/-/g,'/'))) / 1000);
|
||||
if (diff < 60) return 'Just now';
|
||||
if (diff < 3600) return Math.floor(diff / 60) + ' min ago';
|
||||
if (diff < 86400) return Math.floor(diff / 3600) + ' hour ago';
|
||||
if (diff < 2592000) return Math.floor(diff / 86400) + ' day ago';
|
||||
if (diff < 31536000) return Math.floor(diff / 2592000) + ' month ago';
|
||||
return Math.floor(diff / 31536000) + ' year ago';
|
||||
}
|
||||
|
||||
|
||||
$(function() {
|
||||
|
||||
retrieve_warehouse().done(function() {
|
||||
retrieve_stock_movement();
|
||||
retrieve_stock_trend();
|
||||
retrieve_stock_activity();
|
||||
});
|
||||
|
||||
retrieve_warehouse_stats();
|
||||
retrieve_warehouse_balance();
|
||||
retrieve_product_stats();
|
||||
|
||||
$(`#warehouse`).change(function() {
|
||||
retrieve_stock_movement();
|
||||
retrieve_stock_trend();
|
||||
retrieve_stock_activity();
|
||||
});
|
||||
// ── Boot ─────────────────────────────────────────────────────────────
|
||||
$(async function() {
|
||||
try {
|
||||
await retrieve_warehouse();
|
||||
} catch(e) {
|
||||
console.log(e);
|
||||
}
|
||||
});
|
||||
|
||||
$('#warehouse').on('change', function() {
|
||||
retrieve_overview();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -33,6 +33,13 @@
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a class="nav-link" href="<?php echo $server_url?>dashboard/low_stock_products.php">
|
||||
<i class="ti ti-alert-triangle"></i>
|
||||
<span class="nav-text">Low Stock Products</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<!-- Product -->
|
||||
<li class="nav-text-space">
|
||||
<small class="nav-text text-muted">Master Data</small>
|
||||
@@ -93,14 +100,7 @@
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a class="nav-link" href="<?php echo $server_url?>ics/low-stock-products.php">
|
||||
<i class="ti ti-alert-triangle"></i>
|
||||
<span class="nav-text">Low Stock Products</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a class="nav-link" href="<?php echo $server_url?>ics/expired-stock.php">
|
||||
<a class="nav-link" href="<?php echo $server_url?>ics/expired_stock.php">
|
||||
<i class="ti ti-clock"></i>
|
||||
<span class="nav-text">Expired Stock</span>
|
||||
</a>
|
||||
|
||||
@@ -6,6 +6,27 @@
|
||||
|
||||
$id = (int)$data['id'];
|
||||
|
||||
// ── Business rule: min_stock < reorder_point ──────────────────────────
|
||||
// The warning tier (balance between min_stock and reorder_point) only
|
||||
// exists when min_stock is strictly less than reorder_point. Enforcing
|
||||
// this invariant keeps the low-stock report meaningful.
|
||||
$min_stock = (float) ($data['min_stock'] ?? 0);
|
||||
$reorder_point = (float) ($data['reorder_point'] ?? 0);
|
||||
|
||||
if ($min_stock < 0 || $reorder_point < 0) {
|
||||
$answer["success"] = 0;
|
||||
$answer["message"] = "Min stock and reorder point cannot be negative.";
|
||||
http_response_code(400);
|
||||
exit(json_encode($answer));
|
||||
}
|
||||
|
||||
if ($min_stock >= $reorder_point) {
|
||||
$answer["success"] = 0;
|
||||
$answer["message"] = "Min stock ({$min_stock}) must be less than reorder point ({$reorder_point}).";
|
||||
http_response_code(400);
|
||||
exit(json_encode($answer));
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
/** 1. FILE HANDLING **/
|
||||
|
||||
@@ -45,11 +45,15 @@
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label for="min_stock" class="form-label">Min Stock</label>
|
||||
<input type="number" class="form-control" id="min_stock" placeholder="0" required>
|
||||
<input type="number" class="form-control" id="min_stock" placeholder="0" min="0" required>
|
||||
<div class="invalid-feedback" id="min_stock_error"></div>
|
||||
<small class="text-muted" id="min_stock_hint">Safety floor</small>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label for="reorder_point" class="form-label">Reorder Point</label>
|
||||
<input type="number" class="form-control" id="reorder_point" placeholder="0" required>
|
||||
<input type="number" class="form-control" id="reorder_point" placeholder="0" min="0" required>
|
||||
<div class="invalid-feedback" id="reorder_point_error"></div>
|
||||
<small class="text-muted" id="reorder_point_hint">Must be greater than min stock</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -203,6 +207,46 @@
|
||||
console.log(e);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
function validate_stock_thresholds() {
|
||||
|
||||
var min_stock = parseFloat($('#min_stock').val() || 0);
|
||||
var reorder_point = parseFloat($('#reorder_point').val() || 0);
|
||||
|
||||
// Reset both fields to clean state
|
||||
$('#min_stock, #reorder_point').removeClass('is-invalid');
|
||||
$('#min_stock_error, #reorder_point_error').text('');
|
||||
$('#min_stock_hint, #reorder_point_hint').show();
|
||||
|
||||
// Helper: mark a field invalid and hide its hint
|
||||
function flag(field, msg) {
|
||||
$('#' + field).addClass('is-invalid');
|
||||
$('#' + field + '_error').text(msg);
|
||||
$('#' + field + '_hint').hide();
|
||||
}
|
||||
|
||||
if (min_stock < 0) {
|
||||
flag('min_stock', 'Cannot be negative.');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (reorder_point < 0) {
|
||||
flag('reorder_point', 'Cannot be negative.');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (min_stock >= reorder_point) {
|
||||
flag('reorder_point', 'Must be greater than min stock (' + min_stock + ').');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Live feedback as the user types
|
||||
$('#min_stock, #reorder_point').on('input', validate_stock_thresholds);
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
@@ -41,19 +41,27 @@
|
||||
<div class="row">
|
||||
<div class="col-md-3 mb-3">
|
||||
<label for="aisle_from" class="form-label">Aisle From</label>
|
||||
<input type="text" class="form-control" id="aisle_from" placeholder="Enter aisle form" required>
|
||||
<input type="text" class="form-control" id="aisle_from" placeholder="e.g. 1 or A" required
|
||||
pattern="^([0-9]+|[A-Za-z])$" title="Enter a number (e.g. 1) or a single letter (e.g. A)">
|
||||
<div class="form-text">Number (1–99) or letter (A–Z)</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label for="aisle_to" class="form-label">Aisle To</label>
|
||||
<input type="text" class="form-control" id="aisle_to" placeholder="Enter aisle to" required>
|
||||
<input type="text" class="form-control" id="aisle_to" placeholder="e.g. 5 or E" required
|
||||
pattern="^([0-9]+|[A-Za-z])$" title="Enter a number (e.g. 5) or a single letter (e.g. E)">
|
||||
<div class="form-text">Must be same type as Aisle From</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label for="rack_from" class="form-label">Rack From</label>
|
||||
<input type="text" class="form-control" id="rack_from" placeholder="Enter rack form" required>
|
||||
<input type="text" class="form-control" id="rack_from" placeholder="e.g. 1 or A" required
|
||||
pattern="^([0-9]+|[A-Za-z])$" title="Enter a number (e.g. 1) or a single letter (e.g. A)">
|
||||
<div class="form-text">Number (1–99) or letter (A–Z)</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label for="rack_to" class="form-label">Rack To</label>
|
||||
<input type="text" class="form-control" id="rack_to" placeholder="Enter rack to" required>
|
||||
<input type="text" class="form-control" id="rack_to" placeholder="e.g. 10 or J" required
|
||||
pattern="^([0-9]+|[A-Za-z])$" title="Enter a number (e.g. 10) or a single letter (e.g. J)">
|
||||
<div class="form-text">Must be same type as Rack From</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -85,6 +93,45 @@
|
||||
<script>
|
||||
function manage_storage() {
|
||||
|
||||
// Validate that aisle from/to and rack from/to are same type
|
||||
var af = $('#aisle_from').val().trim().toUpperCase();
|
||||
var at = $('#aisle_to').val().trim().toUpperCase();
|
||||
var rf = $('#rack_from').val().trim().toUpperCase();
|
||||
var rt = $('#rack_to').val().trim().toUpperCase();
|
||||
|
||||
var isNumeric = function(v) { return /^[0-9]+$/.test(v); };
|
||||
var isAlpha = function(v) { return /^[A-Z]$/.test(v); };
|
||||
|
||||
if (af && at) {
|
||||
if (isNumeric(af) !== isNumeric(at)) {
|
||||
alert('Aisle From and Aisle To must both be numbers or both be letters.');
|
||||
return;
|
||||
}
|
||||
if (isAlpha(af) && isAlpha(at) && af.charCodeAt(0) > at.charCodeAt(0)) {
|
||||
alert('Aisle From must be less than or equal to Aisle To.');
|
||||
return;
|
||||
}
|
||||
if (isNumeric(af) && isNumeric(at) && parseInt(af) > parseInt(at)) {
|
||||
alert('Aisle From must be less than or equal to Aisle To.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (rf && rt) {
|
||||
if (isNumeric(rf) !== isNumeric(rt)) {
|
||||
alert('Rack From and Rack To must both be numbers or both be letters.');
|
||||
return;
|
||||
}
|
||||
if (isAlpha(rf) && isAlpha(rt) && rf.charCodeAt(0) > rt.charCodeAt(0)) {
|
||||
alert('Rack From must be less than or equal to Rack To.');
|
||||
return;
|
||||
}
|
||||
if (isNumeric(rf) && isNumeric(rt) && parseInt(rf) > parseInt(rt)) {
|
||||
alert('Rack From must be less than or equal to Rack To.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
return ajax_request({
|
||||
url: "<?php echo $server_url?>inventory/api/engine/manage_storage.php",
|
||||
autoPrepare: true,
|
||||
|
||||
+202
-178
@@ -2,7 +2,6 @@
|
||||
session_start();
|
||||
require '../config.php';
|
||||
require '../include_header.php';
|
||||
|
||||
?>
|
||||
|
||||
<body>
|
||||
@@ -13,96 +12,87 @@
|
||||
<!-- MAIN CONTENT -->
|
||||
<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-5 gap-3">
|
||||
<div class="">
|
||||
<div>
|
||||
<h1 class="fs-3 mb-1">Products</h1>
|
||||
<p class="mb-0">Manage your products</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary stats -->
|
||||
<div class="row g-5 mb-5">
|
||||
<div class="col-xl-3 col-md-6 col-12">
|
||||
<div class="card">
|
||||
<div class="card-body p-6">
|
||||
<div class="d-flex align-items-start gap-3">
|
||||
<div class="icon-shape icon-lg bg-primary bg-opacity-10 text-primary rounded-2">
|
||||
<i class="ti ti-category fs-4 "></i>
|
||||
<i class="ti ti-category fs-4"></i>
|
||||
</div>
|
||||
<div class="">
|
||||
<div>
|
||||
<span>Total Categories</span>
|
||||
<h2 class="fw-bold mb-0" id="total_category">6</h2>
|
||||
<h2 class="fw-bold mb-0" id="total_category">—</h2>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="col-xl-3 col-md-6 col-12">
|
||||
<div class="card">
|
||||
<div class="card-body p-6">
|
||||
<div class="d-flex align-items-start gap-3">
|
||||
<div class="icon-shape icon-lg bg-info bg-opacity-10 text-info rounded-2">
|
||||
<i class="ti ti-check fs-4 "></i>
|
||||
<i class="ti ti-check fs-4"></i>
|
||||
</div>
|
||||
<div class="">
|
||||
<div>
|
||||
<span>Active</span>
|
||||
<h2 class="fw-bold mb-0" id="active_category">5</h2>
|
||||
<h2 class="fw-bold mb-0" id="active_category">—</h2>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="col-xl-3 col-md-6 col-12">
|
||||
<div class="card">
|
||||
<div class="card-body p-6">
|
||||
<div class="d-flex align-items-start gap-3">
|
||||
<div class="icon-shape icon-lg bg-warning bg-opacity-10 text-warning rounded-2">
|
||||
<i class="ti ti-x fs-4 "></i>
|
||||
<i class="ti ti-x fs-4"></i>
|
||||
</div>
|
||||
<div class="">
|
||||
<div>
|
||||
<span>Inactive</span>
|
||||
<h2 class="fw-bold mb-0" id="inactive_category">1</h2>
|
||||
<h2 class="fw-bold mb-0" id="inactive_category">—</h2>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="col-xl-3 col-md-6 col-12">
|
||||
<div class="card">
|
||||
<div class="card-body p-6">
|
||||
<div class="d-flex align-items-start gap-3">
|
||||
<div class="icon-shape icon-lg bg-warning bg-opacity-10 text-warning rounded-2">
|
||||
<i class="ti ti-box-seam fs-4 "></i>
|
||||
<i class="ti ti-box-seam fs-4"></i>
|
||||
</div>
|
||||
<div class="">
|
||||
<div>
|
||||
<span>Total Products</span>
|
||||
<h2 class="fw-bold mb-0" id="total_product">356</h2>
|
||||
<h2 class="fw-bold mb-0" id="total_product">—</h2>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<ul class="nav nav-pills mb-3 bg-light p-2 rounded-2 d-inline-flex" id="pills-tab" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="pills-categories-tab" data-bs-toggle="pill"
|
||||
@@ -115,6 +105,7 @@
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Categories tab -->
|
||||
<div class="row g-5 mb-5 tab-pane fade show active" id="pills-categories" role="tabpanel"
|
||||
aria-labelledby="pills-categories-tab">
|
||||
<div class="col-12">
|
||||
@@ -129,20 +120,23 @@
|
||||
<div>
|
||||
<a href="<?php echo $server_url?>inventory/manage_category.php" class="btn btn-primary">
|
||||
<span class="me-1"><i class="ti ti-plus"></i></span>
|
||||
<span>Add Categories</span></a>
|
||||
<span>Add Categories</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search bar -->
|
||||
<div class="col-lg-12 mb-5">
|
||||
<div class="position-relative">
|
||||
<input type="search" class="form-control ps-9" placeholder="Search by product name or SKU">
|
||||
<input type="search" id="search_category" class="form-control ps-9"
|
||||
placeholder="Search by category, slug, or description…">
|
||||
<span class="position-absolute top-25 ms-4">
|
||||
<i class="ti ti-search text-muted"></i>
|
||||
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="table-responsive">
|
||||
<table class="table mb-0 text-nowrap table-hover table-centered" id="category">
|
||||
<thead class="table-primary border-light">
|
||||
@@ -154,26 +148,23 @@
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
<tbody>
|
||||
|
||||
</tbody>
|
||||
<tfoot class="">
|
||||
|
||||
</tfoot>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
<tfoot></tfoot>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Products tab -->
|
||||
<div class="row g-5 mb-5 tab-pane fade" id="pills-products" role="tabpanel" aria-labelledby="pills-products-tab">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body p-5">
|
||||
|
||||
<div class="mb-6 d-flex align-items-center justify-content-between">
|
||||
<div>
|
||||
<h3 class="mb-1 fs-4">Products</h3>
|
||||
@@ -182,10 +173,23 @@
|
||||
<div>
|
||||
<a href="<?php echo $server_url?>inventory/manage_product.php" class="btn btn-secondary">
|
||||
<span class="me-1"><i class="ti ti-plus"></i></span>
|
||||
<span>Add Product</span></a>
|
||||
<span>Add Product</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search bar -->
|
||||
<div class="col-lg-12 mb-5">
|
||||
<div class="position-relative">
|
||||
<input type="search" id="search_product" class="form-control ps-9"
|
||||
placeholder="Search by product name or SKU…">
|
||||
<span class="position-absolute top-25 ms-4">
|
||||
<i class="ti ti-search text-muted"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0 table-centered" id="product">
|
||||
<thead class="table-secondary border-light">
|
||||
@@ -201,24 +205,27 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
<tfoot class="">
|
||||
|
||||
</tfoot>
|
||||
<tfoot></tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<?php require "../include_ending.php";?>
|
||||
|
||||
<script>
|
||||
// ── Current search terms ─────────────────────────────────────────────
|
||||
var search_term_category = '';
|
||||
var search_term_product = '';
|
||||
|
||||
|
||||
// ── Stats ────────────────────────────────────────────────────────────
|
||||
function retrieve_stats() {
|
||||
|
||||
return ajax_request({
|
||||
@@ -228,20 +235,16 @@
|
||||
action: 'read',
|
||||
debugMode: 0,
|
||||
onSuccess: function(res) {
|
||||
|
||||
$("#total_category").text(res.total_category);
|
||||
$("#active_category").text(res.active_category);
|
||||
$("#inactive_category").text(res.inactive_category);
|
||||
$("#total_product").text(res.total_product);
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
// ── Category: fetch + render ─────────────────────────────────────────
|
||||
function retrieve_category() {
|
||||
|
||||
return ajax_request({
|
||||
@@ -252,31 +255,129 @@
|
||||
debugMode: 0,
|
||||
onSuccess: function(res) {
|
||||
|
||||
// Register the array as a table name
|
||||
alasql('CREATE TABLE IF NOT EXISTS output');
|
||||
alasql.tables.output.data = res.output || [];
|
||||
alasql('CREATE TABLE IF NOT EXISTS category');
|
||||
alasql.tables.category.data = res.output || [];
|
||||
|
||||
var total_records = alasql.tables.output.data.length;
|
||||
|
||||
var table_id = 'category';
|
||||
|
||||
var page = generate_pagination(table_id,total_records);
|
||||
$(`table#${table_id} tfoot`).html(page);
|
||||
|
||||
// dynamic function name
|
||||
if (typeof window[`change_page_${table_id}`] === "function") {
|
||||
window[`change_page_${table_id}`](1);
|
||||
} else {
|
||||
console.error(`Function change_page_${table_id} does not exist.`);
|
||||
}
|
||||
change_page_category(1);
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
function change_page_category(page_num) {
|
||||
|
||||
function remove_category(id,element) {
|
||||
var offset = (page_num - 1) * prop_limit;
|
||||
var kw = search_term_category.toLowerCase();
|
||||
|
||||
var filtered = kw ?
|
||||
alasql(`SELECT * FROM category
|
||||
WHERE LOWER(category) LIKE '%${kw}%'
|
||||
OR LOWER(slug) LIKE '%${kw}%'
|
||||
OR LOWER(description) LIKE '%${kw}%'`) :
|
||||
alasql(`SELECT * FROM category`);
|
||||
|
||||
var page = generate_pagination('category', filtered.length);
|
||||
$(`table#category tfoot`).html(page);
|
||||
|
||||
if (filtered.length === 0) {
|
||||
$("table#category > tbody").html(
|
||||
'<tr><td colspan="6" class="text-center py-5 text-muted">No categories match your search.</td></tr>'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
var page_data = filtered.slice(offset, offset + prop_limit);
|
||||
|
||||
var body = '';
|
||||
$.each(page_data, function(key, item) {
|
||||
body += `<tr>
|
||||
<td class="py-3">${item['category']}</td>
|
||||
<td class="py-3">${item['slug']}</td>
|
||||
<td class="py-3">${item['description']}</td>
|
||||
<td class="py-3" style="text-align: right;">${item['product_count']}</td>
|
||||
<td class="py-3">
|
||||
${(item['status']==1)?'<span class="badge bg-success">Active</span>':'<span class="badge bg-secondary">Inactive</span>'}
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<a href="<?php echo $server_url?>inventory/manage_category.php?id=${item['id']}" class=""><i class="ti ti-eye fs-5"></i></a>
|
||||
<a href="javascript:;" class="link-danger" onclick="remove_category(${item['id']},$(this))"><i class="ti ti-trash ms-2 fs-5"></i></a>
|
||||
</td>
|
||||
</tr>`;
|
||||
});
|
||||
|
||||
$("table#category > tbody").html(body);
|
||||
}
|
||||
|
||||
|
||||
// ── Product: fetch + render ──────────────────────────────────────────
|
||||
function retrieve_product() {
|
||||
|
||||
return ajax_request({
|
||||
url: "<?php echo $server_url?>inventory/api/engine/product.php",
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
action: 'read',
|
||||
onSuccess: function(res) {
|
||||
|
||||
alasql('CREATE TABLE IF NOT EXISTS product');
|
||||
alasql.tables.product.data = res.output || [];
|
||||
|
||||
change_page_product(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function change_page_product(page_num) {
|
||||
|
||||
var offset = (page_num - 1) * prop_limit;
|
||||
var kw = search_term_product.toLowerCase();
|
||||
|
||||
var filtered = kw ?
|
||||
alasql(`SELECT * FROM product
|
||||
WHERE LOWER(product_name) LIKE '%${kw}%'
|
||||
OR LOWER(sku) LIKE '%${kw}%'`) :
|
||||
alasql(`SELECT * FROM product`);
|
||||
|
||||
var page = generate_pagination('product', filtered.length);
|
||||
$(`table#product tfoot`).html(page);
|
||||
|
||||
if (filtered.length === 0) {
|
||||
$("table#product > tbody").html(
|
||||
'<tr><td colspan="8" class="text-center py-5 text-muted">No products match your search.</td></tr>'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
var page_data = filtered.slice(offset, offset + prop_limit);
|
||||
|
||||
var body = '';
|
||||
$.each(page_data, function(key, item) {
|
||||
body += `<tr height="50px">
|
||||
<td class="py-3">
|
||||
<img src="<?php echo $server_url?>uploads/product/${item['product_image']}" alt="${item['product_name']}" class="img-fluid" style="height: 25px;">
|
||||
</td>
|
||||
<td class="py-3">${item['product_name']}</td>
|
||||
<td class="py-3">${item['sku']}</td>
|
||||
<td class="py-3" style="text-align: right;">${format_number(item['product_balance'], 4)}</td>
|
||||
<td class="py-3" style="text-align: right;">${format_number(item['min_stock'], 2)}</td>
|
||||
<td class="py-3" style="text-align: right;">${format_number(item['reorder_point'], 2)}</td>
|
||||
<td class="py-3">
|
||||
${(item['status']==1)?'<span class="badge bg-success">Active</span>':'<span class="badge bg-secondary">Inactive</span>'}
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<a href="<?php echo $server_url?>inventory/manage_product.php?id=${item['id']}" class=""><i class="ti ti-eye fs-5"></i></a>
|
||||
<a href="javascript:;" class="link-danger" onclick="remove_product(${item['id']},$(this))"><i class="ti ti-trash ms-2 fs-5"></i></a>
|
||||
</td>
|
||||
</tr>`;
|
||||
});
|
||||
|
||||
$("table#product > tbody").html(body);
|
||||
}
|
||||
|
||||
|
||||
// ── Remove handlers ──────────────────────────────────────────────────
|
||||
function remove_category(id, element) {
|
||||
|
||||
return ajax_request({
|
||||
url: "<?php echo $server_url?>inventory/api/engine/remove_category.php",
|
||||
@@ -288,17 +389,13 @@
|
||||
},
|
||||
action: 'delete',
|
||||
onSuccess: function(res) {
|
||||
|
||||
element.parent().parent().remove();
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function remove_product(id,element) {
|
||||
function remove_product(id, element) {
|
||||
|
||||
return ajax_request({
|
||||
url: "<?php echo $server_url?>inventory/api/engine/remove_product.php",
|
||||
@@ -310,109 +407,37 @@
|
||||
},
|
||||
action: 'delete',
|
||||
onSuccess: function(res) {
|
||||
|
||||
element.parent().parent().remove();
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function change_page_category(page_num) {
|
||||
|
||||
var offset = (page_num - 1) * prop_limit;
|
||||
|
||||
var page_data = alasql(`SELECT * FROM output LIMIT ${prop_limit} OFFSET ${offset}`);
|
||||
|
||||
var body = ``;
|
||||
$.each(page_data, function(key, item) {
|
||||
body += `<tr>
|
||||
<td class="py-3">${item['category']}</td>
|
||||
<td class="py-3">${item['slug']}</td>
|
||||
<td class="py-3">${item['description']}</td>
|
||||
<td class="py-3" style="text-align: right;">${item['product_count']}</td>
|
||||
<td class="py-3">
|
||||
${(item['status']==1)?'<span class="badge bg-success">Active</span>':'<span class="badge bg-secondary">Inactive</span>'}
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<a href="<?php echo $server_url?>inventory/manage_category.php?id=${item['id']}" class=""><i class="ti ti-eye fs-5"></i></a>
|
||||
<a href="javascript:;" class="link-danger" onclick="remove_category(${item['id']},$(this))"><i class="ti ti-trash ms-2 fs-5"></i></a>
|
||||
</td>
|
||||
</tr>`;
|
||||
})
|
||||
|
||||
$("table#category > tbody").html(body);
|
||||
|
||||
// ── Search: debounce + re-render ─────────────────────────────────────
|
||||
function debounce(fn, ms) {
|
||||
var t;
|
||||
return function() {
|
||||
var args = arguments,
|
||||
ctx = this;
|
||||
clearTimeout(t);
|
||||
t = setTimeout(function() {
|
||||
fn.apply(ctx, args);
|
||||
}, ms);
|
||||
};
|
||||
}
|
||||
|
||||
$('#search_category').on('input', debounce(function() {
|
||||
search_term_category = $(this).val().trim();
|
||||
change_page_category(1);
|
||||
}, 200));
|
||||
|
||||
$('#search_product').on('input', debounce(function() {
|
||||
search_term_product = $(this).val().trim();
|
||||
change_page_product(1);
|
||||
}, 200));
|
||||
|
||||
|
||||
function retrieve_product() {
|
||||
|
||||
return ajax_request({
|
||||
url: "<?php echo $server_url?>inventory/api/engine/product.php",
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
action: 'read',
|
||||
onSuccess: function(res) {
|
||||
|
||||
// Register the array as a table name
|
||||
alasql('CREATE TABLE IF NOT EXISTS product');
|
||||
alasql.tables.product.data = res.output || [];
|
||||
|
||||
var total_records = alasql.tables.product.data.length;
|
||||
|
||||
var table_id = 'product';
|
||||
|
||||
var page = generate_pagination(table_id,total_records);
|
||||
$(`table#${table_id} tfoot`).html(page);
|
||||
|
||||
// dynamic function name
|
||||
if (typeof window[`change_page_${table_id}`] === "function") {
|
||||
window[`change_page_${table_id}`](1);
|
||||
} else {
|
||||
console.error(`Function change_page_${table_id} does not exist.`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function change_page_product(page_num) {
|
||||
|
||||
var offset = (page_num - 1) * prop_limit;
|
||||
|
||||
var page_data = alasql(`SELECT * FROM product LIMIT ${prop_limit} OFFSET ${offset}`);
|
||||
|
||||
var body = ``;
|
||||
$.each(page_data, function(key, item) {
|
||||
body += `<tr height="50px">
|
||||
<td class="py-3">
|
||||
<img src="<?php echo $server_url?>uploads/product/${item['product_image']}" alt="${item['product_name']}" class="img-fluid" style="height: 25px;">
|
||||
</td>
|
||||
<td class="py-3">${item['product_name']}</td>
|
||||
<td class="py-3">${item['sku']}</td>
|
||||
<td class="py-3" style='text-align: right;'>${format_number(item['product_balance'],4)}</td>
|
||||
<td class="py-3" style='text-align: right;'>${format_number(item['min_stock'],2)}</td>
|
||||
<td class="py-3" style='text-align: right;'>${format_number(item['reorder_point'],2)}</td>
|
||||
<td class="py-3">
|
||||
${(item['status']==1)?'<span class="badge bg-success">Active</span>':'<span class="badge bg-secondary">Inactive</span>'}
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<a href="<?php echo $server_url?>inventory/manage_product.php?id=${item['id']}" class=""><i class="ti ti-eye fs-5"></i></a>
|
||||
<a href="javascript:;" class="link-danger" onclick="remove_product(${item['id']},$(this))"><i class="ti ti-trash ms-2 fs-5"></i></a>
|
||||
</td>
|
||||
</tr>`;
|
||||
})
|
||||
|
||||
$("table#product > tbody").html(body);
|
||||
|
||||
}
|
||||
|
||||
// ── Boot ─────────────────────────────────────────────────────────────
|
||||
$(async function() {
|
||||
try {
|
||||
await retrieve_stats();
|
||||
@@ -423,20 +448,19 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Remember last active tab across page reloads
|
||||
$(document).ready(function() {
|
||||
// switching to nav-link from last leave
|
||||
$('button.nav-link').on('click', function() {
|
||||
nav_id = $(this).attr('id');
|
||||
var nav_id = $(this).attr('id');
|
||||
sessionStorage.setItem("product_nav_id", nav_id);
|
||||
})
|
||||
let nav_id = sessionStorage.getItem("product_nav_id");
|
||||
if(nav_id){
|
||||
});
|
||||
var nav_id = sessionStorage.getItem("product_nav_id");
|
||||
if (nav_id) {
|
||||
$(`button#${nav_id}`).trigger('click');
|
||||
}
|
||||
})
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
+168
-139
@@ -2,7 +2,6 @@
|
||||
session_start();
|
||||
require '../config.php';
|
||||
require '../include_header.php';
|
||||
|
||||
?>
|
||||
|
||||
<body>
|
||||
@@ -13,30 +12,33 @@
|
||||
<!-- MAIN CONTENT -->
|
||||
<main id="content" class="content py-15">
|
||||
<div class="container-fluid">
|
||||
<div class="row ">
|
||||
|
||||
<!-- Page header -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="mb-6 d-flex align-items-center justify-content-between">
|
||||
<div>
|
||||
<h1 class="fs-3 mb-1">Warehouse / Location Management
|
||||
</h1>
|
||||
<h1 class="fs-3 mb-1">Warehouse / Location Management</h1>
|
||||
<p class="mb-0">Manage your warehouses, storage locations, and capacity.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary stats -->
|
||||
<div class="row mb-5 g-5">
|
||||
<div class="col-12">
|
||||
<div class="card ">
|
||||
<div class="card">
|
||||
<div class="card-body p-0">
|
||||
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap ">
|
||||
<div class=" p-6 flex-grow-1 d-flex justify-content-between" id="total_warehouse">
|
||||
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap">
|
||||
|
||||
<div class="p-6 flex-grow-1 d-flex justify-content-between" id="total_warehouse">
|
||||
<div class="d-flex flex-column">
|
||||
<span class="fs-2 fw-bold mb-3 d-block">5</span>
|
||||
<span class="fs-2 fw-bold mb-3 d-block">—</span>
|
||||
<span class="fs-5">Total Warehouses</span>
|
||||
<small class="text-info">
|
||||
Across 5 locations</small>
|
||||
<small class="text-info">Across — locations</small>
|
||||
</div>
|
||||
<div class="">
|
||||
<div>
|
||||
<span class="text-info">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"
|
||||
@@ -48,17 +50,15 @@
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class=" p-6 flex-grow-1 d-flex justify-content-between" id="total_capacity">
|
||||
|
||||
<div class="p-6 flex-grow-1 d-flex justify-content-between" id="total_capacity">
|
||||
<div class="d-flex flex-column">
|
||||
<span class="fs-2 fw-bold mb-3 d-block">41,000</span>
|
||||
<!-- Total Capacity card -->
|
||||
<span class="fs-2 fw-bold mb-3 d-block">—</span>
|
||||
<span class="fs-5">Total Capacity</span>
|
||||
<small class="text-warning">Rack positions available</small>
|
||||
<!-- was "Storage units available" -->
|
||||
</div>
|
||||
<div class="">
|
||||
<div>
|
||||
<span class="text-warning">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"
|
||||
@@ -77,17 +77,15 @@
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class=" p-6 flex-grow-1 d-flex justify-content-between" id="total_space_used">
|
||||
|
||||
<div class="p-6 flex-grow-1 d-flex justify-content-between" id="total_space_used">
|
||||
<div class="d-flex flex-column">
|
||||
<span class="fs-2 fw-bold mb-3 d-block">25,500</span>
|
||||
<!-- Space Used card -->
|
||||
<span class="fs-2 fw-bold mb-3 d-block">—</span>
|
||||
<span class="fs-5">Space Used</span>
|
||||
<small class="text-success">Racks occupied</small>
|
||||
<!-- was "Storage units available" (which was wrong!) -->
|
||||
</div>
|
||||
<div class="">
|
||||
<div>
|
||||
<span class="text-success">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"
|
||||
@@ -101,16 +99,15 @@
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="-0 p-6 flex-grow-1 d-flex justify-content-between" id="total_product">
|
||||
|
||||
<div class="p-6 flex-grow-1 d-flex justify-content-between" id="total_product">
|
||||
<div class="d-flex flex-column">
|
||||
<span class="fs-2 fw-bold mb-3 d-block">730</span>
|
||||
<span class="fs-2 fw-bold mb-3 d-block">—</span>
|
||||
<span class="fs-5">Total Products</span>
|
||||
<small class="text-primary">
|
||||
Products stored</small>
|
||||
<small class="text-primary">Products stored</small>
|
||||
</div>
|
||||
<div class="">
|
||||
<div>
|
||||
<span class="text-primary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"
|
||||
@@ -121,19 +118,15 @@
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<ul class="nav nav-pills mb-3 bg-light p-2 rounded-2 d-inline-flex" id="pills-tab" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="pills-warehouse-tab" data-bs-toggle="pill"
|
||||
@@ -146,32 +139,37 @@
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Warehouses tab -->
|
||||
<div class="row g-5 mb-5 tab-pane fade show active" id="pills-warehouse" role="tabpanel"
|
||||
aria-labelledby="pills-warehouse-tab">
|
||||
<div class="col-12">
|
||||
<div class="card ">
|
||||
<div class="card">
|
||||
<div class="card-body p-5 justify-content-between">
|
||||
|
||||
<div class="mb-6 d-flex align-items-center justify-content-between">
|
||||
<div>
|
||||
<h2 class="fs-4 mb-2">Warehouses</h2>
|
||||
<p>All warehouse facilities and their current status</p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="<?php echo $server_url?>inventory/manage_warehouse.php" class="btn btn-primary">Add
|
||||
Warehouse</a>
|
||||
<a href="<?php echo $server_url?>inventory/manage_warehouse.php" class="btn btn-primary">
|
||||
Add Warehouse
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search bar -->
|
||||
<div class="col-lg-12 mb-5">
|
||||
<div class="position-relative">
|
||||
<input type="search" class="form-control ps-9" placeholder="Search by product name or SKU">
|
||||
<input type="search" id="search_warehouse" class="form-control ps-9"
|
||||
placeholder="Search by warehouse name, location, or manager…">
|
||||
<span class="position-absolute top-25 ms-4">
|
||||
<i class="ti ti-search text-muted"></i>
|
||||
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="table-responsive">
|
||||
<table class="table mb-0 text-nowrap table-hover table-centered" id="warehouse">
|
||||
<thead class="table-primary border-primary">
|
||||
@@ -182,46 +180,49 @@
|
||||
<th class="py-3">Capacity</th>
|
||||
<th class="py-3">Products</th>
|
||||
<th class="py-3">Status</th>
|
||||
|
||||
<th class="py-3">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
<tfoot class="">
|
||||
|
||||
</tfoot>
|
||||
<tfoot></tfoot>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Storage tab -->
|
||||
<div class="row g-5 mb-5 tab-pane fade" id="pills-storage" role="tabpanel" aria-labelledby="pills-storage-tab">
|
||||
<div class="col-12">
|
||||
<div class="card ">
|
||||
<div class="card">
|
||||
<div class="card-body p-5">
|
||||
|
||||
<div class="mb-6 d-flex align-items-center justify-content-between">
|
||||
<div>
|
||||
<h2 class="fs-4 mb-2">Storage Locations</h2>
|
||||
<p>Zones, aisles, and rack assignments</p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="<?php echo $server_url?>inventory/manage_storage.php" class="btn btn-secondary">Add
|
||||
Storage</a>
|
||||
<a href="<?php echo $server_url?>inventory/manage_storage.php" class="btn btn-secondary">
|
||||
Add Storage
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search bar -->
|
||||
<div class="col-lg-12 mb-5">
|
||||
<div class="position-relative">
|
||||
<input type="search" class="form-control ps-9" placeholder="Search by product name or SKU">
|
||||
<input type="search" id="search_storage" class="form-control ps-9"
|
||||
placeholder="Search by warehouse or zone…">
|
||||
<span class="position-absolute top-25 ms-4">
|
||||
<i class="ti ti-search text-muted"></i>
|
||||
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="table-responsive">
|
||||
<table class="table mb-0 text-nowrap table-hover table-centered" id="storage">
|
||||
<thead class="table-secondary">
|
||||
@@ -231,17 +232,14 @@
|
||||
<th class="py-3">Aisle Range</th>
|
||||
<th class="py-3">Rack Range</th>
|
||||
<th class="py-3">Items Stored</th>
|
||||
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
<tfoot class="">
|
||||
|
||||
</tfoot>
|
||||
<tfoot></tfoot>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -253,7 +251,14 @@
|
||||
<?php require "../include_ending.php";?>
|
||||
|
||||
<script>
|
||||
// ── Current search terms (read by change_page_* when paginating) ─────
|
||||
var search_term_warehouse = '';
|
||||
var search_term_storage = '';
|
||||
|
||||
|
||||
// ── Stats card ───────────────────────────────────────────────────────
|
||||
function retrieve_stats() {
|
||||
|
||||
return ajax_request({
|
||||
url: "<?php echo $server_url?>inventory/api/engine/warehouse_stats.php",
|
||||
autoPrepare: true,
|
||||
@@ -264,16 +269,13 @@
|
||||
$("#total_warehouse span.fs-2").text(res.total_warehouse);
|
||||
$("#total_warehouse small.text-info").text(`Across ${res.total_location} locations`);
|
||||
|
||||
// Capacity = rack count (integer, no decimals)
|
||||
$("#total_capacity span.fs-2").text(format_number(res.total_capacity, 0));
|
||||
|
||||
// NEW: Space Used wiring
|
||||
$("#total_space_used span.fs-2").text(format_number(res.space_used, 0));
|
||||
|
||||
// Optional: show % utilization in the small text
|
||||
var pct = res.total_capacity > 0
|
||||
? Math.round(res.space_used / res.total_capacity * 100)
|
||||
: 0;
|
||||
var pct = res.total_capacity > 0 ?
|
||||
Math.round(res.space_used / res.total_capacity * 100) :
|
||||
0;
|
||||
$("#total_space_used small").text(`${pct}% utilized`);
|
||||
|
||||
$("#total_product span.fs-2").text(res.total_product);
|
||||
@@ -282,8 +284,7 @@
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
// ── Warehouse: fetch + render ────────────────────────────────────────
|
||||
function retrieve_warehouse() {
|
||||
|
||||
return ajax_request({
|
||||
@@ -293,58 +294,65 @@
|
||||
action: 'read',
|
||||
onSuccess: function(res) {
|
||||
|
||||
// Register the array as a table name
|
||||
alasql('CREATE TABLE IF NOT EXISTS output');
|
||||
alasql.tables.output.data = res.output || [];
|
||||
|
||||
var total_records = alasql.tables.output.data.length;
|
||||
|
||||
var table_id = 'warehouse';
|
||||
|
||||
var page = generate_pagination(table_id,total_records);
|
||||
$(`table#${table_id} tfoot`).html(page);
|
||||
|
||||
// dynamic function name
|
||||
if (typeof window[`change_page_${table_id}`] === "function") {
|
||||
window[`change_page_${table_id}`](1);
|
||||
} else {
|
||||
console.error(`Function change_page_${table_id} does not exist.`);
|
||||
}
|
||||
change_page_warehouse(1);
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
function change_page_warehouse(page_num) {
|
||||
|
||||
var offset = (page_num - 1) * prop_limit;
|
||||
var kw = search_term_warehouse.toLowerCase();
|
||||
|
||||
var page_data = alasql(`SELECT * FROM output LIMIT ${prop_limit} OFFSET ${offset}`);
|
||||
// Filter in-memory, then paginate
|
||||
var filtered = kw ?
|
||||
alasql(`SELECT * FROM output
|
||||
WHERE LOWER(warehouse_name) LIKE '%${kw}%'
|
||||
OR LOWER(location) LIKE '%${kw}%'
|
||||
OR LOWER(manager_name) LIKE '%${kw}%'
|
||||
OR LOWER(manager_surname) LIKE '%${kw}%'`) :
|
||||
alasql(`SELECT * FROM output`);
|
||||
|
||||
var body = ``;
|
||||
// Pagination reflects the filtered total
|
||||
var page = generate_pagination('warehouse', filtered.length);
|
||||
$(`table#warehouse tfoot`).html(page);
|
||||
|
||||
var page_data = filtered.slice(offset, offset + prop_limit);
|
||||
|
||||
if (filtered.length === 0) {
|
||||
$("table#warehouse > tbody").html(
|
||||
'<tr><td colspan="7" class="text-center py-5 text-muted">No warehouses match your search.</td></tr>'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
var body = '';
|
||||
$.each(page_data, function(key, item) {
|
||||
body += `<tr>
|
||||
<td class="py-3">${item['warehouse_name']}</td>
|
||||
<td class="py-3">${item['location']}</td>
|
||||
<td class="py-3">${item['manager_name']} ${item['manager_surname']}</td>
|
||||
<td class="py-3" style='text-align: right;'>${format_number(item['capacity'],2)}</td>
|
||||
<td class="py-3">${item['unique_product']}</td>
|
||||
<td class="py-3">
|
||||
${(item['status']==1)?'<span class="badge bg-success">Active</span>':'<span class="badge bg-secondary">Inactive</span>'}
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<a href="<?php echo $server_url?>inventory/manage_warehouse.php?id=${item['id']}" class=""><i class="ti ti-eye fs-5"></i></a>
|
||||
<a href="javascript:;" class="link-danger" onclick="remove_warehouse(${item['id']},$(this))"><i class="ti ti-trash ms-2 fs-5"></i></a>
|
||||
</td>
|
||||
</tr>`;
|
||||
})
|
||||
<td class="py-3">${item['warehouse_name']}</td>
|
||||
<td class="py-3">${item['location']}</td>
|
||||
<td class="py-3">${item['manager_name']} ${item['manager_surname']}</td>
|
||||
<td class="py-3" style='text-align: right;'>${format_number(item['capacity'], 2)}</td>
|
||||
<td class="py-3">${item['unique_product']}</td>
|
||||
<td class="py-3">
|
||||
${(item['status']==1)?'<span class="badge bg-success">Active</span>':'<span class="badge bg-secondary">Inactive</span>'}
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<a href="<?php echo $server_url?>inventory/manage_warehouse.php?id=${item['id']}" class=""><i class="ti ti-eye fs-5"></i></a>
|
||||
<a href="javascript:;" class="link-danger" onclick="remove_warehouse(${item['id']},$(this))"><i class="ti ti-trash ms-2 fs-5"></i></a>
|
||||
</td>
|
||||
</tr>`;
|
||||
});
|
||||
|
||||
$("table#warehouse > tbody").html(body);
|
||||
|
||||
}
|
||||
|
||||
|
||||
// ── Storage: fetch + render ──────────────────────────────────────────
|
||||
function retrieve_storage() {
|
||||
|
||||
return ajax_request({
|
||||
@@ -354,56 +362,59 @@
|
||||
action: 'read',
|
||||
onSuccess: function(res) {
|
||||
|
||||
// Register the array as a table name
|
||||
alasql('CREATE TABLE IF NOT EXISTS storage');
|
||||
alasql.tables.storage.data = res.output || [];
|
||||
|
||||
var total_records = alasql.tables.storage.data.length;
|
||||
|
||||
var table_id = 'storage';
|
||||
|
||||
var page = generate_pagination(table_id,total_records);
|
||||
$(`table#${table_id} tfoot`).html(page);
|
||||
|
||||
// dynamic function name
|
||||
if (typeof window[`change_page_${table_id}`] === "function") {
|
||||
window[`change_page_${table_id}`](1);
|
||||
} else {
|
||||
console.error(`Function change_page_${table_id} does not exist.`);
|
||||
}
|
||||
change_page_storage(1);
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
function change_page_storage(page_num) {
|
||||
|
||||
var offset = (page_num - 1) * prop_limit;
|
||||
var kw = search_term_storage.toLowerCase();
|
||||
|
||||
var page_data = alasql(`SELECT * FROM storage LIMIT ${prop_limit} OFFSET ${offset}`);
|
||||
var filtered = kw ?
|
||||
alasql(`SELECT * FROM storage
|
||||
WHERE LOWER(warehouse_name) LIKE '%${kw}%'
|
||||
OR LOWER(zone) LIKE '%${kw}%'`) :
|
||||
alasql(`SELECT * FROM storage`);
|
||||
|
||||
var body = ``;
|
||||
var page = generate_pagination('storage', filtered.length);
|
||||
$(`table#storage tfoot`).html(page);
|
||||
|
||||
var page_data = filtered.slice(offset, offset + prop_limit);
|
||||
|
||||
if (filtered.length === 0) {
|
||||
$("table#storage > tbody").html(
|
||||
'<tr><td colspan="6" class="text-center py-5 text-muted">No storage locations match your search.</td></tr>'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
var body = '';
|
||||
$.each(page_data, function(key, item) {
|
||||
body += `<tr>
|
||||
<td class="py-3">${item['warehouse_name']}</td>
|
||||
<td class="py-3">${item['zone']}</td>
|
||||
<td class="py-3">${item['aisle_from']} - ${item["aisle_to"]}</td>
|
||||
<td class="py-3">${item['rack_from']} - ${item["rack_to"]}</td>
|
||||
<td class="py-3"></td>
|
||||
<td class="py-3">
|
||||
<a href="<?php echo $server_url?>inventory/manage_storage.php?id=${item['id']}" class=""><i class="ti ti-eye fs-5"></i></a>
|
||||
<a href="javascript:;" class="link-danger" onclick="remove_storage(${item['id']},$(this))"><i class="ti ti-trash ms-2 fs-5"></i></a>
|
||||
</td>
|
||||
</tr>`;
|
||||
})
|
||||
<td class="py-3">${item['warehouse_name']}</td>
|
||||
<td class="py-3">${item['zone']}</td>
|
||||
<td class="py-3">${item['aisle_from']} - ${item["aisle_to"]}</td>
|
||||
<td class="py-3">${item['rack_from']} - ${item["rack_to"]}</td>
|
||||
<td class="py-3"></td>
|
||||
<td class="py-3">
|
||||
<a href="<?php echo $server_url?>inventory/manage_storage.php?id=${item['id']}" class=""><i class="ti ti-eye fs-5"></i></a>
|
||||
<a href="javascript:;" class="link-danger" onclick="remove_storage(${item['id']},$(this))"><i class="ti ti-trash ms-2 fs-5"></i></a>
|
||||
</td>
|
||||
</tr>`;
|
||||
});
|
||||
|
||||
$("table#storage > tbody").html(body);
|
||||
|
||||
}
|
||||
|
||||
|
||||
function remove_warehouse(id,element) {
|
||||
// ── Remove handlers ──────────────────────────────────────────────────
|
||||
function remove_warehouse(id, element) {
|
||||
|
||||
return ajax_request({
|
||||
url: "<?php echo $server_url?>inventory/api/engine/remove_warehouse.php",
|
||||
@@ -415,16 +426,13 @@
|
||||
},
|
||||
action: 'delete',
|
||||
onSuccess: function(res) {
|
||||
|
||||
element.parent().parent().remove();
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
function remove_storage(id,element) {
|
||||
function remove_storage(id, element) {
|
||||
|
||||
return ajax_request({
|
||||
url: "<?php echo $server_url?>inventory/api/engine/remove_storage.php",
|
||||
@@ -436,15 +444,37 @@
|
||||
},
|
||||
action: 'delete',
|
||||
onSuccess: function(res) {
|
||||
|
||||
element.parent().parent().remove();
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
// ── Search: debounce + re-render ─────────────────────────────────────
|
||||
function debounce(fn, ms) {
|
||||
var t;
|
||||
return function() {
|
||||
var args = arguments,
|
||||
ctx = this;
|
||||
clearTimeout(t);
|
||||
t = setTimeout(function() {
|
||||
fn.apply(ctx, args);
|
||||
}, ms);
|
||||
};
|
||||
}
|
||||
|
||||
$('#search_warehouse').on('input', debounce(function() {
|
||||
search_term_warehouse = $(this).val().trim();
|
||||
change_page_warehouse(1); // always reset to page 1 on new search
|
||||
}, 200));
|
||||
|
||||
$('#search_storage').on('input', debounce(function() {
|
||||
search_term_storage = $(this).val().trim();
|
||||
change_page_storage(1);
|
||||
}, 200));
|
||||
|
||||
|
||||
// ── Boot ─────────────────────────────────────────────────────────────
|
||||
$(async function() {
|
||||
try {
|
||||
await retrieve_stats();
|
||||
@@ -455,20 +485,19 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Remember last active tab across page reloads
|
||||
$(document).ready(function() {
|
||||
// switching to nav-link from last leave
|
||||
$('button.nav-link').on('click', function() {
|
||||
nav_id = $(this).attr('id');
|
||||
var nav_id = $(this).attr('id');
|
||||
sessionStorage.setItem("warehouse_nav_id", nav_id);
|
||||
})
|
||||
let nav_id = sessionStorage.getItem("warehouse_nav_id");
|
||||
if(nav_id){
|
||||
});
|
||||
var nav_id = sessionStorage.getItem("warehouse_nav_id");
|
||||
if (nav_id) {
|
||||
$(`button#${nav_id}`).trigger('click');
|
||||
}
|
||||
})
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
Reference in New Issue
Block a user