diff --git a/app.zip b/app.zip deleted file mode 100644 index c39ea11..0000000 Binary files a/app.zip and /dev/null differ diff --git a/app/assets/js/custom.js b/app/assets/js/custom.js index 9c93227..10cb9ec 100644 --- a/app/assets/js/custom.js +++ b/app/assets/js/custom.js @@ -13,7 +13,7 @@ $(function () { var $el = $(el); $el.autocomplete({ - + // Fetch data from server source: function (request, response) { @@ -120,7 +120,7 @@ $(function () { var $el = $(el); $el.autocomplete({ - + // Fetch data from server source: function (request, response) { @@ -202,12 +202,12 @@ $(function () { }) - // Allow HTML rendering in dropdown - .data("ui-autocomplete")._renderItem = function (ul, item) { - return $("
  • ") - .append($("
    ").html(item.label)) - .appendTo(ul); - }; + // Allow HTML rendering in dropdown + .data("ui-autocomplete")._renderItem = function (ul, item) { + return $("
  • ") + .append($("
    ").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 += "
    "; + temp += "
    "; + temp += "Lot: " + item.lot_number; + temp += "
    "; + temp += "
    "; + temp += "Expiry: " + (item.expiry_date || 'N/A'); + temp += "
    "; + temp += "
    "; + + 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 $("
  • ") + .append($("
    ").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"); } diff --git a/app/assets/utils/classes/Reports.php b/app/assets/utils/classes/Reports.php index fc96ee0..2a70a2f 100644 --- a/app/assets/utils/classes/Reports.php +++ b/app/assets/utils/classes/Reports.php @@ -11,6 +11,38 @@ class Reports $this->companyId = $companyId; } + /** + * Resolve the td_stock_ 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; + } + } diff --git a/app/assets/utils/classes/WarehouseManager.php b/app/assets/utils/classes/WarehouseManager.php index 76ff906..e39cebd 100644 --- a/app/assets/utils/classes/WarehouseManager.php +++ b/app/assets/utils/classes/WarehouseManager.php @@ -20,6 +20,64 @@ class WarehouseManager { $this->company_id = $company_id; } + // ───────────────────────────────────────────────────────────── + // Private helpers + // ───────────────────────────────────────────────────────────── + + /** + * Resolve the td_stock_ 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 // ───────────────────────────────────────────────────────────── diff --git a/app/contact/api/engine/contact_stats.php b/app/contact/api/engine/contact_stats.php new file mode 100644 index 0000000..68f6c91 --- /dev/null +++ b/app/contact/api/engine/contact_stats.php @@ -0,0 +1,15 @@ +getTotalContactCategory(); + $answer["active_category"] = $report->getActiveContactCategory(); + $answer["inactive_category"] = $report->getInactiveContactCategory(); + $answer["total_contact"] = $report->getTotalContact(); + $answer["success"] = 1; + exit(json_encode($answer)); +?> \ No newline at end of file diff --git a/app/contact/contact.php b/app/contact/contact.php index aebf043..fc16a52 100644 --- a/app/contact/contact.php +++ b/app/contact/contact.php @@ -2,7 +2,6 @@ session_start(); require '../config.php'; require '../include_header.php'; - ?> @@ -13,96 +12,87 @@
    + +
    -
    +

    Contact

    Manage your contact

    + +
    - +
    -
    +
    Total Categories -

    6

    +

    —

    -
    -
    -
    -
    +
    - +
    -
    +
    Active -

    5

    +

    —

    -
    -
    -
    -
    +
    - +
    -
    +
    Inactive -

    1

    +

    —

    -
    -
    -
    -
    +
    - +
    -
    +
    Total Contact -

    356

    +

    —

    -
    -
    -
    -
    -
    + +
    @@ -129,20 +120,23 @@
    - Add Contact Type + Add Contact Type +
    +
    - + -
    +
    @@ -153,26 +147,23 @@ - - - - - - + + +
    Status Actions
    -
    +
    - -
    +
    +

    Contacts

    @@ -181,10 +172,23 @@
    - Add Contact + Add Contact +
    + +
    +
    + + + + +
    +
    + +
    @@ -200,24 +204,27 @@ - - - +
    +
    -
    -
    -
    +
    - \ No newline at end of file diff --git a/app/dashboard/api/engine_report/low_stock.php b/app/dashboard/api/engine_report/low_stock.php new file mode 100644 index 0000000..2f05e2f --- /dev/null +++ b/app/dashboard/api/engine_report/low_stock.php @@ -0,0 +1,25 @@ +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)); +?> \ No newline at end of file diff --git a/app/dashboard/low_stock_products.php b/app/dashboard/low_stock_products.php new file mode 100644 index 0000000..058ae6d --- /dev/null +++ b/app/dashboard/low_stock_products.php @@ -0,0 +1,311 @@ + + + + + + + + +
    +
    + + +
    +
    +
    +
    +

    Low Stock Products

    +

    Products whose current balance has fallen below minimum stock level

    +
    +
    +
    +
    + + +
    +
    +
    +
    +
    + Total Low Stock + +
    +
    + — + Products need attention +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    + Critical Level + +
    +
    + — + Immediate restock needed +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    + Warning Level + +
    +
    + — + Restock soon +
    +
    +
    +
    +
    +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    +

    Low Stock Items

    + +
    +
    + + + + + + + + + + + + + + + + + + + +
    ProductSKUWarehouseBalanceMin StockReorder PointStatusAction
    +
    Loading… +
    +
    +
    +
    +
    +
    + +
    +
    + + + + + + + + \ No newline at end of file diff --git a/app/ics/api/engine/manage_stock_in.php b/app/ics/api/engine/manage_stock_in.php index 77564b3..622dc96 100644 --- a/app/ics/api/engine/manage_stock_in.php +++ b/app/ics/api/engine/manage_stock_in.php @@ -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 diff --git a/app/ics/api/engine/manage_stock_out.php b/app/ics/api/engine/manage_stock_out.php index 705ca13..05484ca 100644 --- a/app/ics/api/engine/manage_stock_out.php +++ b/app/ics/api/engine/manage_stock_out.php @@ -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. diff --git a/app/ics/api/engine/manage_stock_transfer.php b/app/ics/api/engine/manage_stock_transfer.php index f74840a..f086641 100644 --- a/app/ics/api/engine/manage_stock_transfer.php +++ b/app/ics/api/engine/manage_stock_transfer.php @@ -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_ 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(); diff --git a/app/ics/api/engine/retrieve_active_lot.php b/app/ics/api/engine/retrieve_active_lot.php new file mode 100644 index 0000000..2132a0a --- /dev/null +++ b/app/ics/api/engine/retrieve_active_lot.php @@ -0,0 +1,64 @@ +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)); +?> \ No newline at end of file diff --git a/app/ics/api/engine/retrieve_active_serial.php b/app/ics/api/engine/retrieve_active_serial.php new file mode 100644 index 0000000..1d47556 --- /dev/null +++ b/app/ics/api/engine/retrieve_active_serial.php @@ -0,0 +1,64 @@ +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)); +?> \ No newline at end of file diff --git a/app/ics/api/engine/retrieve_aisle.php b/app/ics/api/engine/retrieve_aisle.php index f10f25f..8390e10 100644 --- a/app/ics/api/engine/retrieve_aisle.php +++ b/app/ics/api/engine/retrieve_aisle.php @@ -1,64 +1,51 @@ -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)); ?> \ No newline at end of file diff --git a/app/ics/api/engine/retrieve_lot.php b/app/ics/api/engine/retrieve_lot.php new file mode 100644 index 0000000..b62002c --- /dev/null +++ b/app/ics/api/engine/retrieve_lot.php @@ -0,0 +1,26 @@ +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)); +?> \ No newline at end of file diff --git a/app/ics/api/engine/retrieve_rack.php b/app/ics/api/engine/retrieve_rack.php index 8be8c09..d97207f 100644 --- a/app/ics/api/engine/retrieve_rack.php +++ b/app/ics/api/engine/retrieve_rack.php @@ -1,69 +1,53 @@ -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)); ?> \ No newline at end of file diff --git a/app/ics/api/engine/retrieve_stock_in.php b/app/ics/api/engine/retrieve_stock_in.php index c66cf06..c2a2d87 100644 --- a/app/ics/api/engine/retrieve_stock_in.php +++ b/app/ics/api/engine/retrieve_stock_in.php @@ -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 "; diff --git a/app/ics/api/engine/retrieve_zone.php b/app/ics/api/engine/retrieve_zone.php index b5778da..c89f7d6 100644 --- a/app/ics/api/engine/retrieve_zone.php +++ b/app/ics/api/engine/retrieve_zone.php @@ -1,59 +1,52 @@ -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)); ?> \ No newline at end of file diff --git a/app/ics/api/engine_report/expired_stock.php b/app/ics/api/engine_report/expired_stock.php new file mode 100644 index 0000000..1f87a92 --- /dev/null +++ b/app/ics/api/engine_report/expired_stock.php @@ -0,0 +1,32 @@ +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)); +?> \ No newline at end of file diff --git a/app/ics/api/engine_report/warehouse_overview.php b/app/ics/api/engine_report/warehouse_overview.php new file mode 100644 index 0000000..625ac1e --- /dev/null +++ b/app/ics/api/engine_report/warehouse_overview.php @@ -0,0 +1,30 @@ +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)); +?> \ No newline at end of file diff --git a/app/ics/expired-stock.php b/app/ics/expired-stock.php deleted file mode 100644 index 94d5b6b..0000000 --- a/app/ics/expired-stock.php +++ /dev/null @@ -1,351 +0,0 @@ - - - - - - - - -
    -
    -
    -
    -
    -

    Expired / Near Expiry Products -

    -

    Monitor and manage products approaching or past expiry dates

    - -
    -
    -
    -
    -
    -
    -
    -
    - - - - -
    -
    - 3 - Expired Products - - - Requires immediate action -
    - -
    - -
    -
    -
    -
    -
    -
    - - - - -
    -
    - 2 - Critical (≤7 days) - - - Expiring within a week -
    - -
    - -
    - - -
    -
    -
    -
    -
    - - - -
    -
    - 2 - Warning (8-14 days) - - - Expiring in 2 weeks -
    - -
    - -
    - -
    -
    -
    -
    -
    - - - -
    -
    - 2 - Caution (15-30 days) - - - Monitor closely -
    - -
    - -
    - -
    - -
    -
    -
    -
    -
    -

    Filter Products

    -
    -
    -
    - - - - - -
    -
    -
    -
    - - - -
    - -
    - - -
    - - -
    - -
    - - -
    - -
    -
    -
    -
    -
    -
    -

    Expired Products

    -

    Products that have passed their expiry date and need to be removed

    -
    -
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Product NameSKUCategoryExpiry DateDays OverdueQuantityStatusAction
    Wireless MouseWM-001Electronics2025-11-1547 days12Expired - -
    USB-C CableUC-002Accessories2025-12-0131 days8Expired - -
    KeyboardKB-004Electronics2025-12-2012 days5Expired - -
    - -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -

    Near Expiry Products

    -

    Products approaching their expiry date that may need attention

    -
    -
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Product NameSKUCategoryExpiry DateDays RemainingQuantityStatusAction
    Laptop StandLS-003Furniture2026-01-054 days15Critical (≤7 days) - Mark For Sale -
    Monitor MN-005Electronics2026-01-087 days10Critical (≤7 days) - Mark For Sale -
    Desk LampDL-006Furniture2026-01-1211 days20Warning (8-14 days) - Mark For Sale -
    Webcam HDWC-007Electronics2026-01-1817 days18Caution (15-30 days) - Mark For Sale -
    HDMI CableHD-008Accessories2026-01-2524 days30Caution (15-30 days) - Mark For Sale -
    - -
    -
    -
    -
    -
    - -
    -
    -
    -

    Copyright © 2026 InApp Inventory Dashboard. Developed by CodesCandy

    -
    -
    - -
    - - -
    -
    - - - - - - - - - - - \ No newline at end of file diff --git a/app/ics/expired_stock.php b/app/ics/expired_stock.php new file mode 100644 index 0000000..704c177 --- /dev/null +++ b/app/ics/expired_stock.php @@ -0,0 +1,362 @@ + + + + + + + +
    +
    + + +
    +
    +
    +
    +

    Expired / Near Expiry Products

    +

    Monitor and manage products approaching or past expiry dates

    +
    +
    + + +
    +
    +
    +
    + + +
    + + +
    +
    +
    +
    + + + +
    + — + Expired Products + Requires immediate action +
    +
    +
    +
    +
    +
    +
    + + + +
    + — + Critical (≤7 days) + Expiring within a week +
    +
    +
    +
    +
    +
    +
    + + + +
    + — + Warning (8–14 days) + Expiring in 2 weeks +
    +
    +
    +
    +
    +
    +
    + + + +
    + — + Caution (15–30 days) + Monitor closely +
    +
    +
    +
    +
    + + +
    +
    +
    +
    +

    Filter Products

    +
    +
    +
    + + + + +
    +
    +
    + +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    +

    Expired Products

    +

    Products that have passed their expiry date and need to be removed

    +
    + + + + + + + + + + + + + + +
    ProductSKULot NumberLocationExpiry DateDays OverdueQuantityStatus
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    +

    Near Expiry Products

    +

    Products approaching their expiry date that may need attention

    +
    + + + + + + + + + + + + + + +
    ProductSKULot NumberLocationExpiry DateDays RemainingQuantityStatus
    +
    +
    +
    +
    +
    + +
    + +
    +
    + + + + + + + + \ No newline at end of file diff --git a/app/ics/low-stock-products.php b/app/ics/low-stock-products.php deleted file mode 100644 index 6f3699c..0000000 --- a/app/ics/low-stock-products.php +++ /dev/null @@ -1,203 +0,0 @@ - - - - - - - - -
    -
    -
    -
    -
    -

    Low Stock Products -

    - - - Restock All - -
    -
    -
    -
    -
    -
    -
    -
    - Total Low Stock - - - -
    -
    - 6 - - Products need attention -
    - -
    - -
    - -
    -
    -
    -
    -
    - Critical Level - - - -
    -
    - 3 - - Immediate restock needed -
    - -
    - -
    - -
    -
    -
    -
    -
    - Warning Level - - - -
    -
    - 3 - - Restock soon -
    - -
    - -
    - -
    - -
    -
    -
    -
    -
    -

    Low Stock Items

    -
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    ProductSKUCurrent StockMin StockWarehouseStatusAction
    Product ASKU-001520Main WarehouseCriticalRestock
    Product BSKU-0021225Main WarehouseWarningRestock
    Product CSKU-003315Secondary WarehouseCriticalRestock
    Product DSKU-0041830Main WarehouseWarningRestock
    Product ESKU-005210Secondary WarehouseCriticalRestock
    Product FSKU-006820Main WarehouseWarningRestock
    -
    -
    - -
    - -
    - -
    - -
    -
    - - - - - - - - - - - \ No newline at end of file diff --git a/app/ics/manage_stock_in.php b/app/ics/manage_stock_in.php index 35f210e..5ec0f25 100644 --- a/app/ics/manage_stock_in.php +++ b/app/ics/manage_stock_in.php @@ -44,7 +44,23 @@
    + +
    + + +
    + +
    + + +
    + + +
    + + +
    @@ -79,7 +95,6 @@
    -
    @@ -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(); + + + prefill_from_restock(); + } 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 + } + }); + + $("select#warehouse").change(function() { retrieve_zone('to'); diff --git a/app/ics/manage_stock_out.php b/app/ics/manage_stock_out.php index 8f6293b..4754859 100644 --- a/app/ics/manage_stock_out.php +++ b/app/ics/manage_stock_out.php @@ -2,22 +2,19 @@ session_start(); require '../config.php'; require '../include_header.php'; - ?> -
    -
    +

    Stock Out

    -
    @@ -25,9 +22,10 @@
    -

    Add Stock

    +

    Remove Stock

    - + +
    - -
    - + +
    + +
    + +
    + + +
    - - +
    - +
    +
    -
    +
    -
    +
    -
    @@ -78,7 +90,6 @@
    -
    @@ -87,15 +98,13 @@
    - +
    -
    -
    @@ -114,67 +123,121 @@ }, action: 'manage', onSuccess: function(res) { - window.location.href = "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: "ics/api/engine/retrieve_active_lot.php", + autoPrepare: true, + checkRequired: 0, + noLoading: true, + action: 'read', + data: { "product_sku": sku }, + onSuccess: function(res) { + + var option = ''; + $.each(res.output, function(i, item) { + var label = item.lot_number; + if (item.expiry_date) label += ' — exp: ' + item.expiry_date; + option += ``; + }); + $('#lot_number').html(option).prop('disabled', false); + $('#serial_number').html('').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: "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 = ''; + $.each(res.output, function(i, 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: "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 = ``; + var option = ''; $.each(res.output, function(key, item) { 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: "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 = ``; + var option = ''; $.each(res.output, function(key, item) { 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: "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 = ``; + var option = ''; $.each(res.output, function(key, 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: "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 = ``; + var option = ''; $.each(res.output, function(key, 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('').prop('disabled', true); + $('#zone').html('').prop('disabled', true); + $('#aisle').html('').prop('disabled', true); + $('#rack').html('').prop('disabled', true); + } + + // ── Edit mode ───────────────────────────────────────────────────────── function retrieve_for_edit() { @@ -242,24 +314,25 @@ var data = res.output; - // 1. Set warehouse (already populated from page load) - $(`#warehouse`).val().attr('disabled', true); // set warehouse BEFORE retrieve_zone + // Lock lot/serial — not editable in edit mode + $('#lot_number').html(``) + .val(data.lot_number || '').prop('disabled', true); + $('#serial_number').html(``) + .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('').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) ─────────────────────────────── - $("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(); }); diff --git a/app/ics/manage_stock_transfer.php b/app/ics/manage_stock_transfer.php index 1376d8d..31c45fd 100644 --- a/app/ics/manage_stock_transfer.php +++ b/app/ics/manage_stock_transfer.php @@ -2,22 +2,19 @@ session_start(); require '../config.php'; require '../include_header.php'; - ?> -
    -
    +

    Stock Transfer

    -
    @@ -27,7 +24,8 @@

    Move Stock

    - + +
    - -
    - -
    - - - - +
    - - +
    +
    - - + + +
    + +

    + + +
    + From +
    + +
    + + +
    +
    + +
    -
    - -
    -
    - -
    +

    - -
    - - + +
    + To
    - + + +
    +
    +
    -
    - +
    -
    - + @@ -110,24 +122,21 @@
    -
    - +
    - +
    -
    -
    @@ -146,135 +155,189 @@ }, action: 'manage', onSuccess: function(res) { - window.location.href = "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: "ics/api/engine/retrieve_active_lot.php", + autoPrepare: true, + checkRequired: 0, + noLoading: true, + action: 'read', + data: { "product_sku": sku }, + onSuccess: function(res) { + var option = ''; + $.each(res.output, function(i, item) { + var label = item.lot_number; + if (item.expiry_date) label += ' — exp: ' + item.expiry_date; + option += ``; + }); + $('#lot_number').html(option).prop('disabled', false); + $('#serial_number').html('').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: "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 = ''; + $.each(res.output, function(i, serial) { + option += ``; + }); + $('#serial_number').html(option).prop('disabled', false); + reset_from_location(); + } + }); + } + + + // ── Warehouse loaders ───────────────────────────────────────────────── function retrieve_warehouse(type) { return ajax_request({ 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 = ``; + var option = ''; $.each(res.output, function(key, item) { 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: "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 = ``; + var option = ''; $.each(res.output, function(key, item) { 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: "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 = ``; + var option = ''; $.each(res.output, function(key, 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: "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 = ``; + var option = ''; $.each(res.output, function(key, 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('').prop('disabled', true); + $('#zone_from').html('').prop('disabled', true); + $('#aisle_from').html('').prop('disabled', true); + $('#rack_from').html('').prop('disabled', true); + } + + // ── Edit mode ───────────────────────────────────────────────────────── function retrieve_for_edit() { @@ -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(``) + .val(data.lot_number || '').prop('disabled', true); + $('#serial_number').html(``) + .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) ─────────────────────────────── - $("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'); }); diff --git a/app/ics/stock_in.php b/app/ics/stock_in.php index 0e3c27a..3b3728d 100644 --- a/app/ics/stock_in.php +++ b/app/ics/stock_in.php @@ -48,6 +48,8 @@ Date Product + Lot Number + Serial Number Quantity Zone Aisle @@ -124,6 +126,8 @@ body += ` ${format_date(item["date"])} ${item["product_sku"]}: ${item['product_name']} + ${item['lot_number'] || '—'} + ${item['serial_number'] || '—'} ${item['quantity']} ${item['zone']} ${item['aisle']} diff --git a/app/ics/stock_out.php b/app/ics/stock_out.php index 0c30699..4ffa0b3 100644 --- a/app/ics/stock_out.php +++ b/app/ics/stock_out.php @@ -48,6 +48,8 @@ Date Product + Lot Number + Serial Number Quantity Zone Aisle @@ -124,6 +126,8 @@ body += ` ${format_date(item["date"])} ${item["product_sku"]}: ${item['product_name']} + ${item['lot_number'] || '—'} + ${item['serial_number'] || '—'} ${item['quantity']} ${item['zone']} ${item['aisle']} diff --git a/app/ics/stock_overview.php b/app/ics/stock_overview.php index e82bf24..cce75f7 100644 --- a/app/ics/stock_overview.php +++ b/app/ics/stock_overview.php @@ -2,183 +2,151 @@ session_start(); require '../config.php'; require '../include_header.php'; - ?> -
    -
    -
    -
    -

    Stock Overview

    -
    -
    - -
    -
    - -
    -
    + +
    +
    +
    +
    +

    Stock Overview

    +

    Warehouse performance and stock summary

    +
    +
    + + +
    -
    -
    -
    -
    -
    -
    - Total Capacity -

    0

    -

    Across all warehouses

    -
    -
    + + +
    + + +
    +
    +
    +
    +
    +
    + Total Capacity +

    —

    +

    Racks in warehouse

    +
    - -
    +
    -
    - -
    -
    -
    -
    -
    - Total Stock In -

    +456

    -

    Unit added

    -
    -
    +
    +
    +
    +
    +
    + Total Stock In +

    —

    +

    Units added

    +
    - -
    +
    - - -
    -
    -
    -
    -
    -
    - Total Stock Out -

    -289

    -

    Unit removed

    -
    -
    +
    +
    +
    +
    +
    + Total Stock Out +

    —

    +

    Units removed

    +
    - -
    +
    - - -
    -
    -
    -
    -
    -
    - Low Stock Alerts -

    12

    -

    Items need attention

    -
    -
    +
    +
    +
    +
    +
    + Low Stock Alerts +

    —

    +

    Items need attention

    +
    - -
    +
    - -
    -
    -
    -
    - -
    -
    -
    + +
    +
    +
    +
    Stock Movement
    - +
    -
    -
    -
    - -
    +
    -
    - -
    -
    - -
    -
    -
    - +
    +
    +
    Stock Level Trend
    - +
    -
    -
    -
    - -
    +
    -
    -
    -
    -
    -
    -
    -
    -

    Recent Stock Activity

    - -
    + +
    +
    +
    +
    +

    Recent Stock Activity

    +
    +
    +
    -
    -
    -
    -
    + +
    @@ -186,233 +154,120 @@ - \ No newline at end of file diff --git a/app/include_sidebar.php b/app/include_sidebar.php index a943008..5d2e7c9 100644 --- a/app/include_sidebar.php +++ b/app/include_sidebar.php @@ -33,6 +33,13 @@
  • +
  • + + + Low Stock Products + +
  • +
  • - - - Low Stock Products - -
  • - -
  • - + Expired Stock diff --git a/app/inventory/api/engine/manage_product.php b/app/inventory/api/engine/manage_product.php index 26db554..f2c9efd 100644 --- a/app/inventory/api/engine/manage_product.php +++ b/app/inventory/api/engine/manage_product.php @@ -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 **/ diff --git a/app/inventory/manage_product.php b/app/inventory/manage_product.php index d290062..ff8c239 100644 --- a/app/inventory/manage_product.php +++ b/app/inventory/manage_product.php @@ -45,11 +45,15 @@
    - + +
    + Safety floor
    - + +
    + Must be greater than min stock
    @@ -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); + diff --git a/app/inventory/manage_storage.php b/app/inventory/manage_storage.php index d03527e..d3d0822 100644 --- a/app/inventory/manage_storage.php +++ b/app/inventory/manage_storage.php @@ -41,19 +41,27 @@
    - + +
    Number (1–99) or letter (A–Z)
    - + +
    Must be same type as Aisle From
    - + +
    Number (1–99) or letter (A–Z)
    - + +
    Must be same type as Rack From
    @@ -85,6 +93,45 @@ - \ No newline at end of file diff --git a/app/inventory/warehouse.php b/app/inventory/warehouse.php index 58af3aa..1748f51 100644 --- a/app/inventory/warehouse.php +++ b/app/inventory/warehouse.php @@ -2,7 +2,6 @@ session_start(); require '../config.php'; require '../include_header.php'; - ?> @@ -13,30 +12,33 @@
    -
    + + +
    -

    Warehouse / Location Management -

    +

    Warehouse / Location Management

    Manage your warehouses, storage locations, and capacity.

    + +
    -
    +
    -
    -
    +
    + +
    - 5 + — Total Warehouses - - Across 5 locations + Across — locations
    -
    +
    -
    -
    + +
    - 41,000 - + — Total Capacity - Rack positions available - + Rack positions available
    -
    +
    -
    -
    + +
    - 25,500 - + — Space Used - Racks occupied - + Racks occupied
    -
    +
    -
    -
    + +
    - 730 + — Total Products - - Products stored + Products stored
    -
    +
    -
    +
    -
    - -
    - -
    + +
    -
    +
    +

    Warehouses

    All warehouse facilities and their current status

    +
    - + -
    +
    @@ -182,46 +180,49 @@ - - - - +
    Capacity Products StatusAction
    -
    +
    + +
    -
    +
    +

    Storage Locations

    Zones, aisles, and rack assignments

    +
    - + -
    +
    @@ -231,17 +232,14 @@ - - - - +
    Aisle Range Rack Range Items StoredAction
    -
    +
    @@ -253,7 +251,14 @@ - \ No newline at end of file