warehouse layers [switchable]

This commit is contained in:
Thanakorn S
2026-05-04 13:43:32 +07:00
parent a90975d9f1
commit 414f1cd81d
26 changed files with 2022 additions and 584 deletions
+15
View File
@@ -698,6 +698,21 @@ function format_date(iso_string) {
return (datePart) ? `${datePart[2]}/${datePart[1]}/${datePart[0]} ${split[1]}` : iso_string;
}
function to_iso_date(dateStr) {
if (!dateStr) return null;
const parts = dateStr.split('/');
if (parts.length !== 3) return null;
let [day, month, year] = parts;
day = day.padStart(2, '0');
month = month.padStart(2, '0');
return `${year}-${month}-${day}`;
}
function format_number(value, decimal) {
var n = Number(value);
if (isNaN(n)) return '--';
@@ -5,28 +5,37 @@
* Table: company_setting (company_id, setting_key, value, log, updated_at)
*
* Current keys:
* default_stock_status int 0 = draft (manual approve required)
* 1 = auto-approve on save
* auto_invoice_and_credit_note int 0 = manual
* 1 = auto-generate invoice on confirm order
* + auto-generate credit note on confirm return
* default_stock_status int 0 = draft, 1 = auto-approve on save
* auto_invoice_and_credit_note int 0 = manual, 1 = auto-generate
* auto_complete_on_ship int 0 = manual, 1 = auto-complete on shipped
* advanced_location int 0 = simple (single location), 1 = zone/aisle/rack
* location_label_rack string label for rack / single location field
* location_label_zone string label for zone (advanced only)
* location_label_aisle string label for aisle (advanced only)
*
* Designed to accept new keys without schema changes.
*/
class CompanySettingManager
{
private PDO $pdo;
private ?PDO $transactionPdo;
private int $companyId;
// ── Known keys with their defaults ───────────────────────────────────────
private const DEFAULTS = [
'default_stock_status' => 1, // auto-approve by default
'auto_invoice_and_credit_note' => 0, // manual by default
'auto_complete_on_ship' => 1, // auto-complete by default
'advanced_location' => 0, // simple mode by default
'location_label_rack' => 'Location', // single label default
'location_label_zone' => 'Zone', // advanced level 1 default
'location_label_aisle' => 'Aisle', // advanced level 2 default
];
public function __construct(PDO $pdo, int $companyId)
public function __construct(PDO $pdo, int $companyId, ?PDO $transactionPdo = null)
{
$this->pdo = $pdo;
$this->transactionPdo = $transactionPdo;
$this->companyId = $companyId;
}
@@ -80,6 +89,32 @@ class CompanySettingManager
return $result;
}
/**
* Return settings that are locked by existing transaction data.
*
* @return array Locked key => reason pairs.
*/
public function getTransactionLocks(): array
{
$locked = [];
if (!$this->transactionPdo || $this->isLocalhostRequest()) {
return $locked;
}
if ($this->hasStockTransactions()) {
$locked['default_stock_status'] = 'stock transactions already exist';
$locked['advanced_location'] = 'stock transactions already exist';
}
if ($this->hasOrderTransactions()) {
$locked['auto_complete_on_ship'] = 'order, invoice, or return transactions already exist';
$locked['auto_invoice_and_credit_note'] = 'order, invoice, or return transactions already exist';
}
return $locked;
}
/**
* Upsert a single setting value.
*
@@ -114,6 +149,144 @@ class CompanySettingManager
]);
}
/**
* Return keys whose value cannot be changed because related transactions exist.
*
* @param array $incoming Requested key => value pairs.
* @return array Blocked key => reason pairs.
*/
private function blockedChanges(array $incoming): array
{
if (!$this->transactionPdo || $this->isLocalhostRequest()) {
return [];
}
$current = $this->getAll();
$blocked = [];
$stock_keys = [
'default_stock_status',
'advanced_location',
];
$order_keys = [
'auto_complete_on_ship',
'auto_invoice_and_credit_note',
];
$needs_stock_check = false;
foreach ($stock_keys as $key) {
if (array_key_exists($key, $incoming) && (string)$current[$key] !== (string)$incoming[$key]) {
$needs_stock_check = true;
break;
}
}
$needs_order_check = false;
foreach ($order_keys as $key) {
if (array_key_exists($key, $incoming) && (string)$current[$key] !== (string)$incoming[$key]) {
$needs_order_check = true;
break;
}
}
$has_stock_transactions = $needs_stock_check ? $this->hasStockTransactions() : false;
$has_order_transactions = $needs_order_check ? $this->hasOrderTransactions() : false;
if ($has_stock_transactions) {
foreach ($stock_keys as $key) {
if (array_key_exists($key, $incoming) && (string)$current[$key] !== (string)$incoming[$key]) {
$blocked[$key] = 'stock transactions already exist';
}
}
}
if ($has_order_transactions) {
foreach ($order_keys as $key) {
if (array_key_exists($key, $incoming) && (string)$current[$key] !== (string)$incoming[$key]) {
$blocked[$key] = 'order, invoice, or return transactions already exist';
}
}
}
return $blocked;
}
private function hasOrderTransactions(): bool
{
foreach (['td_order', 'td_invoice', 'td_return'] as $table) {
if ($this->tableHasCompanyRows($table)) {
return true;
}
}
return false;
}
private function hasStockTransactions(): bool
{
if ($this->tableHasCompanyRows('warehouse_balance')) {
return true;
}
if ($this->tableHasCompanyRows('md_lot')) {
return true;
}
$sth = $this->transactionPdo->prepare(
"SELECT warehouse_name FROM md_warehouse WHERE company_id = :company_id"
);
$sth->execute([':company_id' => $this->companyId]);
$warehouses = $sth->fetchAll(PDO::FETCH_COLUMN);
foreach ($warehouses as $warehouse_name) {
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', (string)$warehouse_name);
if ($safe !== '' && $this->tableHasCompanyRows('td_stock_' . $safe)) {
return true;
}
}
return false;
}
private function tableHasCompanyRows(string $table): bool
{
if (!$this->tableExists($table)) {
return false;
}
$safe_table = str_replace('`', '``', $table);
$sth = $this->transactionPdo->prepare(
"SELECT COUNT(*) FROM `{$safe_table}` WHERE company_id = :company_id LIMIT 1"
);
$sth->execute([':company_id' => $this->companyId]);
return (int)$sth->fetchColumn() > 0;
}
private function tableExists(string $table): bool
{
$sth = $this->transactionPdo->prepare(
"SELECT COUNT(*)
FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = :table_name"
);
$sth->execute([':table_name' => $table]);
return (int)$sth->fetchColumn() > 0;
}
private function isLocalhostRequest(): bool
{
$host = strtolower($_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? '');
$host = preg_replace('/:\d+$/', '', $host);
return in_array($host, ['localhost', '127.0.0.1', '::1'], true)
|| ($_SERVER['REMOTE_ADDR'] ?? '') === '127.0.0.1'
|| ($_SERVER['REMOTE_ADDR'] ?? '') === '::1';
}
/**
* HTTP handler — read or update settings via AJAX.
*
@@ -127,14 +300,48 @@ class CompanySettingManager
$action = $data['action'] ?? '';
if ($action === 'read') {
echo json_encode(['success' => 1, 'output' => $this->getAll()]);
echo json_encode([
'success' => 1,
'output' => $this->getAll(),
'locked' => $this->getTransactionLocks(),
]);
exit;
}
if ($action === 'update') {
$allowed = array_keys(self::DEFAULTS);
$incoming = [];
foreach ($allowed as $key) {
$this->set($key, $data[$key] ?? self::DEFAULTS[$key]);
$incoming[$key] = $data[$key] ?? self::DEFAULTS[$key];
}
$blocked = $this->blockedChanges($incoming);
if (!empty($blocked)) {
$labels = [
'default_stock_status' => 'Default Stock Status',
'advanced_location' => 'Advanced Location',
'auto_invoice_and_credit_note' => 'Invoice & Credit Note Generation',
'auto_complete_on_ship' => 'Auto-Complete on Shipped',
];
$names = array_map(
fn($key) => $labels[$key] ?? $key,
array_keys($blocked)
);
http_response_code(409);
echo json_encode([
'success' => 0,
'message' => 'Cannot change ' . implode(', ', $names) . ' because relevant transactions already exist.',
'blocked' => $blocked,
]);
exit;
}
foreach ($incoming as $key => $value) {
if ((string)$this->get($key) !== (string)$value) {
$this->set($key, $value);
}
}
echo json_encode(['success' => 1, 'message' => 'Settings saved.']);
exit;
+167 -51
View File
@@ -8,7 +8,8 @@
*
* Method order:
* Transaction basis → getOrderList, getOrderById, generateOrderNumber,
* saveOrder, confirmOrder, cancelOrder
* saveOrder, confirmOrder, cancelOrder,
* updateFulfillmentStatus
*
* Key design decisions:
* - Order items are stored as a JSON array in td_order.items.
@@ -19,10 +20,10 @@
* - confirmOrder() creates stock-out rows with status=0 (draft).
* Warehouse staff approve them via the existing approve_stock.php
* engine, which handles rack release and balance adjustment.
* - cancelOrder() sets td_order.status = -1 and flips all linked
* draft stock-out rows (status=0) to status=-1 (cancelled).
* Approved stock-out rows block cancellation — caller must handle
* these manually before cancelling.
* - cancelOrder() sets td_order.status = -1 and soft-deletes ALL linked
* stock-out rows (status → -1) across all td_stock_* tables.
* Cancellation is blocked if any active invoice (status != -1) or
* active return (status != -1) is linked to the order.
* - Cancel logic is intentionally self-contained here. status=-1 is
* an order-domain concept with no rack/balance side effects, so
* WarehouseManager and StockManager are not involved.
@@ -240,7 +241,7 @@ class OrderManager {
* Must be called inside dbTransaction() by the caller.
*
* @param array $data Keys: id, contact_id, order_date, items (array),
* discount, tax, shipping_fee, notes.
* discount, tax, shipping_fee, shipping_tracking_number, notes.
* @param array $logging Audit entry to append to log column.
* @return int New td_order.id on insert, 0 on update.
* @throws Exception If updating a non-draft order.
@@ -257,6 +258,7 @@ class OrderManager {
$discount = (float)($data['discount'] ?? 0);
$tax = (float)($data['tax'] ?? 0);
$shipping_fee = (float)($data['shipping_fee'] ?? 0);
$tracking_no = trim((string)($data['shipping_tracking_number'] ?? ''));
$grand_total = $subtotal - $discount + $tax + $shipping_fee;
if ($id > 0) {
@@ -288,6 +290,7 @@ class OrderManager {
discount = :discount,
tax = :tax,
shipping_fee = :shipping_fee,
shipping_tracking_number = :shipping_tracking_number,
grand_total = :grand_total,
notes = :notes,
`log` = :log
@@ -300,6 +303,7 @@ class OrderManager {
':discount' => $discount,
':tax' => $tax,
':shipping_fee' => $shipping_fee,
':shipping_tracking_number' => $tracking_no,
':grand_total' => $grand_total,
':notes' => $data['notes'] ?? '',
':log' => json_encode($log),
@@ -317,11 +321,11 @@ class OrderManager {
"INSERT INTO td_order
(company_id, uuid, order_number, contact_id, order_date,
status, payment_status, subtotal, discount, tax,
shipping_fee, grand_total, items, notes, `log`, created_at)
shipping_fee, shipping_tracking_number, grand_total, items, notes, `log`, created_at)
VALUES
(:company_id, :uuid, :order_number, :contact_id, :order_date,
0, 0, :subtotal, :discount, :tax,
:shipping_fee, :grand_total, :items, :notes, :log, :created_at)"
:shipping_fee, :shipping_tracking_number, :grand_total, :items, :notes, :log, :created_at)"
)->execute([
':company_id' => $this->company_id,
':uuid' => bin2hex(random_bytes(16)),
@@ -332,6 +336,7 @@ class OrderManager {
':discount' => $discount,
':tax' => $tax,
':shipping_fee' => $shipping_fee,
':shipping_tracking_number' => $tracking_no,
':grand_total' => $grand_total,
':items' => json_encode($items, JSON_UNESCAPED_UNICODE),
':notes' => $data['notes'] ?? '',
@@ -484,28 +489,27 @@ class OrderManager {
}
/**
* Cancel an order and flip all linked draft stock-out rows to status=-1.
* Cancel an order and soft-delete all linked stock-out rows (status → -1).
*
* Flow:
* 1. Load the order — must be status=0 (draft) or status=1 (confirmed).
* Orders at status=2 (processing) or status=3 (completed) cannot be
* cancelled here; they require manual stock reversal first.
* 2. Check that no linked stock-out rows are already approved (status=1).
* If any are approved, throw — those must be deleted via the existing
* deleteStockOut() flow before cancellation can proceed.
* 3. UPDATE all td_stock_<wh> rows where
* source='order' AND source_id=order_id AND status=0 → status=-1.
* 4. UPDATE td_order.status = -1.
* Business rule:
* An order can be cancelled (Draft or Confirmed) as long as no active
* downstream documents exist. Active means status != -1 (not voided /
* not cancelled).
*
* Cancel logic is intentionally self-contained — status=-1 rows have no
* rack/balance side effects so WarehouseManager is not involved.
* Downstream documents that block cancellation:
* - td_invoice where order_id = $order_id AND status != -1
* - td_return where order_id = $order_id AND status != -1
*
* Stock-out rows are children of the order — they follow the parent
* and are never independently approved. On cancel, ALL stock-out rows
* linked to this order (any status except already -1) are soft-deleted
* (status → -1) across all td_stock_* tables.
*
* Must be called inside dbTransaction() by the caller.
*
* @param int $order_id td_order.id to cancel.
* @param array $logging Audit entry appended to td_order.log.
* @throws Exception If order not found, already cancelled, in a non-cancellable
* status, or has approved stock-out rows.
* @throws Exception
*/
public function cancelOrder(int $order_id, array $logging): void
{
@@ -528,14 +532,41 @@ class OrderManager {
}
if ($status >= 2) {
throw new Exception(
"Cannot cancel an order that is processing or completed. " .
"Please reverse stock movements manually first."
"Cannot cancel an order that is processing or completed."
);
}
// ── Guard: no approved stock-out rows ─────────────────────────────
// Discover all td_stock_* tables and check for approved rows
// linked to this order before making any changes.
// ── Guard: no active invoice ──────────────────────────────────────
$sth = $this->pdo->prepare(
"SELECT COUNT(*) FROM td_invoice
WHERE company_id = :company_id
AND order_id = :order_id
AND status != -1"
);
$sth->execute([':company_id' => $this->company_id, ':order_id' => $order_id]);
if ((int)$sth->fetchColumn() > 0) {
throw new Exception(
"Cannot cancel — this order has an active invoice. " .
"Please void the invoice first."
);
}
// ── Guard: no active return ───────────────────────────────────────
$sth = $this->pdo->prepare(
"SELECT COUNT(*) FROM td_return
WHERE company_id = :company_id
AND order_id = :order_id
AND status != -1"
);
$sth->execute([':company_id' => $this->company_id, ':order_id' => $order_id]);
if ((int)$sth->fetchColumn() > 0) {
throw new Exception(
"Cannot cancel — this order has an active return. " .
"Please cancel the return first."
);
}
// ── Soft-delete all linked stock-out rows ─────────────────────────
$sth = $this->pdo->prepare(
"SELECT table_name FROM information_schema.tables
WHERE table_schema = DATABASE()
@@ -545,33 +576,13 @@ class OrderManager {
$tables = $sth->fetchAll(PDO::FETCH_COLUMN);
foreach ($tables as $table) {
$sth = $this->pdo->prepare(
"SELECT COUNT(*) FROM `{$table}`
WHERE company_id = :company_id
AND source = 'order'
AND source_id = :order_id
AND status = 1"
);
$sth->execute([
':company_id' => $this->company_id,
':order_id' => $order_id,
]);
if ((int)$sth->fetchColumn() > 0) {
throw new Exception(
"Cannot cancel — some stock-out rows for this order are already " .
"approved. Please delete them from the Stock Out page first."
);
}
}
// ── Flip draft stock-out rows to cancelled ────────────────────────
foreach ($tables as $table) {
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
$this->pdo->prepare(
"UPDATE `{$table}` SET status = -1
"UPDATE `{$safe}` SET status = -1
WHERE company_id = :company_id
AND source = 'order'
AND source_id = :order_id
AND status = 0"
AND status != -1"
)->execute([
':company_id' => $this->company_id,
':order_id' => $order_id,
@@ -593,4 +604,109 @@ class OrderManager {
':company_id' => $this->company_id,
]);
}
/**
* Update the fulfillment sub-status of a confirmed order.
*
* Fulfillment status values:
* 1 = Picking — warehouse staff are picking items
* 2 = Packed — items are packed, ready to ship
* 3 = Shipped — goods have left the warehouse
*
* Rules:
* - Only allowed on orders with status >= 1 (confirmed) and not cancelled.
* - Fulfillment must move forward only (no going backwards).
* - If auto_complete_on_ship = true AND fulfillment_status = 3 (Shipped),
* order status is automatically advanced to 3 (Completed).
* - If auto_complete_on_ship = false, order status is set to 2 (Processing)
* on first fulfillment update, and stays there until manually completed.
*
* Must be called inside dbTransaction() by the caller.
*
* @param int $order_id td_order.id
* @param int $fulfillment_status 1=Picking, 2=Packed, 3=Shipped
* @param string $tracking_number Shipping carrier tracking number.
* @param array $logging Audit entry.
* @param bool $auto_complete Whether Shipped auto-drives order to Completed.
* @throws Exception
*/
public function updateFulfillmentStatus(
int $order_id,
int $fulfillment_status,
string $tracking_number,
array $logging,
bool $auto_complete = true
): void {
// ── Load order ────────────────────────────────────────────────────
$sth = $this->pdo->prepare(
"SELECT * FROM td_order
WHERE company_id = :company_id AND id = :id"
);
$sth->execute([':company_id' => $this->company_id, ':id' => $order_id]);
$order = $sth->fetch(PDO::FETCH_ASSOC);
if (!$order) {
throw new Exception("Order not found.");
}
$status = (int)$order['status'];
$current_fulfillment = (int)($order['fulfillment_status'] ?? 0);
$current_tracking = trim((string)($order['shipping_tracking_number'] ?? ''));
$tracking_number = trim($tracking_number);
if ($status === -1) {
throw new Exception("Cannot update a cancelled order.");
}
if ($status < 1) {
throw new Exception("Confirm the order before updating fulfillment.");
}
if ($status === 3) {
throw new Exception("Order is already completed.");
}
if (!in_array($fulfillment_status, [1, 2, 3], true)) {
throw new Exception("Invalid fulfillment status.");
}
if ($fulfillment_status < $current_fulfillment) {
throw new Exception("Fulfillment status can only move forward.");
}
if ($fulfillment_status === $current_fulfillment && $tracking_number === $current_tracking) {
throw new Exception("No fulfillment or tracking changes to save.");
}
// ── Determine new order status ────────────────────────────────────
$new_order_status = $status;
if ($fulfillment_status > $current_fulfillment && $fulfillment_status === 3 && $auto_complete) {
// Shipped + auto-complete → Completed
$new_order_status = 3;
} elseif ($fulfillment_status > $current_fulfillment && $status === 1) {
// First fulfillment update → Processing
$new_order_status = 2;
}
// ── Persist ───────────────────────────────────────────────────────
$log = json_decode($order['log'] ?? '[]', true) ?: [];
$log[] = array_merge($logging, [
'action' => 'update_fulfillment',
'fulfillment_status' => $fulfillment_status,
'shipping_tracking_number' => $tracking_number,
'order_status' => $new_order_status,
]);
$this->pdo->prepare(
"UPDATE td_order SET
fulfillment_status = :fulfillment_status,
shipping_tracking_number = :shipping_tracking_number,
status = :status,
`log` = :log
WHERE id = :id AND company_id = :company_id"
)->execute([
':fulfillment_status' => $fulfillment_status,
':shipping_tracking_number' => $tracking_number,
':status' => $new_order_status,
':log' => json_encode($log),
':id' => $order_id,
':company_id' => $this->company_id,
]);
}
}
+141 -1
View File
@@ -7,7 +7,7 @@
*
* Method order:
* Transaction basis → getReturnList, getReturnById, generateReturnNumber,
* saveReturn, confirmReturn
* saveReturn, confirmReturn, cancelReturn
*
* Key design decisions:
* - status: -1=cancelled, 0=draft, 1=confirmed (consistent with td_order)
@@ -386,4 +386,144 @@ class ReturnManager {
);
}
}
/**
* Cancel a return (Draft or Confirmed) and soft-delete linked stock-in rows.
*
* Business rule:
* A return can be cancelled as long as no active downstream document exists.
* Active means status != -1 (not voided).
*
* Downstream document that blocks cancellation:
* - td_invoice (credit note) where ref_invoice_id links back via
* order_id = return.order_id AND doc_type = 'credit_note' AND status != -1.
* Credit notes store order_id (not return_id), so we match via order_id
* and doc_type.
*
* If the return was confirmed, stock-in rows (source='return', source_id=$return_id)
* were created with status=1 and rack/balance were already adjusted.
* On cancel we soft-delete those rows (status → -1) and reverse the
* rack occupation and balance via WarehouseManager.
*
* If the return was still draft, no stock rows exist — simple status flip.
*
* Must be called inside dbTransaction() by the caller.
*
* @param int $return_id td_return.id to cancel.
* @param array $logging Audit entry appended to td_return.log.
* @param object $whMgmt WarehouseManager instance (needed for confirmed reversal).
* @throws Exception
*/
public function cancelReturn(int $return_id, array $logging, $whMgmt): void
{
// ── Load return ───────────────────────────────────────────────────
$sth = $this->pdo->prepare(
"SELECT * FROM td_return
WHERE company_id = :company_id AND id = :id"
);
$sth->execute([':company_id' => $this->company_id, ':id' => $return_id]);
$return = $sth->fetch(PDO::FETCH_ASSOC);
if (!$return) {
throw new Exception("Return not found.");
}
$status = (int)$return['status'];
if ($status === -1) {
throw new Exception("Return is already cancelled.");
}
// ── Guard: no active credit note ──────────────────────────────────
$order_id = (int)$return['order_id'];
if ($order_id > 0) {
$sth = $this->pdo->prepare(
"SELECT COUNT(*) FROM td_invoice
WHERE company_id = :company_id
AND order_id = :order_id
AND doc_type = 'credit_note'
AND status != -1"
);
$sth->execute([':company_id' => $this->company_id, ':order_id' => $order_id]);
if ((int)$sth->fetchColumn() > 0) {
throw new Exception(
"Cannot cancel — this return has an active credit note. " .
"Please void the credit note first."
);
}
}
// ── If confirmed: reverse stock-in rows + rack/balance ────────────
if ($status === 1) {
$items = json_decode($return['items'] ?? '[]', true) ?: [];
foreach ($items as $item) {
$warehouse_id = (int)($item['warehouse_id'] ?? 0);
$product_sku = $item['product_sku'] ?? '';
$quantity = (float)($item['quantity'] ?? 0);
if (!$warehouse_id || !$product_sku) continue;
$table = $this->resolveStockTable($warehouse_id);
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
// Find the stock-in row created by confirmReturn()
$sth = $this->pdo->prepare(
"SELECT id, zone, aisle, rack FROM `{$safe}`
WHERE company_id = :company_id
AND source = 'return'
AND source_id = :return_id
AND product_sku = :sku
AND status = 1
LIMIT 1"
);
$sth->execute([
':company_id' => $this->company_id,
':return_id' => $return_id,
':sku' => $product_sku,
]);
$stock_row = $sth->fetch(PDO::FETCH_ASSOC);
if (!$stock_row) continue;
// Soft-delete the stock-in row
$this->pdo->prepare(
"UPDATE `{$safe}` SET status = -1
WHERE id = :id AND company_id = :company_id"
)->execute([
':id' => $stock_row['id'],
':company_id' => $this->company_id,
]);
// Release rack occupation
$whMgmt->releaseRack(
$warehouse_id,
$stock_row['zone'],
$stock_row['aisle'],
$stock_row['rack'],
$product_sku,
$stock_row['id']
);
// Reverse balance
$whMgmt->adjustBalance('out', $warehouse_id, $product_sku, 0, $quantity);
}
}
// ── Cancel the return ─────────────────────────────────────────────
$log = json_decode($return['log'] ?? '[]', true) ?: [];
$log[] = array_merge($logging, ['action' => 'cancel']);
$this->pdo->prepare(
"UPDATE td_return SET
status = -1,
`log` = :log
WHERE id = :id AND company_id = :company_id"
)->execute([
':log' => json_encode($log),
':id' => $return_id,
':company_id' => $this->company_id,
]);
}
}
+87 -7
View File
@@ -78,7 +78,12 @@ class WarehouseManager {
$from = trim($from);
$to = trim($to);
// Numeric range
// Same value — single location, any format allowed (e.g. "Shelf-A", "Bin-3")
if ($from === $to) {
return [$from];
}
// Numeric range e.g. 1 → 10
if (is_numeric($from) && is_numeric($to)) {
$f = (int) $from;
$t = (int) $to;
@@ -86,15 +91,62 @@ class WarehouseManager {
return array_map('strval', range($f, $t));
}
// Single-letter alpha range
// Single-letter alpha range e.g. A → Z
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
if (ord(strtoupper($from)) > ord(strtoupper($to))) {
throw new Exception("Range start ($from) must be <= end ($to)");
}
return range(strtoupper($from), strtoupper($to));
}
// Prefix + suffix range e.g. Shelf-A → Shelf-E or Bin-1 → Bin-5
// Split on the last sequence of digits or single letter at the end
$pattern = '/^(.*?)([A-Za-z]|[0-9]+)$/';
if (preg_match($pattern, $from, $mf) && preg_match($pattern, $to, $mt)) {
$prefix_f = $mf[1];
$prefix_t = $mt[1];
$suffix_f = $mf[2];
$suffix_t = $mt[2];
// Prefixes must match
if ($prefix_f !== $prefix_t) {
throw new Exception(
"Location prefix mismatch: '$prefix_f' vs '$prefix_t'. " .
"From and To must share the same prefix (e.g. Shelf-A → Shelf-E)."
);
}
$prefix = $prefix_f;
// Suffix: numeric range
if (is_numeric($suffix_f) && is_numeric($suffix_t)) {
$f = (int) $suffix_f;
$t = (int) $suffix_t;
if ($f > $t) throw new Exception("Range start ($from) must be <= end ($to)");
return array_map(fn($n) => $prefix . $n, range($f, $t));
}
// Suffix: single-letter range
if (ctype_alpha($suffix_f) && ctype_alpha($suffix_t)
&& strlen($suffix_f) === 1 && strlen($suffix_t) === 1) {
$sf = strtoupper($suffix_f);
$st = strtoupper($suffix_t);
if (ord($sf) > ord($st)) {
throw new Exception("Range start ($from) must be <= end ($to)");
}
return array_map(fn($c) => $prefix . $c, range($sf, $st));
}
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'"
"Location suffix must be a number (e.g. 1–99) or single letter (e.g. A–Z). " .
"Got: '$suffix_f' → '$suffix_t'"
);
}
throw new Exception(
"Invalid location range: '$from' → '$to'. " .
"Use formats like: 1–10, A–Z, Shelf-1–Shelf-10, or Shelf-A–Shelf-Z."
);
}
@@ -848,6 +900,16 @@ class WarehouseManager {
*/
public function getRacksAll(int $warehouse_id, string $zone, string $aisle): array
{
if ($zone === '' || $aisle === '') {
$sth = $this->pdo->prepare(
"SELECT DISTINCT rack FROM md_rack
WHERE company_id = :company_id AND warehouse = :warehouse
ORDER BY CAST(rack AS UNSIGNED), rack"
);
$sth->execute([':company_id' => $this->company_id, ':warehouse' => $warehouse_id]);
return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'rack');
}
$sth = $this->pdo->prepare(
"SELECT DISTINCT rack FROM md_rack
WHERE company_id = :company_id AND warehouse = :warehouse AND zone = :zone AND aisle = :aisle
@@ -1703,6 +1765,21 @@ class WarehouseManager {
*/
public function getRacksIn(int $warehouse_id, string $zone, string $aisle): array
{
if ($zone === '' || $aisle === '') {
$sth = $this->pdo->prepare(
"SELECT DISTINCT rack FROM md_rack
WHERE company_id = :company_id
AND warehouse = :warehouse
AND product_sku IS NULL
ORDER BY CAST(rack AS UNSIGNED), rack"
);
$sth->execute([
':company_id' => $this->company_id,
':warehouse' => $warehouse_id,
]);
return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'rack');
}
$sth = $this->pdo->prepare(
"SELECT DISTINCT rack FROM md_rack
WHERE company_id = :company_id
@@ -1746,17 +1823,20 @@ class WarehouseManager {
[$lot_cond, $serial_cond, $params] = $this->buildLotSerialCondition(
$warehouse_id, $product_sku, $lot_number, $serial_number
);
$location_cond = '';
if ($zone !== '' && $aisle !== '') {
$location_cond = "AND r.zone = :zone AND r.aisle = :aisle";
$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
{$location_cond}
AND r.td_stock_id IN (
SELECT s.id FROM `{$table}` s
WHERE s.company_id = :company_id2
+112 -58
View File
@@ -68,22 +68,22 @@
<select name="warehouse" id="warehouse" class="form-select" required></select>
</div>
<div class="mb-3 col-lg-6">
<label for="zone" class="form-label">Zone</label>
<select name="zone" id="zone" class="form-select" required>
<div class="mb-3 col-lg-6" id="group_zone">
<label for="zone" class="form-label" id="label_zone">Zone</label>
<select name="zone" id="zone" class="form-select">
<option value="">Please select zone</option>
</select>
</div>
<div class="mb-3 col-lg-6">
<label for="aisle" class="form-label">Aisle</label>
<select name="aisle" id="aisle" class="form-select" required>
<div class="mb-3 col-lg-6" id="group_aisle">
<label for="aisle" class="form-label" id="label_aisle">Aisle</label>
<select name="aisle" id="aisle" class="form-select">
<option value="">Please select aisle</option>
</select>
</div>
<div class="mb-3 col-lg-6">
<label for="rack" class="form-label">Rack</label>
<div class="mb-3 col-lg-6" id="group_rack">
<label for="rack" class="form-label" id="label_rack">Rack</label>
<select name="rack" id="rack" class="form-select" required>
<option value="">Please select rack</option>
</select>
@@ -118,24 +118,72 @@
<?php require "../include_ending.php";?>
<script>
var advanced = false;
var label_rack = 'Location';
var label_zone = 'Zone';
var label_aisle = 'Aisle';
function load_location_config(callback) {
return ajax_request({
url: server_url + 'setting/api/engine/company_setting.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
onSuccess: function(res) {
var s = res.output;
advanced = s.advanced_location == 1;
label_rack = s.location_label_rack || (advanced ? 'Rack' : 'Location');
label_zone = s.location_label_zone || 'Zone';
label_aisle = s.location_label_aisle || 'Aisle';
if (callback) callback();
}
});
}
function apply_location_labels() {
if (advanced) {
$('#group_zone').show();
$('#group_aisle').show();
$('#label_zone').text(label_zone);
$('#label_aisle').text(label_aisle);
$('#label_rack').text(label_rack);
$('#zone').attr('placeholder', 'Please select ' + label_zone.toLowerCase());
$('#aisle').attr('placeholder', 'Please select ' + label_aisle.toLowerCase());
$('#rack').attr('placeholder', 'Please select ' + label_rack.toLowerCase());
} else {
$('#group_zone').hide();
$('#group_aisle').hide();
$('#label_rack').text(label_rack);
$('#rack').attr('placeholder', 'Please select ' + label_rack.toLowerCase());
}
}
function manage_stock_in() {
// In simple mode, mirror rack value to zone and aisle
if (!advanced) {
var rack_val = $('#rack').val();
$('#zone').val(rack_val);
$('#aisle').val(rack_val);
}
return ajax_request({
url: "<?php echo $server_url?>ics/api/engine/manage_stock_in.php",
url: '<?php echo $server_url?>ics/api/engine/manage_stock_in.php',
autoPrepare: true,
checkRequired: 1,
debugMode: 0,
data: {
"contact_id": $("#contact").attr('data-id'),
"lot_number": $("#lot_number").val(),
"expiry_date": $("#expiry_date").val(),
"serial_number": $("#serial_number").val()
'contact_id': $('#contact').attr('data-id'),
'lot_number': $('#lot_number').val(),
'expiry_date': $('#expiry_date').val(),
'serial_number': $('#serial_number').val(),
'zone': $('#zone').val(),
'aisle': $('#aisle').val(),
},
action: 'manage',
onSuccess: function(res) {
window.location.href = "<?php echo $server_url?>ics/stock_in.php";
onSuccess: function() {
window.location.href = '<?php echo $server_url?>ics/stock_in.php';
}
});
@@ -179,11 +227,11 @@
debugMode: 0,
action: 'read',
onSuccess: function(res) {
var option = `<option value="">Please select zone</option>`;
var option = '<option value="">Please select ' + label_zone.toLowerCase() + '</option>';
$.each(res.output, function(key, item) {
option += `<option value='${item.zone}'>${item.zone}</option>`;
})
$(`select#zone`).html(option);
option += '<option value="' + item.zone + '">' + item.zone + '</option>';
});
$('select#zone').html(option);
if (callback) callback(); // ← add this
}
@@ -205,11 +253,11 @@
},
action: 'read',
onSuccess: function(res) {
var option = `<option value="">Please select aisle</option>`;
var option = '<option value="">Please select ' + label_aisle.toLowerCase() + '</option>';
$.each(res.output, function(key, item) {
option += `<option value='${item}'>${item}</option>`;
})
$(`select#aisle`).html(option);
option += '<option value="' + item + '">' + item + '</option>';
});
$('select#aisle').html(option);
if (callback) callback(); // ← add this
}
@@ -231,11 +279,11 @@
},
action: 'read',
onSuccess: function(res) {
var option = `<option value="">Please select rack</option>`;
var option = '<option value="">Please select ' + label_rack.toLowerCase() + '</option>';
$.each(res.output, function(key, item) {
option += `<option value='${item}'>${item}</option>`;
})
$(`select#rack`).html(option);
option += '<option value="' + item + '">' + item + '</option>';
});
$('select#rack').html(option);
if (callback) callback(); // ← add this
}
@@ -261,55 +309,53 @@
var data = res.output;
// 1. Set warehouse (already populated from page load)
$('#warehouse').val('<?php echo $_GET["wh"];?>').attr('disabled', true);
$(`#warehouse`).val('<?php echo $_GET["wh"];?>').attr('disabled', true); // set warehouse BEFORE retrieve_zone
// 2. Retrieve zone (autoPrepare reads warehouse value)
retrieve_zone('to', function() {
$(`select#zone`).val(data.zone).attr('disabled', true); // set zone BEFORE retrieve_aisle
// 3. Retrieve aisle (autoPrepare reads zone value)
retrieve_aisle('to', function() {
$(`#aisle`).val(data.aisle).attr('disabled', true); // set aisle BEFORE retrieve_rack
// 4. Retrieve rack (autoPrepare reads aisle value)
retrieve_rack('to', function() {
$(`#rack`).val(data.rack).attr('disabled', true);
// retrieve for edit
function populate_fields() {
$.each(data, function(key, item) {
if (!['warehouse', 'zone', 'aisle', 'rack'].includes(key)) {
$(`#${key}`).val(item);
$('#' + key).val(item);
}
});
$('#contact').val(data.contact_name)
.attr('secondary', data.contact_name)
.attr('data-id', data.contact_id);
$('#product_name').val(data.product_name);
$('#lot_number').val(data.lot_number || '').prop('disabled', true);
$('#serial_number').val(data.serial_number || '').prop('disabled', true);
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')
$('#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);
$('button[type=submit]').text('Update');
$('button[type=reset]').hide();
}
$(`button[type=submit]`).text('Update');
$(`button[type=reset]`).hide();
if (advanced) {
retrieve_zone('to', function() {
$('select#zone').val(data.zone).attr('disabled', true);
retrieve_aisle('to', function() {
$('#aisle').val(data.aisle).attr('disabled', true);
retrieve_rack('to', function() {
$('#rack').val(data.rack).attr('disabled', true);
populate_fields();
});
});
});
// Set re
} else {
// Simple mode — skip zone/aisle cascade
retrieve_rack('to', function() {
$('#rack').val(data.rack).attr('disabled', true);
populate_fields();
});
}
@@ -351,13 +397,16 @@
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.
// Chain-load location selects — skip zone/aisle in simple mode
if (advanced) {
retrieve_zone('to', function() {
retrieve_aisle('to', function() {
retrieve_rack('to');
});
});
} else {
retrieve_rack('to');
}
}
return true;
@@ -366,6 +415,7 @@
$(async function() {
try {
await load_location_config(apply_location_labels);
await retrieve_warehouse('to');
await retrieve_for_edit();
@@ -395,15 +445,19 @@
<?php if(empty($_GET["id"])){ ?>
$("select#warehouse").change(function() {
if (advanced) {
retrieve_zone('to');
} else {
retrieve_rack('to');
}
});
$("select#zone").change(function() {
retrieve_aisle('to');
if (advanced) retrieve_aisle('to');
});
$("select#aisle").change(function() {
retrieve_rack('to');
if (advanced) retrieve_rack('to');
});
<?php } ?>
+97 -43
View File
@@ -61,24 +61,24 @@
</div>
<!-- Step 5: Zone -->
<div class="mb-3 col-lg-6">
<label for="zone" class="form-label">Zone</label>
<select name="zone" id="zone" class="form-select" required disabled>
<div class="mb-3 col-lg-6" id="group_zone">
<label for="zone" class="form-label" id="label_zone">Zone</label>
<select name="zone" id="zone" class="form-select" disabled>
<option value="">Please select zone</option>
</select>
</div>
<!-- Step 6: Aisle -->
<div class="mb-3 col-lg-6">
<label for="aisle" class="form-label">Aisle</label>
<select name="aisle" id="aisle" class="form-select" required disabled>
<div class="mb-3 col-lg-6" id="group_aisle">
<label for="aisle" class="form-label" id="label_aisle">Aisle</label>
<select name="aisle" id="aisle" class="form-select" disabled>
<option value="">Please select aisle</option>
</select>
</div>
<!-- Step 7: Rack -->
<div class="mb-3 col-lg-6">
<label for="rack" class="form-label">Rack</label>
<div class="mb-3 col-lg-6" id="group_rack">
<label for="rack" class="form-label" id="label_rack">Rack</label>
<select name="rack" id="rack" class="form-select" required disabled>
<option value="">Please select rack</option>
</select>
@@ -111,19 +111,63 @@
<?php require "../include_ending.php";?>
<script>
var advanced = false;
var label_rack = 'Location';
var label_zone = 'Zone';
var label_aisle = 'Aisle';
function load_location_config(callback) {
return ajax_request({
url: server_url + 'setting/api/engine/company_setting.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
onSuccess: function(res) {
var s = res.output;
advanced = s.advanced_location == 1;
label_rack = s.location_label_rack || (advanced ? 'Rack' : 'Location');
label_zone = s.location_label_zone || 'Zone';
label_aisle = s.location_label_aisle || 'Aisle';
if (callback) callback();
}
});
}
function apply_location_labels() {
if (advanced) {
$('#group_zone').show();
$('#group_aisle').show();
$('#label_zone').text(label_zone);
$('#label_aisle').text(label_aisle);
$('#label_rack').text(label_rack);
} else {
$('#group_zone').hide();
$('#group_aisle').hide();
$('#label_rack').text(label_rack);
}
}
function manage_stock_out() {
if (!advanced) {
var rack_val = $('#rack').val();
$('#zone').val(rack_val);
$('#aisle').val(rack_val);
}
return ajax_request({
url: "<?php echo $server_url?>ics/api/engine/manage_stock_out.php",
url: '<?php echo $server_url?>ics/api/engine/manage_stock_out.php',
autoPrepare: true,
checkRequired: 1,
debugMode: 0,
data: {
"contact_id": $("#contact").attr('data-id')
'contact_id': $('#contact').attr('data-id'),
'zone': $('#zone').val(),
'aisle': $('#aisle').val(),
},
action: 'manage',
onSuccess: function(res) {
window.location.href = "<?php echo $server_url?>ics/stock_out.php";
onSuccess: function() {
window.location.href = '<?php echo $server_url?>ics/stock_out.php';
}
});
}
@@ -227,7 +271,7 @@
},
action: 'read',
onSuccess: function(res) {
var option = '<option value="">Please select zone</option>';
var option = '<option value="">Please select ' + label_zone.toLowerCase() + '</option>';
$.each(res.output, function(key, item) {
option += `<option value='${item.zone}'>${item.zone}</option>`;
});
@@ -252,7 +296,7 @@
},
action: 'read',
onSuccess: function(res) {
var option = '<option value="">Please select aisle</option>';
var option = '<option value="">Please select ' + label_aisle.toLowerCase() + '</option>';
$.each(res.output, function(key, item) {
option += `<option value='${item}'>${item}</option>`;
});
@@ -277,7 +321,7 @@
},
action: 'read',
onSuccess: function(res) {
var option = '<option value="">Please select rack</option>';
var option = '<option value="">Please select ' + label_rack.toLowerCase() + '</option>';
$.each(res.output, function(key, item) {
option += `<option value='${item}'>${item}</option>`;
});
@@ -291,9 +335,9 @@
// ── Reset location dropdowns ──────────────────────────────────────────
function reset_location() {
$('#warehouse').html('<option value="">Select warehouse...</option>').prop('disabled', true);
$('#zone').html('<option value="">Please select zone</option>').prop('disabled', true);
$('#aisle').html('<option value="">Please select aisle</option>').prop('disabled', true);
$('#rack').html('<option value="">Please select rack</option>').prop('disabled', true);
$('#zone').html('<option value="">Please select ' + label_zone.toLowerCase() + '</option>').prop('disabled', true);
$('#aisle').html('<option value="">Please select ' + label_aisle.toLowerCase() + '</option>').prop('disabled', true);
$('#rack').html('<option value="">Please select ' + label_rack.toLowerCase() + '</option>').prop('disabled', true);
}
@@ -322,34 +366,39 @@
$('#warehouse').val('<?php echo $_GET["wh"];?>').prop('disabled', true);
retrieve_zone(function() {
$('#zone').val(data.zone).prop('disabled', true);
retrieve_aisle(function() {
$('#aisle').val(data.aisle).prop('disabled', true);
retrieve_rack(function() {
$('#rack').val(data.rack).prop('disabled', true);
function populate_fields() {
$.each(data, function(key, item) {
if (!['warehouse', 'zone', 'aisle', 'rack', 'lot_number', 'serial_number'].includes(key)) {
$(`#${key}`).val(item);
$('#' + key).val(item);
}
});
$('#contact').val(data.contact_name)
.attr('secondary', data.contact_name)
.attr('data-id', data.contact_id);
$('#product_name').val(data.product_name);
$('#product_sku').attr('secondary', data.product_sku);
$('#product_sku').prop('disabled', true);
$('#product_sku').attr('secondary', data.product_sku).prop('disabled', true);
$('button[type=submit]').text('Update');
$('button[type=reset]').hide();
}
if (advanced) {
retrieve_zone(function() {
$('#zone').val(data.zone).prop('disabled', true);
retrieve_aisle(function() {
$('#aisle').val(data.aisle).prop('disabled', true);
retrieve_rack(function() {
$('#rack').val(data.rack).prop('disabled', true);
populate_fields();
});
});
});
} else {
// Simple mode — skip zone/aisle cascade
retrieve_rack(function() {
$('#rack').val(data.rack).prop('disabled', true);
populate_fields();
});
}
}
});
}
@@ -358,6 +407,7 @@
// ── Boot ─────────────────────────────────────────────────────────────
$(async function() {
try {
await load_location_config(apply_location_labels);
await retrieve_warehouse();
await retrieve_for_edit();
} catch(e) {
@@ -388,19 +438,23 @@
retrieve_warehouse();
});
// Warehouse selected → load zone
// Warehouse selected → load zone (advanced) or rack directly (simple)
$('#warehouse').on('change', function() {
if (advanced) {
retrieve_zone();
});
// Zone selected → load aisle
$('#zone').on('change', function() {
retrieve_aisle();
});
// Aisle selected → load rack
$('#aisle').on('change', function() {
} else {
retrieve_rack();
}
});
// Zone selected → load aisle (advanced only)
$('#zone').on('change', function() {
if (advanced) retrieve_aisle();
});
// Aisle selected → load rack (advanced only)
$('#aisle').on('change', function() {
if (advanced) retrieve_rack();
});
<?php } ?>
+177 -126
View File
@@ -65,20 +65,20 @@
<option value="">Select warehouse...</option>
</select>
</div>
<div class="mb-3 col-lg-3">
<label class="form-label">From Zone</label>
<div class="mb-3 col-lg-3" id="group_zone_from">
<label class="form-label" id="label_zone_from">From Zone</label>
<select name="zone" id="zone_from" role="from" class="form-select" required disabled>
<option value="">Please select zone</option>
</select>
</div>
<div class="mb-3 col-lg-3">
<label class="form-label">From Aisle</label>
<div class="mb-3 col-lg-3" id="group_aisle_from">
<label class="form-label" id="label_aisle_from">From Aisle</label>
<select name="aisle" id="aisle_from" role="from" class="form-select" required disabled>
<option value="">Please select aisle</option>
</select>
</div>
<div class="mb-3 col-lg-3">
<label class="form-label">From Rack</label>
<label class="form-label" id="label_rack_from">From Rack</label>
<select name="rack" id="rack_from" role="from" class="form-select" required disabled>
<option value="">Please select rack</option>
</select>
@@ -97,20 +97,20 @@
<option value="">Select warehouse...</option>
</select>
</div>
<div class="mb-3 col-lg-3">
<label class="form-label">To Zone</label>
<div class="mb-3 col-lg-3" id="group_zone_to">
<label class="form-label" id="label_zone_to">To Zone</label>
<select name="zone" id="zone_to" role="to" class="form-select" required>
<option value="">Please select zone</option>
</select>
</div>
<div class="mb-3 col-lg-3">
<label class="form-label">To Aisle</label>
<div class="mb-3 col-lg-3" id="group_aisle_to">
<label class="form-label" id="label_aisle_to">To Aisle</label>
<select name="aisle" id="aisle_to" role="to" class="form-select" required>
<option value="">Please select aisle</option>
</select>
</div>
<div class="mb-3 col-lg-3">
<label class="form-label">To Rack</label>
<label class="form-label" id="label_rack_to">To Rack</label>
<select name="rack" id="rack_to" role="to" class="form-select" required>
<option value="">Please select rack</option>
</select>
@@ -143,43 +143,97 @@
<?php require "../include_ending.php";?>
<script>
// ── Location config ───────────────────────────────────────────────────────
var advanced = false;
var label_rack = 'Location';
var label_zone = 'Zone';
var label_aisle = 'Aisle';
function load_location_config(callback) {
return ajax_request({
url: '<?php echo $server_url?>setting/api/engine/company_setting.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
onSuccess: function(res) {
var s = res.output;
advanced = s.advanced_location == 1;
label_rack = s.location_label_rack || (advanced ? 'Rack' : 'Location');
label_zone = s.location_label_zone || 'Zone';
label_aisle = s.location_label_aisle || 'Aisle';
if (callback) callback();
}
});
}
function apply_location_labels() {
if (advanced) {
$('#group_zone_from, #group_zone_to').show();
$('#group_aisle_from, #group_aisle_to').show();
$('#label_zone_from').text('From ' + label_zone);
$('#label_zone_to').text('To ' + label_zone);
$('#label_aisle_from').text('From ' + label_aisle);
$('#label_aisle_to').text('To ' + label_aisle);
$('#label_rack_from').text('From ' + label_rack);
$('#label_rack_to').text('To ' + label_rack);
// Update select placeholders
$('#zone_from, #zone_to').find('option:first').text('Please select ' + label_zone.toLowerCase());
$('#aisle_from, #aisle_to').find('option:first').text('Please select ' + label_aisle.toLowerCase());
$('#rack_from, #rack_to').find('option:first').text('Please select ' + label_rack.toLowerCase());
} else {
$('#group_zone_from, #group_zone_to').hide();
$('#group_aisle_from, #group_aisle_to').hide();
$('#label_rack_from').text('From ' + label_rack);
$('#label_rack_to').text('To ' + label_rack);
$('#rack_from, #rack_to').find('option:first').text('Please select ' + label_rack.toLowerCase());
}
}
// ── Submit ────────────────────────────────────────────────────────────────
function manage_stock_transfer() {
// Simple mode — mirror rack value to zone and aisle before submit
if (!advanced) {
var rack_from_val = $('#rack_from').val();
var rack_to_val = $('#rack_to').val();
$('#zone_from').val(rack_from_val);
$('#aisle_from').val(rack_from_val);
$('#zone_to').val(rack_to_val);
$('#aisle_to').val(rack_to_val);
}
return ajax_request({
url: "<?php echo $server_url?>ics/api/engine/manage_stock_transfer.php",
url: '<?php echo $server_url?>ics/api/engine/manage_stock_transfer.php',
autoPrepare: true,
checkRequired: 1,
debugMode: 0,
data: {
"contact_id": $("#contact").attr('data-id')
},
data: { 'contact_id': $('#contact').attr('data-id') },
action: 'manage',
onSuccess: function(res) {
window.location.href = "<?php echo $server_url?>ics/stock_transfer.php";
onSuccess: function() {
window.location.href = '<?php echo $server_url?>ics/stock_transfer.php';
}
});
}
// ── Step 1: Product selected → load active lots + to-warehouses ───────
// ── Step 1: Product selected → load active lots + from-warehouses ─────────
function retrieve_active_lot() {
var sku = $('#product_sku').attr('secondary') || $('#product_sku').val();
if (!sku) return;
return ajax_request({
url: "<?php echo $server_url?>ics/api/engine/retrieve_active_lot.php",
url: '<?php echo $server_url?>ics/api/engine/retrieve_active_lot.php',
autoPrepare: true,
checkRequired: 0,
noLoading: true,
action: 'read',
data: { "product_sku": sku },
data: { 'product_sku': sku },
onSuccess: function(res) {
var option = '<option value="">Select lot... (optional)</option>';
$.each(res.output, function(i, item) {
var label = item.lot_number;
if (item.expiry_date) label += ' — exp: ' + item.expiry_date;
option += `<option value="${item.lot_number}">${label}</option>`;
option += '<option value="' + item.lot_number + '">' + label + '</option>';
});
$('#lot_number').html(option).prop('disabled', false);
$('#serial_number').html('<option value="">Select serial... (optional)</option>').prop('disabled', false);
@@ -189,26 +243,21 @@
}
// ── Step 2: Lot selected → load active serials ────────────────────────
// ── Step 2: Lot selected → load active serials ────────────────────────────
function retrieve_active_serial() {
var sku = $('#product_sku').attr('secondary') || $('#product_sku').val();
if (!sku) return;
return ajax_request({
url: "<?php echo $server_url?>ics/api/engine/retrieve_active_serial.php",
url: '<?php echo $server_url?>ics/api/engine/retrieve_active_serial.php',
autoPrepare: true,
checkRequired: 0,
noLoading: true,
action: 'read',
data: {
"product_sku": sku,
"lot_number": $('#lot_number').val()
},
data: { 'product_sku': sku, 'lot_number': $('#lot_number').val() },
onSuccess: function(res) {
var option = '<option value="">Select serial... (optional)</option>';
$.each(res.output, function(i, serial) {
option += `<option value="${serial}">${serial}</option>`;
option += '<option value="' + serial + '">' + serial + '</option>';
});
$('#serial_number').html(option).prop('disabled', false);
reset_from_location();
@@ -217,199 +266,205 @@
}
// ── Warehouse loaders ─────────────────────────────────────────────────
// ── Warehouse loaders ─────────────────────────────────────────────────────
function retrieve_warehouse(type) {
return ajax_request({
url: "<?php echo $server_url?>ics/api/engine/retrieve_warehouse.php",
url: '<?php echo $server_url?>ics/api/engine/retrieve_warehouse.php',
autoPrepare: true,
checkRequired: 0,
noLoading: true,
data: { "type": type, "lot_number": type === 'from' ? $('#lot_number').val() : '' },
data: { 'type': type, 'lot_number': type === 'from' ? $('#lot_number').val() : '' },
action: 'read',
debugMode: 0,
onSuccess: function(res) {
var option = '<option value="">Select warehouse...</option>';
$.each(res.output, function(key, item) {
option += `<option value='${item.id}'>${item.warehouse_name}</option>`;
option += '<option value="' + item.id + '">' + item.warehouse_name + '</option>';
});
$(`select[name='warehouse'][role='${type}']`).html(option).prop('disabled', false);
$('select[name="warehouse"][role="' + type + '"]').html(option).prop('disabled', false);
}
});
}
// ── Zone / Aisle / Rack — From (type=from, filtered by sku+lot+serial) ─
// ── Zone / Aisle / Rack loaders ───────────────────────────────────────────
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()
'product_sku': $('#product_sku').attr('secondary') || $('#product_sku').val(),
'lot_number': $('#lot_number').val(),
'serial_number': $('#serial_number').val()
};
}
return ajax_request({
url: "<?php echo $server_url?>ics/api/engine/retrieve_zone.php",
url: '<?php echo $server_url?>ics/api/engine/retrieve_zone.php',
autoPrepare: true,
checkRequired: 0,
noLoading: true,
data: Object.assign({ "type": type }, extra),
data: Object.assign({ 'type': type }, extra),
action: 'read',
onSuccess: function(res) {
var option = '<option value="">Please select zone</option>';
var option = '<option value="">Please select ' + label_zone.toLowerCase() + '</option>';
$.each(res.output, function(key, item) {
option += `<option value='${item.zone}'>${item.zone}</option>`;
option += '<option value="' + item.zone + '">' + item.zone + '</option>';
});
$(`select[name='zone'][role='${type}']`).html(option).prop('disabled', false);
$('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()
'product_sku': $('#product_sku').attr('secondary') || $('#product_sku').val(),
'lot_number': $('#lot_number').val(),
'serial_number': $('#serial_number').val()
};
}
return ajax_request({
url: "<?php echo $server_url?>ics/api/engine/retrieve_aisle.php",
url: '<?php echo $server_url?>ics/api/engine/retrieve_aisle.php',
autoPrepare: true,
checkRequired: 0,
noLoading: true,
data: Object.assign({ "type": type }, extra),
data: Object.assign({ 'type': type }, extra),
action: 'read',
onSuccess: function(res) {
var option = '<option value="">Please select aisle</option>';
var option = '<option value="">Please select ' + label_aisle.toLowerCase() + '</option>';
$.each(res.output, function(key, item) {
option += `<option value='${item}'>${item}</option>`;
option += '<option value="' + item + '">' + item + '</option>';
});
$(`select[name='aisle'][role='${type}']`).html(option).prop('disabled', false);
$('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()
'product_sku': $('#product_sku').attr('secondary') || $('#product_sku').val(),
'lot_number': $('#lot_number').val(),
'serial_number': $('#serial_number').val()
};
}
return ajax_request({
url: "<?php echo $server_url?>ics/api/engine/retrieve_rack.php",
url: '<?php echo $server_url?>ics/api/engine/retrieve_rack.php',
autoPrepare: true,
checkRequired: 0,
noLoading: true,
data: Object.assign({ "type": type }, extra),
data: Object.assign({ 'type': type }, extra),
action: 'read',
onSuccess: function(res) {
var option = '<option value="">Please select rack</option>';
var option = '<option value="">Please select ' + label_rack.toLowerCase() + '</option>';
$.each(res.output, function(key, item) {
option += `<option value='${item}'>${item}</option>`;
option += '<option value="' + item + '">' + item + '</option>';
});
$(`select[name='rack'][role='${type}']`).html(option).prop('disabled', false);
$('select[name="rack"][role="' + type + '"]').html(option).prop('disabled', false);
if (callback) callback();
}
});
}
// ── Reset from-location dropdowns ─────────────────────────────────────
// ── Reset from-location dropdowns ─────────────────────────────────────────
function reset_from_location() {
$('#warehouse_from').html('<option value="">Select warehouse...</option>').prop('disabled', true);
$('#zone_from').html('<option value="">Please select zone</option>').prop('disabled', true);
$('#aisle_from').html('<option value="">Please select aisle</option>').prop('disabled', true);
$('#rack_from').html('<option value="">Please select rack</option>').prop('disabled', true);
$('#zone_from').html('<option value="">Please select ' + label_zone.toLowerCase() + '</option>').prop('disabled', true);
$('#aisle_from').html('<option value="">Please select ' + label_aisle.toLowerCase() + '</option>').prop('disabled', true);
$('#rack_from').html('<option value="">Please select ' + label_rack.toLowerCase() + '</option>').prop('disabled', true);
}
// ── Edit mode ─────────────────────────────────────────────────────────
// ── Edit mode ─────────────────────────────────────────────────────────────
function retrieve_for_edit() {
<?php if(empty($_GET["id"])){ ?>
<?php if(empty($_GET['id'])){ ?>
return false;
<?php } ?>
return ajax_request({
url: "<?php echo $server_url?>ics/api/engine/retrieve_stock_transfer.php",
url: '<?php echo $server_url?>ics/api/engine/retrieve_stock_transfer.php',
autoPrepare: true,
checkRequired: 0,
debugMode: 0,
action: 'read',
onSuccess: function(res) {
var data = res.output;
var from_wh = data.ref.ref_warehouse;
var to_wh = data.ref_warehouse;
// Lock lot/serial
$('#lot_number').html(`<option value="${data.lot_number || ''}">${data.lot_number || '—'}</option>`)
$('#lot_number').html('<option value="' + (data.lot_number || '') + '">' + (data.lot_number || '—') + '</option>')
.val(data.lot_number || '').prop('disabled', true);
$('#serial_number').html(`<option value="${data.serial_number || ''}">${data.serial_number || '—'}</option>`)
$('#serial_number').html('<option value="' + (data.serial_number || '') + '">' + (data.serial_number || '—') + '</option>')
.val(data.serial_number || '').prop('disabled', true);
// From side
$("select[name='warehouse'][role='from']").val(from_wh).prop('disabled', true);
if (advanced) {
// Full cascade for From
$('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);
$('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);
$('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);
// 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);
});
});
$('select[name="rack"][role="from"]').val(data.rack).prop('disabled', true);
populate_to_side(data, to_wh);
});
});
});
} else {
// Simple mode — skip zone/aisle cascade, load rack directly
$('select[name="warehouse"][role="from"]').val(from_wh).prop('disabled', true);
retrieve_rack('from', function() {
$('select[name="rack"][role="from"]').val(data.rack).prop('disabled', true);
populate_to_side_simple(data, to_wh);
});
}
// Common fields
$.each(data, function(key, item) {
if (!['warehouse', 'zone', 'aisle', 'rack', 'lot_number', 'serial_number', 'ref'].includes(key)) {
$(`#${key}`).val(item);
$('#' + key).val(item);
}
});
$('#contact').val(data.contact_name)
.attr('secondary', data.contact_name)
.attr('data-id', data.contact_id);
$('#product_name').val(data.product_name);
$('#product_sku').attr('secondary', data.product_sku).prop('disabled', true);
$('button[type=submit]').text('Update');
$('button[type=reset]').hide();
}
});
}
function populate_to_side(data, to_wh) {
$('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);
});
});
});
}
// ── Boot ─────────────────────────────────────────────────────────────
function populate_to_side_simple(data, to_wh) {
$('select[name="warehouse"][role="to"]').val(to_wh).prop('disabled', true);
retrieve_rack('to', function() {
$('select[name="rack"][role="to"]').val(data.ref.rack).prop('disabled', true);
});
}
// ── Boot ─────────────────────────────────────────────────────────────────
$(async function() {
try {
await load_location_config(apply_location_labels);
await retrieve_warehouse('to');
await retrieve_for_edit();
} catch(e) {
@@ -418,10 +473,9 @@
});
// ── Change handlers (create mode only) ───────────────────────────────
<?php if(empty($_GET["id"])){ ?>
// ── Change handlers (create mode only) ───────────────────────────────────
<?php if(empty($_GET['id'])){ ?>
// SKU selected → load lots + from-warehouse
$('input#product_sku').on('blur', function() {
var sku = $(this).attr('secondary');
if (!sku) return;
@@ -429,50 +483,47 @@
retrieve_warehouse('from');
});
// Lot selected → reload serials + reset from-location
$('#lot_number').on('change', function() {
retrieve_active_serial();
retrieve_warehouse('from');
});
// 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 warehouse → zone (advanced) or rack directly (simple)
$('select[name="warehouse"][role="from"]').on('change', function() {
if (advanced) { retrieve_zone('from'); } else { retrieve_rack('from'); }
});
// From zone → from aisle
$("select[name='zone'][role='from']").on('change', function() {
retrieve_aisle('from');
// From zone → aisle (advanced only)
$('select[name="zone"][role="from"]').on('change', function() {
if (advanced) retrieve_aisle('from');
});
// From aisle → from rack
$("select[name='aisle'][role='from']").on('change', function() {
retrieve_rack('from');
// From aisle → rack (advanced only)
$('select[name="aisle"][role="from"]').on('change', function() {
if (advanced) retrieve_rack('from');
});
// To warehouse → to zone
$("select[name='warehouse'][role='to']").on('change', function() {
retrieve_zone('to');
// To warehouse → zone (advanced) or rack directly (simple)
$('select[name="warehouse"][role="to"]').on('change', function() {
if (advanced) { retrieve_zone('to'); } else { retrieve_rack('to'); }
});
// To zone → to aisle
$("select[name='zone'][role='to']").on('change', function() {
retrieve_aisle('to');
// To zone → aisle (advanced only)
$('select[name="zone"][role="to"]').on('change', function() {
if (advanced) retrieve_aisle('to');
});
// To aisle → to rack
$("select[name='aisle'][role='to']").on('change', function() {
retrieve_rack('to');
// To aisle → rack (advanced only)
$('select[name="aisle"][role="to"]').on('change', function() {
if (advanced) retrieve_rack('to');
});
<?php } ?>
</script>
</body>
</html>
+36 -7
View File
@@ -51,9 +51,9 @@
<th>Lot Number</th>
<th>Serial Number</th>
<th>Quantity</th>
<th>Zone</th>
<th>Aisle</th>
<th>Rack</th>
<th class="th_loc_zone">Zone</th>
<th class="th_loc_aisle">Aisle</th>
<th class="th_loc_rack">Rack</th>
<th>Status</th>
<th>Action</th>
</tr>
@@ -78,6 +78,31 @@
<?php require "../include_ending.php";?>
<script>
var advanced = false;
var label_rack = 'Location';
var label_zone = 'Zone';
var label_aisle = 'Aisle';
function apply_location_labels() {
if (advanced) {
$('.th_loc_zone').text(label_zone).show();
$('.th_loc_aisle').text(label_aisle).show();
$('.th_loc_rack').text(label_rack);
} else {
$('.th_loc_zone, .th_loc_aisle').hide();
$('.th_loc_rack').text(label_rack);
}
}
function location_cells(item) {
if (advanced) {
return `<td class="py-3">${item['zone'] || ''}</td>
<td class="py-3">${item['aisle'] || ''}</td>
<td class="py-3">${item['rack'] || ''}</td>`;
}
return `<td class="py-3">${item['rack'] || ''}</td>`;
}
function retrieve_stock_in() {
return ajax_request({
@@ -129,9 +154,7 @@
<td class="py-3">${item['lot_number'] || '<span class="text-muted">—</span>'}</td>
<td class="py-3">${item['serial_number'] || '<span class="text-muted">—</span>'}</td>
<td class="py-3">${item['quantity']}</td>
<td class="py-3">${item['zone']}</td>
<td class="py-3">${item['aisle']}</td>
<td class="py-3">${item['rack']}</td>
${location_cells(item)}
<td class="py-3">
${item['status'] == 1 ? '<span class="badge bg-success">Approved</span>' : '<span class="badge bg-warning text-dark">Draft</span>'}
</td>
@@ -213,7 +236,13 @@
checkRequired: 0,
action: 'read',
onSuccess: function(res) {
auto_approve = res.output.default_stock_status == 1;
var s = res.output || {};
auto_approve = s.default_stock_status == 1;
advanced = s.advanced_location == 1;
label_rack = s.location_label_rack || (advanced ? 'Rack' : 'Location');
label_zone = s.location_label_zone || 'Zone';
label_aisle = s.location_label_aisle || 'Aisle';
apply_location_labels();
}
});
}
+36 -7
View File
@@ -51,9 +51,9 @@
<th>Lot Number</th>
<th>Serial Number</th>
<th>Quantity</th>
<th>Zone</th>
<th>Aisle</th>
<th>Rack</th>
<th class="th_loc_zone">Zone</th>
<th class="th_loc_aisle">Aisle</th>
<th class="th_loc_rack">Rack</th>
<th>Status</th>
<th>Action</th>
</tr>
@@ -78,6 +78,31 @@
<?php require "../include_ending.php";?>
<script>
var advanced = false;
var label_rack = 'Location';
var label_zone = 'Zone';
var label_aisle = 'Aisle';
function apply_location_labels() {
if (advanced) {
$('.th_loc_zone').text(label_zone).show();
$('.th_loc_aisle').text(label_aisle).show();
$('.th_loc_rack').text(label_rack);
} else {
$('.th_loc_zone, .th_loc_aisle').hide();
$('.th_loc_rack').text(label_rack);
}
}
function location_cells(item) {
if (advanced) {
return `<td class="py-3">${item['zone'] || ''}</td>
<td class="py-3">${item['aisle'] || ''}</td>
<td class="py-3">${item['rack'] || ''}</td>`;
}
return `<td class="py-3">${item['rack'] || ''}</td>`;
}
function retrieve_stock_out() {
return ajax_request({
@@ -129,9 +154,7 @@
<td class="py-3">${item['lot_number'] || '<span class="text-muted">—</span>'}</td>
<td class="py-3">${item['serial_number'] || '<span class="text-muted">—</span>'}</td>
<td class="py-3">${item['quantity']}</td>
<td class="py-3">${item['zone']}</td>
<td class="py-3">${item['aisle']}</td>
<td class="py-3">${item['rack']}</td>
${location_cells(item)}
<td class="py-3">
${item['status'] == 1 ? '<span class="badge bg-success">Approved</span>' : '<span class="badge bg-warning text-dark">Draft</span>'}
</td>
@@ -213,7 +236,13 @@
checkRequired: 0,
action: 'read',
onSuccess: function(res) {
auto_approve = res.output.default_stock_status == 1;
var s = res.output || {};
auto_approve = s.default_stock_status == 1;
advanced = s.advanced_location == 1;
label_rack = s.location_label_rack || (advanced ? 'Rack' : 'Location');
label_zone = s.location_label_zone || 'Zone';
label_aisle = s.location_label_aisle || 'Aisle';
apply_location_labels();
}
});
}
+36 -7
View File
@@ -49,9 +49,9 @@
<th>Date</th>
<th>Product</th>
<th>Quantity</th>
<th>Zone</th>
<th>Aisle</th>
<th>Rack</th>
<th class="th_loc_zone">Zone</th>
<th class="th_loc_aisle">Aisle</th>
<th class="th_loc_rack">Rack</th>
<th>Status</th>
<th>Action</th>
</tr>
@@ -76,6 +76,31 @@
<?php require "../include_ending.php";?>
<script>
var advanced = false;
var label_rack = 'Location';
var label_zone = 'Zone';
var label_aisle = 'Aisle';
function apply_location_labels() {
if (advanced) {
$('.th_loc_zone').text(label_zone).show();
$('.th_loc_aisle').text(label_aisle).show();
$('.th_loc_rack').text(label_rack);
} else {
$('.th_loc_zone, .th_loc_aisle').hide();
$('.th_loc_rack').text(label_rack);
}
}
function location_cells(item) {
if (advanced) {
return `<td class="py-3">${item['zone'] || ''}</td>
<td class="py-3">${item['aisle'] || ''}</td>
<td class="py-3">${item['rack'] || ''}</td>`;
}
return `<td class="py-3">${item['rack'] || ''}</td>`;
}
function retrieve_stock_transfer() {
return ajax_request({
@@ -125,9 +150,7 @@
<td class="py-3">${format_date(item["date"])}</td>
<td class="py-3">${item["product_sku"]}: ${item['product_name']}</td>
<td class="py-3">${item['quantity']}</td>
<td class="py-3">${item['zone']}</td>
<td class="py-3">${item['aisle']}</td>
<td class="py-3">${item['rack']}</td>
${location_cells(item)}
<td class="py-3">
${item['status'] == 1 ? '<span class="badge bg-success">Approved</span>' : '<span class="badge bg-warning text-dark">Draft</span>'}
</td>
@@ -211,7 +234,13 @@
checkRequired: 0,
action: 'read',
onSuccess: function(res) {
auto_approve = res.output.default_stock_status == 1;
var s = res.output || {};
auto_approve = s.default_stock_status == 1;
advanced = s.advanced_location == 1;
label_rack = s.location_label_rack || (advanced ? 'Rack' : 'Location');
label_zone = s.location_label_zone || 'Zone';
label_aisle = s.location_label_aisle || 'Aisle';
apply_location_labels();
}
});
}
+176 -79
View File
@@ -28,59 +28,84 @@
<div class="col-12">
<div class="card">
<div class="card-body p-6">
<!-- Warehouse — always shown -->
<div class="row">
<div class="col-md-6 mb-3">
<label for="warehouse" class="form-label">Warehouse</label>
<select class="form-select" id="warehouse" required></select>
</div>
</div>
<!-- Simple mode — from/to location fields -->
<div id="form_simple">
<div class="row">
<div class="col-md-3 mb-3">
<label for="location_from" class="form-label" id="label_location_from">Location From</label>
<input type="text" class="form-control" id="location_from"
placeholder="e.g. Shelf-A or 1">
<div class="form-text">Single value or range start e.g. Shelf-A, Bin-1, 1</div>
</div>
<div class="col-md-3 mb-3">
<label for="location_to" class="form-label" id="label_location_to">Location To</label>
<input type="text" class="form-control" id="location_to"
placeholder="e.g. Shelf-E or 5">
<div class="form-text">Same as From for a single location</div>
</div>
</div>
</div>
<!-- Advanced mode — zone + aisle range + rack range -->
<div id="form_advanced" class="d-none">
<div class="row">
<div class="col-md-6 mb-3">
<label for="zone" class="form-label">Zone</label>
<input type="text" class="form-control" id="zone" placeholder="Enter zone" required>
<label for="zone" class="form-label" id="label_zone">Zone</label>
<input type="text" class="form-control" id="zone" placeholder="e.g. A">
</div>
</div>
<div class="row">
<div class="col-md-3 mb-3">
<label for="aisle_from" class="form-label">Aisle From</label>
<input type="text" class="form-control" id="aisle_from" placeholder="e.g. 1 or A" required
pattern="^([0-9]+|[A-Za-z])$" title="Enter a number (e.g. 1) or a single letter (e.g. A)">
<label for="aisle_from" class="form-label" id="label_aisle_from">Aisle From</label>
<input type="text" class="form-control" id="aisle_from" placeholder="e.g. 1 or A"
pattern="^([0-9]+|[A-Za-z])$" title="Number or single letter">
<div class="form-text">Number (1–99) or letter (A–Z)</div>
</div>
<div class="col-md-3 mb-3">
<label for="aisle_to" class="form-label">Aisle To</label>
<input type="text" class="form-control" id="aisle_to" placeholder="e.g. 5 or E" required
pattern="^([0-9]+|[A-Za-z])$" title="Enter a number (e.g. 5) or a single letter (e.g. E)">
<label for="aisle_to" class="form-label" id="label_aisle_to">Aisle To</label>
<input type="text" class="form-control" id="aisle_to" placeholder="e.g. 5 or E"
pattern="^([0-9]+|[A-Za-z])$" title="Number or single letter">
<div class="form-text">Must be same type as Aisle From</div>
</div>
<div class="col-md-3 mb-3">
<label for="rack_from" class="form-label">Rack From</label>
<input type="text" class="form-control" id="rack_from" placeholder="e.g. 1 or A" required
pattern="^([0-9]+|[A-Za-z])$" title="Enter a number (e.g. 1) or a single letter (e.g. A)">
<label for="rack_from" class="form-label" id="label_rack_from">Rack From</label>
<input type="text" class="form-control" id="rack_from" placeholder="e.g. 1 or A"
pattern="^([0-9]+|[A-Za-z])$" title="Number or single letter">
<div class="form-text">Number (1–99) or letter (A–Z)</div>
</div>
<div class="col-md-3 mb-3">
<label for="rack_to" class="form-label">Rack To</label>
<input type="text" class="form-control" id="rack_to" placeholder="e.g. 10 or J" required
pattern="^([0-9]+|[A-Za-z])$" title="Enter a number (e.g. 10) or a single letter (e.g. J)">
<label for="rack_to" class="form-label" id="label_rack_to">Rack To</label>
<input type="text" class="form-control" id="rack_to" placeholder="e.g. 10 or J"
pattern="^([0-9]+|[A-Za-z])$" title="Number or single letter">
<div class="form-text">Must be same type as Rack From</div>
</div>
</div>
</div>
<!-- Description — always shown -->
<div class="mb-3">
<label for="description" class="form-label">Description</label>
<textarea class="form-control" id="description" rows="4"
placeholder="Enter warehouse description"></textarea>
placeholder="Enter storage description"></textarea>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary" onclick="manage_storage();">Add Storage</button>
<button type="reset" class="btn btn-secondary" onclick="reset_input('#storageForm')">Clear</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -91,116 +116,188 @@
<?php require "../include_ending.php";?>
<script>
function manage_storage() {
// ── Location config ───────────────────────────────────────────────────────
var advanced = false;
var label_rack = 'Location';
var label_zone = 'Zone';
var label_aisle = 'Aisle';
// Validate that aisle from/to and rack from/to are same type
var af = $('#aisle_from').val().trim().toUpperCase();
var at = $('#aisle_to').val().trim().toUpperCase();
var rf = $('#rack_from').val().trim().toUpperCase();
var rt = $('#rack_to').val().trim().toUpperCase();
function load_company_setting() {
return ajax_request({
url: '<?php echo $server_url?>setting/api/engine/company_setting.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
onSuccess: function(res) {
var s = res.output;
advanced = s.advanced_location == 1;
label_rack = s.location_label_rack || (advanced ? 'Rack' : 'Location');
label_zone = s.location_label_zone || 'Zone';
label_aisle = s.location_label_aisle || 'Aisle';
apply_location_labels();
}
});
}
function apply_location_labels() {
if (advanced) {
$('#form_simple').addClass('d-none');
$('#form_advanced').removeClass('d-none');
$('#label_zone').text(label_zone);
$('#label_aisle_from').text(label_aisle + ' From');
$('#label_aisle_to').text(label_aisle + ' To');
$('#label_rack_from').text(label_rack + ' From');
$('#label_rack_to').text(label_rack + ' To');
} else {
$('#form_advanced').addClass('d-none');
$('#form_simple').removeClass('d-none');
$('#label_location_from').text(label_rack + ' From');
$('#label_location_to').text(label_rack + ' To');
$('#location_from').attr('placeholder', 'e.g. Shelf-A or 1');
$('#location_to').attr('placeholder', 'e.g. Shelf-E or 5');
}
}
// ── Save storage ──────────────────────────────────────────────────────────
function manage_storage() {
var zone, aisle_from, aisle_to, rack_from, rack_to;
if (advanced) {
zone = $('#zone').val().trim();
aisle_from = $('#aisle_from').val().trim().toUpperCase();
aisle_to = $('#aisle_to').val().trim().toUpperCase();
rack_from = $('#rack_from').val().trim().toUpperCase();
rack_to = $('#rack_to').val().trim().toUpperCase();
var isNumeric = function(v) { return /^[0-9]+$/.test(v); };
var isAlpha = function(v) { return /^[A-Z]$/.test(v); };
if (af && at) {
if (isNumeric(af) !== isNumeric(at)) {
alert('Aisle From and Aisle To must both be numbers or both be letters.');
return;
if (aisle_from && aisle_to) {
if (isNumeric(aisle_from) !== isNumeric(aisle_to)) {
alert(label_aisle + ' From and To must both be numbers or both be letters.'); return;
}
if (isAlpha(af) && isAlpha(at) && af.charCodeAt(0) > at.charCodeAt(0)) {
alert('Aisle From must be less than or equal to Aisle To.');
return;
if (isAlpha(aisle_from) && aisle_from.charCodeAt(0) > aisle_to.charCodeAt(0)) {
alert(label_aisle + ' From must be less than or equal to To.'); return;
}
if (isNumeric(af) && isNumeric(at) && parseInt(af) > parseInt(at)) {
alert('Aisle From must be less than or equal to Aisle To.');
return;
if (isNumeric(aisle_from) && parseInt(aisle_from) > parseInt(aisle_to)) {
alert(label_aisle + ' From must be less than or equal to To.'); return;
}
}
if (rack_from && rack_to) {
if (isNumeric(rack_from) !== isNumeric(rack_to)) {
alert(label_rack + ' From and To must both be numbers or both be letters.'); return;
}
if (isAlpha(rack_from) && rack_from.charCodeAt(0) > rack_to.charCodeAt(0)) {
alert(label_rack + ' From must be less than or equal to To.'); return;
}
if (isNumeric(rack_from) && parseInt(rack_from) > parseInt(rack_to)) {
alert(label_rack + ' From must be less than or equal to To.'); return;
}
}
if (rf && rt) {
if (isNumeric(rf) !== isNumeric(rt)) {
alert('Rack From and Rack To must both be numbers or both be letters.');
return;
} else {
// Simple mode — from/to, same value written to zone/aisle/rack
var loc_from = $('#location_from').val().trim();
var loc_to = $('#location_to').val().trim();
if (!loc_from) {
alert('Please enter a ' + label_rack + ' From value.'); return;
}
if (isAlpha(rf) && isAlpha(rt) && rf.charCodeAt(0) > rt.charCodeAt(0)) {
alert('Rack From must be less than or equal to Rack To.');
return;
}
if (isNumeric(rf) && isNumeric(rt) && parseInt(rf) > parseInt(rt)) {
alert('Rack From must be less than or equal to Rack To.');
return;
// If To is blank, default to same as From (single location)
if (!loc_to) loc_to = loc_from;
// Frontend prefix validation for better UX
var prefix_pattern = /^(.*?)([A-Za-z]|[0-9]+)$/;
var mf = loc_from.match(prefix_pattern);
var mt = loc_to.match(prefix_pattern);
if (mf && mt && mf[1] !== mt[1]) {
alert(label_rack + ' From and To must share the same prefix (e.g. Shelf-A → Shelf-E).'); return;
}
zone = loc_from;
aisle_from = loc_from;
aisle_to = loc_to;
rack_from = loc_from;
rack_to = loc_to;
}
return ajax_request({
url: "<?php echo $server_url?>inventory/api/engine/manage_storage.php",
url: '<?php echo $server_url?>inventory/api/engine/manage_storage.php',
autoPrepare: true,
checkRequired: 0,
debugMode: 0,
action: 'manage',
onSuccess: function(res) {
window.location.href = "<?php echo $server_url?>inventory/warehouse.php";
data: {
warehouse: $('#warehouse').val(),
zone: zone,
aisle_from: aisle_from,
aisle_to: aisle_to,
rack_from: rack_from,
rack_to: rack_to,
description: $('#description').val(),
},
onSuccess: function() {
window.location.href = '<?php echo $server_url?>inventory/warehouse.php';
}
});
}
// ── Retrieve warehouses ───────────────────────────────────────────────────
function retrieve_warehouse() {
return ajax_request({
url: "<?php echo $server_url?>inventory/api/engine/warehouse.php",
url: '<?php echo $server_url?>inventory/api/engine/warehouse.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
onSuccess: function(res) {
var option = ``;
var option = '';
$.each(res.output, function(key, item) {
option += `<option value='${item.id}'>${item.warehouse_name}</option>`;
})
$(`select#warehouse`).html(option);
option += '<option value="' + item.id + '">' + item.warehouse_name + '</option>';
});
$('select#warehouse').html(option);
}
});
}
// ── Load for edit ─────────────────────────────────────────────────────────
function retrieve_for_edit() {
return ajax_request({
url: "<?php echo $server_url?>inventory/api/engine/retrieve_storage.php",
url: '<?php echo $server_url?>inventory/api/engine/retrieve_storage.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
onSuccess: function(res) {
$.each(res.output, function(key, item) {
$(`#${key}`).val(item);
});
$(`button[type=submit]`).text('Update Storage');
$(`button[type=reset]`).hide();
var d = res.output;
$('#warehouse').val(d.warehouse);
$('#description').val(d.description || '');
if (advanced) {
$('#zone').val(d.zone || '');
$('#aisle_from').val(d.aisle_from || '');
$('#aisle_to').val(d.aisle_to || '');
$('#rack_from').val(d.rack_from || '');
$('#rack_to').val(d.rack_to || '');
} else {
$('#location_from').val(d.rack_from || '');
$('#location_to').val(d.rack_to || '');
}
$('button[type=submit]').text('Update Storage');
$('button[type=reset]').hide();
}
});
}
// ── Boot ──────────────────────────────────────────────────────────────────
$(async function() {
try {
await load_company_setting();
await retrieve_warehouse();
<?php if(!empty($_GET["id"])){ ?>
<?php if (!empty($_GET['id'])) { ?>
await retrieve_for_edit();
<?php } ?>
} catch(e) {
console.log(e);
console.error(e);
}
});
</script>
+62 -8
View File
@@ -202,7 +202,7 @@
<div class="mb-6 d-flex align-items-center justify-content-between">
<div>
<h2 class="fs-4 mb-2">Storage Locations</h2>
<p>Zones, aisles, and rack assignments</p>
<p id="storage_subtitle">Zones, aisles, and rack assignments</p>
</div>
<div>
<a href="<?php echo $server_url?>inventory/manage_storage.php" class="btn btn-secondary">
@@ -228,9 +228,9 @@
<thead class="table-secondary">
<tr>
<th class="py-3">Warehouse</th>
<th class="py-3">Zone</th>
<th class="py-3">Aisle Range</th>
<th class="py-3">Rack Range</th>
<th class="py-3" id="th_level1">Zone</th>
<th class="py-3" id="th_level2">Aisle Range</th>
<th class="py-3" id="th_level3">Rack Range</th>
<th class="py-3">Items Stored</th>
<th>Action</th>
</tr>
@@ -251,6 +251,54 @@
<?php require "../include_ending.php";?>
<script>
// ── Location config ───────────────────────────────────────────────────────
var advanced = false;
var label_rack = 'Location';
var label_zone = 'Zone';
var label_aisle = 'Aisle';
function load_company_setting() {
return ajax_request({
url: '<?php echo $server_url?>setting/api/engine/company_setting.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
onSuccess: function(res) {
var s = res.output;
advanced = s.advanced_location == 1;
label_rack = s.location_label_rack || (advanced ? 'Rack' : 'Location');
label_zone = s.location_label_zone || 'Zone';
label_aisle = s.location_label_aisle || 'Aisle';
apply_location_labels();
}
});
}
function apply_location_labels() {
// Stats card subtitles
$('#total_capacity small').text(label_rack + ' positions available');
$('#total_space_used small').text(label_rack + 's occupied');
// Storage tab subtitle + search
$('#storage_subtitle').text(advanced
? label_zone + 's, ' + label_aisle + 's, and ' + label_rack + ' assignments'
: label_rack + ' assignments');
$('#search_storage').attr('placeholder', advanced
? 'Search by warehouse or ' + label_zone.toLowerCase() + '…'
: 'Search by warehouse or ' + label_rack.toLowerCase() + '…');
// Table headers
if (advanced) {
$('#th_level1').text(label_zone).show();
$('#th_level2').text(label_aisle + ' Range').show();
$('#th_level3').text(label_rack + ' Range');
} else {
$('#th_level1').hide();
$('#th_level2').hide();
$('#th_level3').text(label_rack + ' Range');
}
}
// ── Current search terms (read by change_page_* when paginating) ─────
var search_term_warehouse = '';
var search_term_storage = '';
@@ -379,7 +427,8 @@
var filtered = kw ?
alasql(`SELECT * FROM storage
WHERE LOWER(warehouse_name) LIKE '%${kw}%'
OR LOWER(zone) LIKE '%${kw}%'`) :
OR LOWER(zone) LIKE '%${kw}%'
OR LOWER(rack) LIKE '%${kw}%'`) :
alasql(`SELECT * FROM storage`);
var page = generate_pagination('storage', filtered.length);
@@ -396,11 +445,15 @@
var body = '';
$.each(page_data, function(key, item) {
var location_cells = advanced
? `<td class="py-3">${item['zone']}</td>
<td class="py-3">${item['aisle_from']} - ${item['aisle_to']}</td>
<td class="py-3">${item['rack_from']} - ${item['rack_to']}</td>`
: `<td class="py-3">${item['rack_from']} - ${item['rack_to']}</td>`;
body += `<tr>
<td class="py-3">${item['warehouse_name']}</td>
<td class="py-3">${item['zone']}</td>
<td class="py-3">${item['aisle_from']} - ${item["aisle_to"]}</td>
<td class="py-3">${item['rack_from']} - ${item["rack_to"]}</td>
${location_cells}
<td class="py-3"></td>
<td class="py-3">
<a href="<?php echo $server_url?>inventory/manage_storage.php?id=${item['id']}" class=""><i class="ti ti-eye fs-5"></i></a>
@@ -477,6 +530,7 @@
// ── Boot ─────────────────────────────────────────────────────────────
$(async function() {
try {
await load_company_setting();
await retrieve_stats();
await retrieve_warehouse();
await retrieve_storage();
+7 -16
View File
@@ -1,31 +1,22 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes/ReturnManager.php';
require '../../../assets/utils/classes/WarehouseManager.php';
$id = (int)($data['id'] ?? 0);
if (!$id) {
$answer['message'] = 'Invalid ID.';
$answer['message'] = 'Invalid return ID.';
http_response_code(400);
exit(json_encode($answer));
}
try {
dbTransaction($pdo2, function($pdo) use ($id, $company_id) {
$sth = $pdo->prepare(
"SELECT status FROM td_return
WHERE company_id = :company_id AND id = :id"
);
$sth->execute([':company_id' => $company_id, ':id' => $id]);
$row = $sth->fetch(PDO::FETCH_ASSOC);
if (!$row) throw new Exception("Return not found.");
if ((int)$row['status'] !== 0) throw new Exception("Only draft returns can be cancelled.");
$pdo->prepare(
"UPDATE td_return SET status = -1
WHERE id = :id AND company_id = :company_id"
)->execute([':id' => $id, ':company_id' => $company_id]);
dbTransaction($pdo2, function($pdo) use ($id, $company_id, $logging) {
$ret = new ReturnManager($pdo, $company_id);
$whMgmt = new WarehouseManager($pdo, $company_id);
$ret->cancelReturn($id, $logging, $whMgmt);
});
$answer['success'] = 1;
@@ -0,0 +1,39 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes/OrderManager.php';
require '../../../assets/utils/classes/CompanySettingManager.php';
$id = (int)($data['id'] ?? 0);
$fulfillment_status = (int)($data['fulfillment_status'] ?? 0);
$tracking_number = trim((string)($data['shipping_tracking_number'] ?? ''));
if (!$id) {
$answer['message'] = 'Invalid order ID.';
http_response_code(400);
exit(json_encode($answer));
}
$csm = new CompanySettingManager($pdo1, $company_id);
$auto_complete = (int)$csm->get('auto_complete_on_ship') === 1;
try {
dbTransaction($pdo2, function($pdo) use ($id, $fulfillment_status, $tracking_number, $company_id, $logging, $auto_complete) {
$order = new OrderManager($pdo, $company_id);
$order->updateFulfillmentStatus($id, $fulfillment_status, $tracking_number, $logging, $auto_complete);
});
$answer['success'] = 1;
$answer['message'] = 'Order updated.';
} catch (PDOException $e) {
$answer['message'] = 'Database error, please try again.';
http_response_code(500);
} catch (Exception $e) {
$answer['message'] = $e->getMessage();
http_response_code(400);
}
exit(json_encode($answer));
?>
+1 -1
View File
@@ -201,7 +201,7 @@
var status_hint = !is_invoice ? 'Status cannot be changed on credit/debit notes'
: status === 0 ? 'Issue the invoice first before changing status'
: status === 4 ? 'Invoice is void' : '';
set_btn_state('#invoice_status_group button', can_change_status, status_hint);
$('#invoice_status_select').prop('disabled', !can_change_status);
}
+61 -16
View File
@@ -60,6 +60,12 @@
placeholder="DD/MM/YYYY" autocomplete="off">
</div>
<div class="mb-3 col-lg-6">
<label class="form-label">Shipping Tracking Number</label>
<input type="text" id="shipping_tracking_number" class="form-control"
placeholder="Carrier tracking number..." maxlength="100" autocomplete="off">
</div>
<div class="mb-3 col-lg-12">
<label class="form-label">Notes</label>
<textarea id="notes" class="form-control" rows="2"
@@ -112,8 +118,10 @@
<div class="d-flex gap-2 mb-2">
<span id="badge_status"></span>
<span id="badge_payment"></span>
<span id="badge_fulfillment"></span>
</div>
<p class="mb-0 small text-muted" id="order_number_display"></p>
<p class="mb-0 small text-muted d-none" id="tracking_number_display"></p>
</div>
</div>
@@ -172,7 +180,21 @@
<i class="ti ti-file-invoice me-1"></i>Proceed to Invoice
</button>
<!-- Update payment status (confirmed and above) -->
<!-- Update fulfillment status (confirmed and above) -->
<div id="fulfillment_status_group">
<label class="form-label small text-muted mb-1">Fulfillment Status</label>
<div class="d-flex gap-2">
<select id="fulfillment_status_select" class="form-select form-select-sm">
<option value="1">Picking</option>
<option value="2">Packed</option>
<option value="3">Shipped</option>
</select>
<button class="btn btn-sm btn-outline-primary text-nowrap"
onclick="update_fulfillment_status()">
<i class="ti ti-check"></i>
</button>
</div>
</div>
<div id="payment_status_group">
<label class="form-label small text-muted mb-1">Payment Status</label>
<div class="d-flex gap-2">
@@ -242,6 +264,15 @@
return map[String(status)] ?? '—';
}
function fulfillment_status_badge(status) {
const map = {
'1': '<span class="badge bg-warning text-dark"><i class="ti ti-list-check me-1"></i>Picking</span>',
'2': '<span class="badge bg-info text-white"><i class="ti ti-package me-1"></i>Packed</span>',
'3': '<span class="badge bg-success"><i class="ti ti-truck me-1"></i>Shipped</span>',
};
return map[String(status)] ?? '';
}
// ── Recalculate totals ────────────────────────────────────────────────────
function recalc_totals() {
@@ -346,37 +377,45 @@
// ── Set view/edit mode ────────────────────────────────────────────────────
function set_mode(status, has_active_invoice, returnable_count) {
function set_mode(status, has_active_invoice, returnable_count, fulfillment_status) {
status = parseInt(status);
has_active_invoice = has_active_invoice || false;
returnable_count = returnable_count ?? 1;
fulfillment_status = parseInt(fulfillment_status) || 0;
var is_draft = status === 0;
var can_confirm = status === 0;
var can_cancel = status === 0 || status === 1;
var is_cancelled = status === -1;
var is_completed = status === 3;
var can_fulfill = status >= 1 && !is_cancelled && !is_completed;
// Edit controls
$('#btn_add_item, #contact, #order_date, #notes, #discount, #tax, #shipping_fee')
.prop('disabled', !is_draft);
$('#shipping_tracking_number').prop('disabled', !(is_draft || can_fulfill));
$('.item_sku, .item_qty, .item_price, .item_warehouse').prop('disabled', !is_draft);
$('a[onclick^="remove_item"]').toggleClass('d-none', !is_draft);
// Action buttons — set_btn_state handles class swap + disabled + title
// Action buttons
set_btn_state('#btn_save', is_draft, 'Only draft orders can be edited');
set_btn_state('#btn_confirm', can_confirm, 'Order must be in draft to confirm');
set_btn_state('#btn_cancel', can_cancel && !is_cancelled,
is_cancelled ? 'Order is already cancelled' : 'Only draft or confirmed orders can be cancelled');
// Order status selector — confirmed and above, not cancelled
var can_change_status = status >= 1 && !is_cancelled;
$('#order_status_group').toggleClass('d-none', !can_change_status);
set_btn_state('#order_status_group button', can_change_status,
is_cancelled ? 'Order is cancelled' : 'Confirm the order first');
$('#order_status_select').prop('disabled', !can_change_status);
// Fulfillment status — confirmed and above, not cancelled, not completed
$('#fulfillment_status_group').toggleClass('d-none', !can_fulfill);
if (can_fulfill) {
// Only show options ahead of current fulfillment
$('#fulfillment_status_select option').each(function() {
$(this).prop('disabled', parseInt($(this).val()) <= fulfillment_status);
});
// Default to next step
var next = fulfillment_status + 1;
if (next <= 3) $('#fulfillment_status_select').val(next);
else $('#fulfillment_status_select').val(fulfillment_status);
}
// Payment status selector
set_btn_state('#payment_status_group button', status >= 1 && !is_cancelled,
'Confirm the order before updating payment status');
$('#payment_status_select').prop('disabled', status < 1 || is_cancelled);
// Create Return
@@ -421,12 +460,17 @@
$('#status_card').removeClass('d-none');
$('#badge_status').html(order_status_badge(o.status));
$('#badge_payment').html(payment_status_badge(o.payment_status));
$('#badge_fulfillment').html(fulfillment_status_badge(o.fulfillment_status));
$('#order_number_display').text(o.order_number);
$('#tracking_number_display')
.toggleClass('d-none', !o.shipping_tracking_number)
.text(o.shipping_tracking_number ? 'Tracking: ' + o.shipping_tracking_number : '');
// Fields
$('#contact').val(o.contact_name || '');
$('#contact_id').val(o.contact_id);
$('#order_date').val(o.order_date ? format_date(o.order_date) : '');
$('#shipping_tracking_number').val(o.shipping_tracking_number || '');
$('#notes').val(o.notes || '');
$('#discount').val(o.discount || 0);
$('#tax').val(o.tax || 0);
@@ -437,9 +481,8 @@
(o.items || []).forEach(function(item) { add_item_row(item); });
recalc_totals();
set_mode(o.status, res.has_active_invoice, (res.returnable_items || []).length);
set_mode(o.status, res.has_active_invoice, (res.returnable_items || []).length, o.fulfillment_status);
$('#payment_status_select').val(o.payment_status ?? 0);
$('#order_status_select').val(o.status);
}
});
}
@@ -470,6 +513,7 @@
discount: $('#discount').val() || 0,
tax: $('#tax').val() || 0,
shipping_fee: $('#shipping_fee').val() || 0,
shipping_tracking_number: $('#shipping_tracking_number').val().trim(),
notes: $('#notes').val(),
},
onSuccess: function(res) {
@@ -508,8 +552,8 @@
}
// ── Update order status ───────────────────────────────────────────────────
function update_order_status() {
// ── Update fulfillment status ─────────────────────────────────────────────
function update_fulfillment_status() {
ajax_request({
url: '<?php echo $server_url?>order/api/engine/update_order_status.php',
autoPrepare: true,
@@ -517,7 +561,8 @@
action: 'update',
data: {
id: order_id,
status: $('#order_status_select').val(),
fulfillment_status: $('#fulfillment_status_select').val(),
shipping_tracking_number: $('#shipping_tracking_number').val().trim(),
},
onSuccess: function() {
retrieve_order();
+2 -2
View File
@@ -376,8 +376,8 @@
status === -1 ? 'Return is cancelled' : 'Return has already been confirmed');
set_btn_state('#btn_confirm', is_draft,
status === -1 ? 'Return is cancelled' : 'Return is already confirmed');
set_btn_state('#btn_cancel', is_draft,
status === -1 ? 'Return is already cancelled' : 'Only draft returns can be cancelled');
set_btn_state('#btn_cancel', is_draft || status === 1,
status === -1 ? 'Return is already cancelled' : 'Return is completed and cannot be cancelled');
}
+16 -2
View File
@@ -95,7 +95,7 @@
<div class="col-lg-5">
<div class="position-relative">
<input type="text" id="search_input" class="form-control ps-9"
placeholder="Search order number or contact...">
placeholder="Search order number, contact, or tracking...">
<span class="position-absolute top-25 ms-4">
<i class="ti ti-search text-muted"></i>
</span>
@@ -144,6 +144,8 @@
<th class="py-3">Items</th>
<th class="py-3">Grand Total</th>
<th class="py-3">Status</th>
<th class="py-3">Fulfillment</th>
<th class="py-3">Tracking #</th>
<th class="py-3">Payment</th>
<th class="py-3">Action</th>
</tr>
@@ -188,6 +190,15 @@
return map[status] ?? '<span class="badge bg-light text-dark">—</span>';
}
function fulfillment_status_badge(status) {
const map = {
'1': '<span class="badge bg-warning text-dark"><i class="ti ti-list-check me-1"></i>Picking</span>',
'2': '<span class="badge bg-info text-white"><i class="ti ti-package me-1"></i>Packed</span>',
'3': '<span class="badge bg-success"><i class="ti ti-truck me-1"></i>Shipped</span>',
};
return map[String(status)] ?? '<span class="text-muted">—</span>';
}
// ── Retrieve orders ───────────────────────────────────────────────────────
function retrieve_orders() {
@@ -208,7 +219,8 @@
var data = all.filter(function(o) {
var match_kw = !keyword ||
(o.order_number || '').toLowerCase().includes(keyword) ||
(o.contact_name || '').toLowerCase().includes(keyword);
(o.contact_name || '').toLowerCase().includes(keyword) ||
(o.shipping_tracking_number || '').toLowerCase().includes(keyword);
var match_st = fil_status === '' || String(o.status) === fil_status;
var match_pm = fil_payment === '' || String(o.payment_status) === fil_payment;
return match_kw && match_st && match_pm;
@@ -254,6 +266,8 @@
<td class="py-3">${item_count} item${item_count !== 1 ? 's' : ''}</td>
<td class="py-3 fw-semibold">${format_number(o.grand_total, 2)}</td>
<td class="py-3">${order_status_badge(o.status)}</td>
<td class="py-3">${fulfillment_status_badge(o.fulfillment_status)}</td>
<td class="py-3">${o.shipping_tracking_number || '<span class="text-muted">—</span>'}</td>
<td class="py-3">${payment_status_badge(o.payment_status)}</td>
<td class="py-3">
<a href="<?php echo $server_url?>order/manage_order.php?id=${o.id}" title="View">
+1 -1
View File
@@ -146,7 +146,7 @@
$.each(page_data, function(i, r) {
var items = JSON.parse(r.items || '[]');
var can_cancel = parseInt(r.status) === 0;
var can_cancel = parseInt(r.status) >= 0 && parseInt(r.status) <= 1;
body += `<tr>
<td class="py-3 fw-semibold">${r.return_number}</td>
+40 -4
View File
@@ -138,7 +138,7 @@
<th>Product</th>
<th>SKU</th>
<th>Lot Number</th>
<th>Location</th>
<th class="th_location">Location</th>
<th>Expiry Date</th>
<th>Days Overdue</th>
<th>Quantity</th>
@@ -167,7 +167,7 @@
<th>Product</th>
<th>SKU</th>
<th>Lot Number</th>
<th>Location</th>
<th class="th_location">Location</th>
<th>Expiry Date</th>
<th>Days Remaining</th>
<th>Quantity</th>
@@ -193,6 +193,41 @@
var all_items = [];
var search_term = '';
var filter_status = '';
var advanced = false;
var label_rack = 'Location';
var label_zone = 'Zone';
var label_aisle = 'Aisle';
function apply_location_labels() {
$('.th_location').text(advanced
? label_zone + ' / ' + label_aisle + ' / ' + label_rack
: label_rack
);
}
function format_location(item) {
if (advanced) {
return [item.zone, item.aisle, item.rack].filter(Boolean).join('-') || '<span class="text-muted">—</span>';
}
return item.rack || '<span class="text-muted">—</span>';
}
function load_company_setting() {
return ajax_request({
url: server_url + 'setting/api/engine/company_setting.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
onSuccess: function(res) {
var s = res.output || {};
advanced = s.advanced_location == 1;
label_rack = s.location_label_rack || (advanced ? 'Rack' : 'Location');
label_zone = s.location_label_zone || 'Zone';
label_aisle = s.location_label_aisle || 'Aisle';
apply_location_labels();
}
});
}
// ── Fetch + populate ─────────────────────────────────────────────────
@@ -270,7 +305,7 @@
<td class="py-3">${item.product_name}</td>
<td class="py-3">${item.product_sku}</td>
<td class="py-3">${item.lot_number || '<span class="text-muted">—</span>'}</td>
<td class="py-3">${item.zone}-${item.aisle}-${item.rack}</td>
<td class="py-3">${format_location(item)}</td>
<td class="py-3">${item.expiry_date}</td>
<td class="py-3 text-danger fw-semibold">${Math.abs(item.days_remaining)} days</td>
<td class="py-3">${item.quantity}</td>
@@ -294,7 +329,7 @@
<td class="py-3">${item.product_name}</td>
<td class="py-3">${item.product_sku}</td>
<td class="py-3">${item.lot_number || '<span class="text-muted">—</span>'}</td>
<td class="py-3">${item.zone}-${item.aisle}-${item.rack}</td>
<td class="py-3">${format_location(item)}</td>
<td class="py-3">${item.expiry_date}</td>
<td class="py-3 text-warning fw-semibold">${item.days_remaining} days</td>
<td class="py-3">${item.quantity}</td>
@@ -349,6 +384,7 @@
// ── Boot ─────────────────────────────────────────────────────────────
$(async function() {
try {
await load_company_setting();
await retrieve_warehouse();
} catch(e) {
console.log(e);
+78 -21
View File
@@ -129,9 +129,9 @@
<thead class="table-primary border-light">
<tr>
<th>Warehouse</th>
<th>Zone</th>
<th>Aisle</th>
<th>Rack</th>
<th class="th_loc_zone">Zone</th>
<th class="th_loc_aisle">Aisle</th>
<th class="th_loc_rack">Rack</th>
<th>SKU</th>
<th>Product</th>
<th>Status</th>
@@ -172,9 +172,9 @@
<thead class="table-danger border-light">
<tr>
<th>Warehouse</th>
<th>Zone</th>
<th>Aisle</th>
<th>Rack</th>
<th class="th_loc_zone">Zone</th>
<th class="th_loc_aisle">Aisle</th>
<th class="th_loc_rack">Rack</th>
<th>SKU</th>
<th>Product</th>
<th>Action</th>
@@ -214,9 +214,9 @@
<thead class="table-success border-light">
<tr>
<th>Warehouse</th>
<th>Zone</th>
<th>Aisle</th>
<th>Rack</th>
<th class="th_loc_zone">Zone</th>
<th class="th_loc_aisle">Aisle</th>
<th class="th_loc_rack">Rack</th>
<th>Action</th>
</tr>
</thead>
@@ -263,6 +263,68 @@
<?php require "../include_ending.php";?>
<script>
// ── Location config ───────────────────────────────────────────────────────
var advanced = false;
var label_rack = 'Location';
var label_zone = 'Zone';
var label_aisle = 'Aisle';
function load_location_config(callback) {
return ajax_request({
url: '<?php echo $server_url?>setting/api/engine/company_setting.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
onSuccess: function(res) {
var s = res.output;
advanced = s.advanced_location == 1;
label_rack = s.location_label_rack || (advanced ? 'Rack' : 'Location');
label_zone = s.location_label_zone || 'Zone';
label_aisle = s.location_label_aisle || 'Aisle';
if (callback) callback();
}
});
}
function apply_location_labels() {
if (advanced) {
$('.th_loc_zone').text(label_zone).show();
$('.th_loc_aisle').text(label_aisle).show();
$('.th_loc_rack').text(label_rack);
} else {
$('.th_loc_zone').hide();
$('.th_loc_aisle').hide();
$('.th_loc_rack').text(label_rack);
}
var loc_hint = advanced
? label_zone.toLowerCase() + ', ' + label_aisle.toLowerCase() + ', ' + label_rack.toLowerCase()
: label_rack.toLowerCase();
$('#search_all, #search_occupied').attr('placeholder',
'Search by warehouse, ' + loc_hint + ', or SKU…');
$('#search_empty').attr('placeholder',
'Search by warehouse or ' + loc_hint + '…');
$('#rackLogModalLabel').text(label_rack + ' Log');
$('#stat_label_racks').text('Total ' + label_rack + 's');
}
// Returns 1 or 3 <td> cells depending on mode
function location_cells(item) {
if (advanced) {
return '<td class="py-3">' + (item.zone || '—') + '</td>'
+ '<td class="py-3">' + (item.aisle || '—') + '</td>'
+ '<td class="py-3">' + (item.rack || '—') + '</td>';
}
return '<td class="py-3">' + (item.rack || '—') + '</td>';
}
// Rack log modal label
function rack_label(item) {
return advanced
? item.warehouse_name + ' ' + item.zone + '-' + item.aisle + '-' + item.rack
: item.warehouse_name + ' ' + item.rack;
}
var search_all = '';
var search_occupied = '';
var search_empty = '';
@@ -334,13 +396,11 @@
: '<span class="badge bg-success">Available</span>';
body += `<tr>
<td class="py-3">${item.warehouse_name}</td>
<td class="py-3">${item.zone}</td>
<td class="py-3">${item.aisle}</td>
<td class="py-3">${item.rack}</td>
` + location_cells(item) + `
<td class="py-3">${item.product_sku || '<span class="text-muted">—</span>'}</td>
<td class="py-3">${item.product_name || '<span class="text-muted">—</span>'}</td>
<td class="py-3">${badge}</td>
<td class="py-3"><a href="javascript:;" onclick="view_rack_log(${item.id}, '${item.warehouse_name} ${item.zone}-${item.aisle}-${item.rack}')"><i class="ti ti-history fs-5"></i></a></td>
<td class="py-3"><a href="javascript:;" onclick="view_rack_log(${item.id}, '${rack_label(item)}')"><i class="ti ti-history fs-5"></i></a></td>
</tr>`;
});
$('table#tbl_all > tbody').html(body);
@@ -377,12 +437,10 @@
$.each(filtered.slice(offset, offset + prop_limit), function(i, item) {
body += `<tr>
<td class="py-3">${item.warehouse_name}</td>
<td class="py-3">${item.zone}</td>
<td class="py-3">${item.aisle}</td>
<td class="py-3">${item.rack}</td>
` + location_cells(item) + `
<td class="py-3">${item.product_sku}</td>
<td class="py-3">${item.product_name || '<span class="text-muted">—</span>'}</td>
<td class="py-3"><a href="javascript:;" onclick="view_rack_log(${item.id}, '${item.warehouse_name} ${item.zone}-${item.aisle}-${item.rack}')"><i class="ti ti-history fs-5"></i></a></td>
<td class="py-3"><a href="javascript:;" onclick="view_rack_log(${item.id}, '${rack_label(item)}')"><i class="ti ti-history fs-5"></i></a></td>
</tr>`;
});
$('table#tbl_occupied > tbody').html(body);
@@ -417,10 +475,8 @@
$.each(filtered.slice(offset, offset + prop_limit), function(i, item) {
body += `<tr>
<td class="py-3">${item.warehouse_name}</td>
<td class="py-3">${item.zone}</td>
<td class="py-3">${item.aisle}</td>
<td class="py-3">${item.rack}</td>
<td class="py-3"><a href="javascript:;" onclick="view_rack_log(${item.id}, '${item.warehouse_name} ${item.zone}-${item.aisle}-${item.rack}')"><i class="ti ti-history fs-5"></i></a></td>
` + location_cells(item) + `
<td class="py-3"><a href="javascript:;" onclick="view_rack_log(${item.id}, '${rack_label(item)}')"><i class="ti ti-history fs-5"></i></a></td>
</tr>`;
});
$('table#tbl_empty > tbody').html(body);
@@ -512,6 +568,7 @@
// ── Boot ─────────────────────────────────────────────────────────────
$(async function() {
try {
await load_location_config(apply_location_labels);
await retrieve_rack_occupancy();
} catch(e) {
console.log(e);
+38 -2
View File
@@ -154,7 +154,7 @@
<th>Date</th>
<th>Type</th>
<th>Warehouse</th>
<th>Location</th>
<th id="th_location">Location</th>
<th>In</th>
<th>Out</th>
<th>Serial</th>
@@ -182,6 +182,10 @@
var lot_log_data = [];
var lot_log_page = 1;
var lot_log_limit = 10;
var advanced = false;
var label_rack = 'Location';
var label_zone = 'Zone';
var label_aisle = 'Aisle';
var type_badge = {
'in': '<span class="badge bg-success">Stock In</span>',
@@ -196,6 +200,37 @@
'expired': '<span class="badge bg-secondary">Expired</span>',
};
function apply_location_labels() {
$('#th_location').text(advanced
? label_zone + ' / ' + label_aisle + ' / ' + label_rack
: label_rack
);
}
function format_location(row) {
if (advanced) {
return [row.zone, row.aisle, row.rack].filter(Boolean).join('-') || '<span class="text-muted">—</span>';
}
return row.rack || '<span class="text-muted">—</span>';
}
function load_company_setting() {
return ajax_request({
url: server_url + 'setting/api/engine/company_setting.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
onSuccess: function(res) {
var s = res.output || {};
advanced = s.advanced_location == 1;
label_rack = s.location_label_rack || (advanced ? 'Rack' : 'Location');
label_zone = s.location_label_zone || 'Zone';
label_aisle = s.location_label_aisle || 'Aisle';
apply_location_labels();
}
});
}
// ── Fetch ─────────────────────────────────────────────────────────────
function retrieve_product_lot() {
@@ -296,7 +331,7 @@
<td class="py-3">${format_date(row.date)}</td>
<td class="py-3">${type_badge[row.type] || row.type}</td>
<td class="py-3">${row.warehouse_name}</td>
<td class="py-3">${row.zone}-${row.aisle}-${row.rack}</td>
<td class="py-3">${format_location(row)}</td>
<td class="py-3 text-success">${parseFloat(row.stock_in) > 0 ? '+' + Number(row.stock_in).toLocaleString() : '<span class="text-muted">—</span>'}</td>
<td class="py-3 text-danger">${parseFloat(row.stock_out) > 0 ? '-' + Number(row.stock_out).toLocaleString() : '<span class="text-muted">—</span>'}</td>
<td class="py-3">${row.serial_number || '<span class="text-muted">—</span>'}</td>
@@ -356,6 +391,7 @@
// ── Boot ─────────────────────────────────────────────────────────────
$(async function() {
try {
await load_company_setting();
await retrieve_product_lot();
} catch(e) {
console.log(e);
+46 -4
View File
@@ -134,7 +134,7 @@
<th>Type</th>
<th>SKU</th>
<th>Lot</th>
<th>Location</th>
<th id="th_location">Location</th>
<th>Contact</th>
<th class="text-end">Stock In</th>
<th class="text-end">Stock Out</th>
@@ -173,6 +173,10 @@
var sku_index = 0; // next SKU to load
var is_loading = false;
var search_params = {}; // warehouse_id, from_month, to_month — fixed per search
var advanced = false;
var label_rack = 'Location';
var label_zone = 'Zone';
var label_aisle = 'Aisle';
var type_badge = {
'in': '<span class="badge bg-success">In</span>',
@@ -180,6 +184,41 @@
'transfer': '<span class="badge bg-info text-dark">Transfer</span>',
};
function apply_location_labels() {
$('#th_location').text(advanced
? label_zone + ' / ' + label_aisle + ' / ' + label_rack
: label_rack
);
}
function format_location(row) {
if (advanced) {
return [row.zone, row.aisle, row.rack].filter(Boolean).join('-');
}
return row.rack || '';
}
function display_location(row) {
return format_location(row) || '<span class="text-muted">—</span>';
}
function load_company_setting() {
return ajax_request({
url: server_url + 'setting/api/engine/company_setting.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
onSuccess: function(res) {
var s = res.output || {};
advanced = s.advanced_location == 1;
label_rack = s.location_label_rack || (advanced ? 'Rack' : 'Location');
label_zone = s.location_label_zone || 'Zone';
label_aisle = s.location_label_aisle || 'Aisle';
apply_location_labels();
}
});
}
// ── Default months ────────────────────────────────────────────────────
(function() {
var now = new Date();
@@ -339,7 +378,7 @@
date: row.date,
type: row.type,
lot: row.lot_number || '',
location: row.zone + '-' + row.aisle + '-' + row.rack,
location: format_location(row),
contact: row.contact_name || '',
stock_in: row.stock_in > 0 ? row.stock_in : '',
stock_out: row.stock_out > 0 ? row.stock_out : '',
@@ -382,7 +421,7 @@
<td class="py-2">${type_badge[row.type] || row.type}</td>
<td class="py-2"><code>${row.product_sku}</code></td>
<td class="py-2">${row.lot_number || '<span class="text-muted">—</span>'}</td>
<td class="py-2">${row.zone}-${row.aisle}-${row.rack}</td>
<td class="py-2">${display_location(row)}</td>
<td class="py-2">${row.contact_name || '<span class="text-muted">—</span>'}</td>
<td class="py-2 text-end text-success">${in_val}</td>
<td class="py-2 text-end text-danger">${out_val}</td>
@@ -474,7 +513,10 @@
$(async function() {
try { await retrieve_warehouse(); } catch(e) { console.log(e); }
try {
await load_company_setting();
await retrieve_warehouse();
} catch(e) { console.log(e); }
});
</script>
+1 -1
View File
@@ -3,5 +3,5 @@ session_start();
require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes/CompanySettingManager.php';
$csm = new CompanySettingManager($pdo1, $company_id);
$csm = new CompanySettingManager($pdo1, $company_id, $pdo2);
$csm->handle($data);
+225 -2
View File
@@ -23,6 +23,96 @@
</div>
</div>
<!-- Warehouse Location Settings -->
<div class="row">
<div class="col-12 col-lg-7">
<div class="card border-0 shadow-sm mb-5">
<div class="card-header bg-transparent border-bottom">
<h6 class="mb-0"><i class="ti ti-map-pin me-2"></i>Warehouse Location Settings</h6>
</div>
<div class="card-body">
<div class="mb-4">
<label class="form-label fw-semibold">Advanced Location</label>
<p class="text-muted small mb-2">
By default, each storage location uses a single <strong>Location</strong> field —
ideal for small warehouses. Enable <strong>Advanced</strong> to use a three-level
hierarchy (e.g. Zone → Aisle → Rack) for larger operations.
You can customise the label for each level to match your team's terminology.
</p>
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" id="advanced_location_toggle" value="1">
<label class="form-check-label" for="advanced_location_toggle">
Enable Advanced Location
</label>
</div>
<!-- Simple mode — single label -->
<div id="location_simple">
<div class="row g-3 align-items-center">
<div class="col-auto">
<label class="col-form-label text-muted small">Location Label</label>
</div>
<div class="col-lg-4">
<input type="text" id="location_label_rack_simple" class="form-control form-control-sm"
placeholder="Location" maxlength="30">
</div>
<div class="col-auto">
<span class="text-muted small">default: "Location"</span>
</div>
</div>
</div>
<!-- Advanced mode — three labels -->
<div id="location_advanced" class="d-none">
<div class="row g-3 align-items-center mb-2">
<div class="col-auto" style="width:100px">
<label class="col-form-label text-muted small">Level 1 Label</label>
</div>
<div class="col-lg-4">
<input type="text" id="location_label_zone" class="form-control form-control-sm"
placeholder="Zone" maxlength="30">
</div>
<div class="col-auto">
<span class="text-muted small">default: "Zone"</span>
</div>
</div>
<div class="row g-3 align-items-center mb-2">
<div class="col-auto" style="width:100px">
<label class="col-form-label text-muted small">Level 2 Label</label>
</div>
<div class="col-lg-4">
<input type="text" id="location_label_aisle" class="form-control form-control-sm"
placeholder="Aisle" maxlength="30">
</div>
<div class="col-auto">
<span class="text-muted small">default: "Aisle"</span>
</div>
</div>
<div class="row g-3 align-items-center">
<div class="col-auto" style="width:100px">
<label class="col-form-label text-muted small">Level 3 Label</label>
</div>
<div class="col-lg-4">
<input type="text" id="location_label_rack_advanced" class="form-control form-control-sm"
placeholder="Rack" maxlength="30">
</div>
<div class="col-auto">
<span class="text-muted small">default: "Rack"</span>
</div>
</div>
</div>
</div>
<button class="btn btn-primary" onclick="save_config();">
<i class="ti ti-device-floppy me-1"></i>Save
</button>
</div>
</div>
</div>
</div>
<!-- Stock Settings -->
<div class="row">
<div class="col-12 col-lg-7">
@@ -67,6 +157,48 @@
</div>
</div>
<!-- Order Fulfillment Settings -->
<div class="card border-0 shadow-sm mb-5">
<div class="card-header bg-transparent border-bottom">
<h6 class="mb-0"><i class="ti ti-truck me-2"></i>Order Fulfillment Settings</h6>
</div>
<div class="card-body">
<div class="mb-4">
<label class="form-label fw-semibold">Auto-Complete on Shipped</label>
<p class="text-muted small mb-2">
Controls whether an order is automatically marked as <strong>Completed</strong>
when its fulfillment status is set to <strong>Shipped</strong>.
When <strong>Manual</strong>, the order stays in Processing and staff
must mark it Completed themselves.
</p>
<div class="d-flex flex-column gap-2" id="auto_complete_on_ship_group">
<div class="form-check">
<input class="form-check-input" type="radio" name="auto_complete_on_ship"
id="complete_auto" value="1">
<label class="form-check-label" for="complete_auto">
<span class="fw-semibold">Auto</span>
<span class="text-muted ms-1 small">— order is completed automatically when shipped</span>
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="auto_complete_on_ship"
id="complete_manual" value="0">
<label class="form-check-label" for="complete_manual">
<span class="fw-semibold">Manual</span>
<span class="text-muted ms-1 small">— staff mark the order as completed manually</span>
</label>
</div>
</div>
</div>
<button class="btn btn-primary" onclick="save_config();">
<i class="ti ti-device-floppy me-1"></i>Save
</button>
</div>
</div>
<!-- Invoice & Credit Note Settings -->
<div class="card border-0 shadow-sm">
<div class="card-header bg-transparent border-bottom">
@@ -127,32 +259,123 @@
action: 'read',
onSuccess: function(res) {
var s = res.output;
// Location settings
var advanced = (s.advanced_location ?? 0) == 1;
$('#advanced_location_toggle').prop('checked', advanced);
toggle_location_mode(advanced);
$('#location_label_rack_simple').val(s.location_label_rack || '');
$('#location_label_zone').val(s.location_label_zone || '');
$('#location_label_aisle').val(s.location_label_aisle || '');
$('#location_label_rack_advanced').val(s.location_label_rack || '');
// Other settings
$('input[name="default_stock_status"][value="' + (s.default_stock_status ?? 1) + '"]').prop('checked', true);
$('input[name="auto_complete_on_ship"][value="' + (s.auto_complete_on_ship ?? 1) + '"]').prop('checked', true);
$('input[name="auto_invoice_and_credit_note"][value="' + (s.auto_invoice_and_credit_note ?? 0) + '"]').prop('checked', true);
apply_setting_locks(res.locked || {});
}
});
// Toggle advanced/simple panels on switch change
$('#advanced_location_toggle').on('change', function() {
toggle_location_mode($(this).is(':checked'));
});
});
function toggle_location_mode(advanced) {
if (advanced) {
$('#location_simple').addClass('d-none');
$('#location_advanced').removeClass('d-none');
} else {
$('#location_simple').removeClass('d-none');
$('#location_advanced').addClass('d-none');
}
}
function apply_setting_locks(locked) {
set_setting_lock(
'advanced_location',
'#advanced_location_toggle',
locked.advanced_location,
'Advanced Location cannot be changed because stock transactions already exist. Location labels can still be renamed.'
);
set_setting_lock(
'default_stock_status',
'input[name="default_stock_status"]',
locked.default_stock_status,
'Default Stock Status cannot be changed because stock transactions already exist.'
);
set_setting_lock(
'auto_complete_on_ship',
'input[name="auto_complete_on_ship"]',
locked.auto_complete_on_ship,
'Auto-Complete on Shipped cannot be changed because order, invoice, or return transactions already exist.'
);
set_setting_lock(
'auto_invoice_and_credit_note',
'input[name="auto_invoice_and_credit_note"]',
locked.auto_invoice_and_credit_note,
'Invoice & Credit Note Generation cannot be changed because order, invoice, or return transactions already exist.'
);
}
function set_setting_lock(key, selector, is_locked, message) {
var $inputs = $(selector);
$inputs.prop('disabled', !!is_locked);
var $container = $inputs.first().closest('.mb-4');
$container.find('.setting-lock-note[data-lock-key="' + key + '"]').remove();
if (is_locked) {
$container.append(
'<div class="setting-lock-note text-warning small mt-2" data-lock-key="' + key + '">' +
'<i class="ti ti-lock me-1"></i>' + message +
'</div>'
);
}
}
// ── Save ──────────────────────────────────────────────────────────────────
function save_config() {
var advanced = $('#advanced_location_toggle').is(':checked') ? 1 : 0;
var stock_status = $('input[name="default_stock_status"]:checked').val();
var auto_invoice_and_cn = $('input[name="auto_invoice_and_credit_note"]:checked').val();
var auto_complete_on_ship = $('input[name="auto_complete_on_ship"]:checked').val();
if (stock_status === undefined || auto_invoice_and_cn === undefined) {
if (stock_status === undefined || auto_invoice_and_cn === undefined || auto_complete_on_ship === undefined) {
bootbox.alert('Please select all settings before saving.');
return;
}
// Resolve label values — fall back to defaults if blank
var label_rack, label_zone, label_aisle;
if (advanced) {
label_zone = $('#location_label_zone').val().trim() || 'Zone';
label_aisle = $('#location_label_aisle').val().trim() || 'Aisle';
label_rack = $('#location_label_rack_advanced').val().trim() || 'Rack';
} else {
label_rack = $('#location_label_rack_simple').val().trim() || 'Location';
label_zone = label_rack;
label_aisle = label_rack;
}
ajax_request({
url: server_url + 'setting/api/engine/company_setting.php',
autoPrepare: true,
checkRequired: 0,
action: 'update',
data: {
advanced_location: advanced,
location_label_zone: label_zone,
location_label_aisle: label_aisle,
location_label_rack: label_rack,
default_stock_status: stock_status,
auto_invoice_and_credit_note: auto_invoice_and_cn,
auto_complete_on_ship: auto_complete_on_ship,
},
onSuccess: function(res) {
onSuccess: function() {
bootbox.alert('Settings saved.');
}
});