implement JS password scoring, td_stock approve logicc
This commit is contained in:
+28
-2
@@ -1,3 +1,27 @@
|
|||||||
|
/** =========================
|
||||||
|
* UTILITIES
|
||||||
|
* ========================= */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a debounced version of fn that delays invoking until
|
||||||
|
* after `wait` ms have elapsed since the last call.
|
||||||
|
*/
|
||||||
|
function debounce(fn, wait) {
|
||||||
|
wait = wait || 300;
|
||||||
|
var timer;
|
||||||
|
return function (event) {
|
||||||
|
var ctx = this;
|
||||||
|
var args = arguments;
|
||||||
|
// Persist the jQuery event so it's accessible after the timeout
|
||||||
|
if (event && event.persist) event.persist();
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(function () {
|
||||||
|
fn.apply(ctx, args);
|
||||||
|
}, wait);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/** =========================
|
/** =========================
|
||||||
* DEFAULT ON LOAD FUNCTION
|
* DEFAULT ON LOAD FUNCTION
|
||||||
* ========================= */
|
* ========================= */
|
||||||
@@ -355,9 +379,11 @@ function prepare_form_data(check_required, raw_data) {
|
|||||||
q[key] = value;
|
q[key] = value;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Collect form inputs
|
// Collect form inputs (exclude search inputs — UI-only filters, not API data)
|
||||||
$(".form-control, .form-select").each(function () {
|
$(".form-control:not([type=search]), .form-select").each(function () {
|
||||||
if (!$(this).attr("id")) return true;
|
if (!$(this).attr("id")) return true;
|
||||||
|
var el = $(this).get(0);
|
||||||
|
if (!el || !el.nodeName) return true;
|
||||||
q[$(this).attr("id")] = $(this).val();
|
q[$(this).attr("id")] = $(this).val();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* CompanySettingManager — per-company key/value configuration store.
|
||||||
|
*
|
||||||
|
* 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
|
||||||
|
*
|
||||||
|
* Designed to accept new keys without schema changes.
|
||||||
|
*/
|
||||||
|
class CompanySettingManager
|
||||||
|
{
|
||||||
|
private PDO $pdo;
|
||||||
|
private int $companyId;
|
||||||
|
|
||||||
|
// ── Known keys with their defaults ───────────────────────────────────────
|
||||||
|
private const DEFAULTS = [
|
||||||
|
'default_stock_status' => 1, // auto-approve by default
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct(PDO $pdo, int $companyId)
|
||||||
|
{
|
||||||
|
$this->pdo = $pdo;
|
||||||
|
$this->companyId = $companyId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Public API
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the value for a single key, or the default if not configured.
|
||||||
|
*
|
||||||
|
* @param string $key Setting key.
|
||||||
|
* @return mixed Stored value cast to int, or the default.
|
||||||
|
*/
|
||||||
|
public function get(string $key): mixed
|
||||||
|
{
|
||||||
|
$sth = $this->pdo->prepare(
|
||||||
|
"SELECT value FROM company_setting
|
||||||
|
WHERE company_id = :company_id AND setting_key = :setting_key
|
||||||
|
LIMIT 1"
|
||||||
|
);
|
||||||
|
$sth->execute([':company_id' => $this->companyId, ':setting_key' => $key]);
|
||||||
|
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
if ($row === false) {
|
||||||
|
return self::DEFAULTS[$key] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cast numeric-looking values to int for clean comparisons
|
||||||
|
return is_numeric($row['value']) ? (int)$row['value'] : $row['value'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return all settings for this company, merged with defaults.
|
||||||
|
*
|
||||||
|
* @return array Associative array of key => value.
|
||||||
|
*/
|
||||||
|
public function getAll(): array
|
||||||
|
{
|
||||||
|
$sth = $this->pdo->prepare(
|
||||||
|
"SELECT setting_key, value FROM company_setting
|
||||||
|
WHERE company_id = :company_id"
|
||||||
|
);
|
||||||
|
$sth->execute([':company_id' => $this->companyId]);
|
||||||
|
$rows = $sth->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||||
|
|
||||||
|
// Merge stored values over defaults, cast numeric values
|
||||||
|
$result = self::DEFAULTS;
|
||||||
|
foreach ($rows as $k => $v) {
|
||||||
|
$result[$k] = is_numeric($v) ? (int)$v : $v;
|
||||||
|
}
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upsert a single setting value.
|
||||||
|
*
|
||||||
|
* @param string $key
|
||||||
|
* @param mixed $value
|
||||||
|
*/
|
||||||
|
public function set(string $key, mixed $value): void
|
||||||
|
{
|
||||||
|
// Fetch existing log to append to it
|
||||||
|
$sth = $this->pdo->prepare(
|
||||||
|
"SELECT log FROM company_setting
|
||||||
|
WHERE company_id = :company_id AND setting_key = :setting_key
|
||||||
|
LIMIT 1"
|
||||||
|
);
|
||||||
|
$sth->execute([':company_id' => $this->companyId, ':setting_key' => $key]);
|
||||||
|
$existing_log = json_decode($sth->fetchColumn() ?: '[]', true) ?: [];
|
||||||
|
$existing_log[] = [
|
||||||
|
'user_id' => $_SESSION['login_user_id'] ?? null,
|
||||||
|
'dt' => date('Y-m-d H:i:s'),
|
||||||
|
'value' => $value,
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->pdo->prepare(
|
||||||
|
"INSERT INTO company_setting (company_id, setting_key, value, log, updated_at)
|
||||||
|
VALUES (:company_id, :setting_key, :value, :log, NOW())
|
||||||
|
ON DUPLICATE KEY UPDATE value = VALUES(value), log = VALUES(log), updated_at = NOW()"
|
||||||
|
)->execute([
|
||||||
|
':company_id' => $this->companyId,
|
||||||
|
':setting_key' => $key,
|
||||||
|
':value' => $value,
|
||||||
|
':log' => json_encode($existing_log),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP handler — read or update settings via AJAX.
|
||||||
|
*
|
||||||
|
* action: 'read' → return all settings
|
||||||
|
* action: 'update' → upsert one or more keys from $data
|
||||||
|
*
|
||||||
|
* @param array $data Request data array.
|
||||||
|
*/
|
||||||
|
public function handle(array $data): void
|
||||||
|
{
|
||||||
|
$action = $data['action'] ?? '';
|
||||||
|
|
||||||
|
if ($action === 'read') {
|
||||||
|
echo json_encode(['success' => 1, 'output' => $this->getAll()]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'update') {
|
||||||
|
$allowed = array_keys(self::DEFAULTS);
|
||||||
|
foreach ($allowed as $key) {
|
||||||
|
$this->set($key, $data[$key] ?? self::DEFAULTS[$key]);
|
||||||
|
}
|
||||||
|
echo json_encode(['success' => 1, 'message' => 'Settings saved.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => 0, 'message' => 'Invalid action.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
*
|
*
|
||||||
* Method order:
|
* Method order:
|
||||||
* Core public API → checkStrength, change, forceSet
|
* Core public API → checkStrength, change, forceSet
|
||||||
* HTTP handlers → handleCheck, handleChange (thin wrappers for AJAX endpoints)
|
* HTTP handler → handleChange (thin wrapper for AJAX endpoint)
|
||||||
* Private helpers → loadZxcvbn, fetchUser, enforceStrength, persist
|
* Private helpers → loadZxcvbn, fetchUser, enforceStrength, persist
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
@@ -49,7 +49,7 @@ class PasswordManager {
|
|||||||
*/
|
*/
|
||||||
public function __construct($pdo, string $include_url) {
|
public function __construct($pdo, string $include_url) {
|
||||||
$this->pdo = $pdo;
|
$this->pdo = $pdo;
|
||||||
$this->zxcvbn_path = rtrim($include_url, '/') . '/assets/zxcvbn-php-master/vendor/autoload.php';
|
$this->zxcvbn_path = rtrim($include_url, '/') . '/../lib/zxcvbn-php-master/vendor/autoload.php';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
@@ -166,44 +166,9 @@ class PasswordManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
// HTTP handlers (thin AJAX endpoint wrappers)
|
// HTTP handler (thin AJAX endpoint wrapper)
|
||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle an AJAX strength-check request and echo a JSON response.
|
|
||||||
*
|
|
||||||
* Called by: setting/api/engine/check_password.php
|
|
||||||
*
|
|
||||||
* Expected $data keys:
|
|
||||||
* password (string) — the password string to analyse
|
|
||||||
*
|
|
||||||
* Response JSON keys:
|
|
||||||
* success (int 1), score (int -1 if empty, else 0–4), feedback (string)
|
|
||||||
*
|
|
||||||
* Outputs JSON and calls exit. Safe against XSS — all output via json_encode.
|
|
||||||
*
|
|
||||||
* @param array $data Request data array (typically from $_POST or decoded JSON body).
|
|
||||||
*/
|
|
||||||
public function handleCheck(array $data): void {
|
|
||||||
|
|
||||||
$password = $data['password'] ?? '';
|
|
||||||
|
|
||||||
if (empty($password)) {
|
|
||||||
echo json_encode(['success' => 1, 'score' => -1, 'feedback' => '']);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
$result = $this->checkStrength($password);
|
|
||||||
$feedback = $result['warning'] ?: ($result['suggestions'][0] ?? '');
|
|
||||||
|
|
||||||
echo json_encode([
|
|
||||||
'success' => 1,
|
|
||||||
'score' => $result['score'],
|
|
||||||
'feedback' => $feedback,
|
|
||||||
]);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle an AJAX change-password request and echo a JSON response.
|
* Handle an AJAX change-password request and echo a JSON response.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -25,18 +25,20 @@
|
|||||||
* - getRecentActivity: warehouse_name in SQL now uses $safe (was unescaped)
|
* - getRecentActivity: warehouse_name in SQL now uses $safe (was unescaped)
|
||||||
* - getExpiredStock: warehouse_id cast to (int) before SQL fragment injection;
|
* - getExpiredStock: warehouse_id cast to (int) before SQL fragment injection;
|
||||||
* warehouse_name in UNION now uses $safe (was unescaped)
|
* warehouse_name in UNION now uses $safe (was unescaped)
|
||||||
* - getRackLog: DB name from SELECT DATABASE() bound via PDO (was raw interpolation)
|
* - getRackLog: cross-DB join uses $mainDb injected at construction time
|
||||||
* - getRackOccupancy: (already safe — no dynamic identifiers)
|
* - getRackOccupancy: (already safe — no dynamic identifiers)
|
||||||
*/
|
*/
|
||||||
class ReportManager
|
class ReportManager
|
||||||
{
|
{
|
||||||
private PDO $pdo;
|
private PDO $pdo;
|
||||||
private int $companyId;
|
private int $companyId;
|
||||||
|
private string $mainDb;
|
||||||
|
|
||||||
public function __construct(PDO $pdo, int $companyId)
|
public function __construct(PDO $pdo, int $companyId, string $mainDb = '')
|
||||||
{
|
{
|
||||||
$this->pdo = $pdo;
|
$this->pdo = $pdo;
|
||||||
$this->companyId = $companyId;
|
$this->companyId = $companyId;
|
||||||
|
$this->mainDb = $mainDb;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
@@ -600,7 +602,8 @@ class ReportManager
|
|||||||
LEFT JOIN md_product p
|
LEFT JOIN md_product p
|
||||||
ON p.company_id = s.company_id
|
ON p.company_id = s.company_id
|
||||||
AND p.sku = s.product_sku
|
AND p.sku = s.product_sku
|
||||||
WHERE s.company_id = {$cid}";
|
WHERE s.company_id = {$cid}
|
||||||
|
AND s.status = 1";
|
||||||
},
|
},
|
||||||
$warehouses
|
$warehouses
|
||||||
));
|
));
|
||||||
@@ -740,7 +743,8 @@ class ReportManager
|
|||||||
ROUND(SUM(`out`), 2) AS stock_out
|
ROUND(SUM(`out`), 2) AS stock_out
|
||||||
FROM `{$table}`
|
FROM `{$table}`
|
||||||
WHERE company_id = :company_id
|
WHERE company_id = :company_id
|
||||||
AND date >= :start AND date <= :end
|
AND status = 1
|
||||||
|
AND date >= :start AND date <= :end
|
||||||
GROUP BY sort_key, label
|
GROUP BY sort_key, label
|
||||||
ORDER BY sort_key ASC"
|
ORDER BY sort_key ASC"
|
||||||
);
|
);
|
||||||
@@ -790,7 +794,7 @@ class ReportManager
|
|||||||
$sth = $this->pdo->prepare(
|
$sth = $this->pdo->prepare(
|
||||||
"SELECT ROUND(COALESCE(SUM(`in`) - SUM(`out`), 0), 2) AS balance
|
"SELECT ROUND(COALESCE(SUM(`in`) - SUM(`out`), 0), 2) AS balance
|
||||||
FROM `{$table}`
|
FROM `{$table}`
|
||||||
WHERE company_id = :company_id AND date < :start"
|
WHERE company_id = :company_id AND status = 1 AND date < :start"
|
||||||
);
|
);
|
||||||
$sth->execute([':company_id' => $this->companyId, ':start' => $start]);
|
$sth->execute([':company_id' => $this->companyId, ':start' => $start]);
|
||||||
$running = (float)$sth->fetchColumn();
|
$running = (float)$sth->fetchColumn();
|
||||||
@@ -801,7 +805,8 @@ class ReportManager
|
|||||||
ROUND(SUM(`out`), 2) AS stock_out
|
ROUND(SUM(`out`), 2) AS stock_out
|
||||||
FROM `{$table}`
|
FROM `{$table}`
|
||||||
WHERE company_id = :company_id
|
WHERE company_id = :company_id
|
||||||
AND date >= :start AND date <= :end
|
AND status = 1
|
||||||
|
AND date >= :start AND date <= :end
|
||||||
GROUP BY sort_key
|
GROUP BY sort_key
|
||||||
ORDER BY sort_key ASC"
|
ORDER BY sort_key ASC"
|
||||||
);
|
);
|
||||||
@@ -939,7 +944,8 @@ class ReportManager
|
|||||||
FROM `td_stock_{$safe}` s
|
FROM `td_stock_{$safe}` s
|
||||||
WHERE s.company_id = {$cid}
|
WHERE s.company_id = {$cid}
|
||||||
AND s.type = 'in'
|
AND s.type = 'in'
|
||||||
AND s.lot_number IS NOT NULL";
|
AND s.lot_number IS NOT NULL
|
||||||
|
AND s.status = 1";
|
||||||
},
|
},
|
||||||
$warehouses
|
$warehouses
|
||||||
));
|
));
|
||||||
@@ -1058,6 +1064,7 @@ class ReportManager
|
|||||||
WHERE s.company_id = :company_id
|
WHERE s.company_id = :company_id
|
||||||
AND s.product_sku = :product_sku
|
AND s.product_sku = :product_sku
|
||||||
AND s.lot_number = :lot_number
|
AND s.lot_number = :lot_number
|
||||||
|
AND s.status = 1
|
||||||
ORDER BY s.date DESC"
|
ORDER BY s.date DESC"
|
||||||
);
|
);
|
||||||
$sth->execute([
|
$sth->execute([
|
||||||
@@ -1089,6 +1096,37 @@ class ReportManager
|
|||||||
*/
|
*/
|
||||||
public function getProductLots(): array
|
public function getProductLots(): array
|
||||||
{
|
{
|
||||||
|
$warehouses = $this->pdo->prepare(
|
||||||
|
"SELECT id, warehouse_name FROM md_warehouse
|
||||||
|
WHERE company_id = :company_id AND status = 1"
|
||||||
|
);
|
||||||
|
$warehouses->execute([':company_id' => $this->companyId]);
|
||||||
|
$wh_list = $warehouses->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
// Build per-lot balance by summing approved td_stock rows across all warehouses.
|
||||||
|
// warehouse_balance is per-product, not per-lot, so we query td_stock directly.
|
||||||
|
$lot_balance = [];
|
||||||
|
$cid = (int) $this->companyId;
|
||||||
|
|
||||||
|
foreach ($wh_list as $wh) {
|
||||||
|
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $wh['warehouse_name']);
|
||||||
|
$table = "td_stock_{$safe}";
|
||||||
|
|
||||||
|
$sth = $this->pdo->query(
|
||||||
|
"SELECT lot_number,
|
||||||
|
ROUND(SUM(COALESCE(`in`, 0)) - SUM(COALESCE(`out`, 0)), 2) AS lot_balance
|
||||||
|
FROM `{$table}`
|
||||||
|
WHERE company_id = {$cid}
|
||||||
|
AND status = 1
|
||||||
|
AND lot_number IS NOT NULL
|
||||||
|
GROUP BY lot_number"
|
||||||
|
);
|
||||||
|
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $lb) {
|
||||||
|
$key = $lb['lot_number'];
|
||||||
|
$lot_balance[$key] = ($lot_balance[$key] ?? 0) + (float)$lb['lot_balance'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$sth = $this->pdo->prepare(
|
$sth = $this->pdo->prepare(
|
||||||
"SELECT
|
"SELECT
|
||||||
l.id,
|
l.id,
|
||||||
@@ -1097,14 +1135,7 @@ class ReportManager
|
|||||||
l.expiry_date,
|
l.expiry_date,
|
||||||
l.description,
|
l.description,
|
||||||
p.product_name,
|
p.product_name,
|
||||||
DATEDIFF(l.expiry_date, CURDATE()) AS days_remaining,
|
DATEDIFF(l.expiry_date, CURDATE()) AS days_remaining
|
||||||
COALESCE(
|
|
||||||
(SELECT ROUND(SUM(wb.total_in) - SUM(wb.total_out), 2)
|
|
||||||
FROM warehouse_balance wb
|
|
||||||
WHERE wb.company_id = l.company_id
|
|
||||||
AND wb.product_sku = l.product_sku),
|
|
||||||
0
|
|
||||||
) AS balance
|
|
||||||
FROM md_lot l
|
FROM md_lot l
|
||||||
INNER JOIN md_product p
|
INNER JOIN md_product p
|
||||||
ON p.company_id = l.company_id
|
ON p.company_id = l.company_id
|
||||||
@@ -1117,18 +1148,24 @@ class ReportManager
|
|||||||
|
|
||||||
$active = $expired = $near = 0;
|
$active = $expired = $near = 0;
|
||||||
|
|
||||||
|
// Exclude lots that have no td_stock record at all (e.g. all rows were soft-deleted)
|
||||||
|
$rows = array_values(array_filter($rows, fn($r) => array_key_exists($r['lot_number'], $lot_balance)));
|
||||||
|
|
||||||
foreach ($rows as &$row) {
|
foreach ($rows as &$row) {
|
||||||
$days = (int)$row['days_remaining'];
|
$days = (int)$row['days_remaining'];
|
||||||
$balance = (float)$row['balance'];
|
$balance = round($lot_balance[$row['lot_number']] ?? 0, 2);
|
||||||
|
|
||||||
|
$row['balance'] = $balance;
|
||||||
|
$row['is_active'] = $balance > 0 ? 1 : 0;
|
||||||
|
|
||||||
if ($balance > 0) $active++;
|
if ($balance > 0) $active++;
|
||||||
if ($days < 0) $expired++;
|
if ($days < 0) $expired++;
|
||||||
if ($days >= 0 && $days <= 30) $near++;
|
if ($days >= 0 && $days <= 30) $near++;
|
||||||
|
|
||||||
if ($days < 0) $row['status'] = 'expired';
|
if ($days < 0) $row['status'] = 'expired';
|
||||||
elseif ($days <= 7) $row['status'] = 'critical';
|
elseif ($days <= 7) $row['status'] = 'critical';
|
||||||
elseif ($days <= 30) $row['status'] = 'near';
|
elseif ($days <= 30) $row['status'] = 'near';
|
||||||
else $row['status'] = 'ok';
|
else $row['status'] = 'ok';
|
||||||
}
|
}
|
||||||
unset($row);
|
unset($row);
|
||||||
|
|
||||||
@@ -1158,9 +1195,7 @@ class ReportManager
|
|||||||
{
|
{
|
||||||
if (!$rack_id) return [];
|
if (!$rack_id) return [];
|
||||||
|
|
||||||
// Fetch DB name safely — used as a backtick-quoted identifier, not user input
|
$main_db = preg_replace('/[^a-zA-Z0-9_]/', '', $this->mainDb);
|
||||||
$db_name = $this->pdo->query("SELECT DATABASE()")->fetchColumn();
|
|
||||||
$db_name = preg_replace('/[^a-zA-Z0-9_]/', '', (string)$db_name);
|
|
||||||
|
|
||||||
$sth = $this->pdo->prepare(
|
$sth = $this->pdo->prepare(
|
||||||
"SELECT
|
"SELECT
|
||||||
@@ -1172,7 +1207,7 @@ class ReportManager
|
|||||||
rl.login,
|
rl.login,
|
||||||
u.name AS user_name
|
u.name AS user_name
|
||||||
FROM md_rack_log rl
|
FROM md_rack_log rl
|
||||||
LEFT JOIN `{$db_name}`.user u
|
LEFT JOIN `{$main_db}`.user u
|
||||||
ON u.user_id = rl.user_id
|
ON u.user_id = rl.user_id
|
||||||
WHERE rl.company_id = :company_id
|
WHERE rl.company_id = :company_id
|
||||||
AND rl.md_rack_id = :rack_id
|
AND rl.md_rack_id = :rack_id
|
||||||
@@ -1299,6 +1334,7 @@ class ReportManager
|
|||||||
"SELECT DISTINCT product_sku
|
"SELECT DISTINCT product_sku
|
||||||
FROM `{$table}`
|
FROM `{$table}`
|
||||||
WHERE company_id = :company_id
|
WHERE company_id = :company_id
|
||||||
|
AND status = 1
|
||||||
AND date BETWEEN :date_from AND :date_to
|
AND date BETWEEN :date_from AND :date_to
|
||||||
ORDER BY product_sku ASC"
|
ORDER BY product_sku ASC"
|
||||||
);
|
);
|
||||||
@@ -1313,6 +1349,7 @@ class ReportManager
|
|||||||
ROUND(SUM(COALESCE(`out`, 0)), 2) AS total_out
|
ROUND(SUM(COALESCE(`out`, 0)), 2) AS total_out
|
||||||
FROM `{$table}`
|
FROM `{$table}`
|
||||||
WHERE company_id = :company_id
|
WHERE company_id = :company_id
|
||||||
|
AND status = 1
|
||||||
AND date BETWEEN :date_from AND :date_to"
|
AND date BETWEEN :date_from AND :date_to"
|
||||||
);
|
);
|
||||||
$sth->execute([':company_id' => $this->companyId, ':date_from' => $date_from, ':date_to' => $date_to]);
|
$sth->execute([':company_id' => $this->companyId, ':date_from' => $date_from, ':date_to' => $date_to]);
|
||||||
@@ -1366,6 +1403,7 @@ class ReportManager
|
|||||||
FROM `{$table}`
|
FROM `{$table}`
|
||||||
WHERE company_id = :company_id
|
WHERE company_id = :company_id
|
||||||
AND product_sku = :sku
|
AND product_sku = :sku
|
||||||
|
AND status = 1
|
||||||
AND date < :date_from"
|
AND date < :date_from"
|
||||||
);
|
);
|
||||||
$sth->execute([':company_id' => $this->companyId, ':sku' => $product_sku, ':date_from' => $date_from]);
|
$sth->execute([':company_id' => $this->companyId, ':sku' => $product_sku, ':date_from' => $date_from]);
|
||||||
@@ -1404,6 +1442,7 @@ class ReportManager
|
|||||||
AND c.id = s.contact_id
|
AND c.id = s.contact_id
|
||||||
WHERE s.company_id = :company_id
|
WHERE s.company_id = :company_id
|
||||||
AND s.product_sku = :sku
|
AND s.product_sku = :sku
|
||||||
|
AND s.status = 1
|
||||||
AND s.date BETWEEN :date_from AND :date_to
|
AND s.date BETWEEN :date_from AND :date_to
|
||||||
ORDER BY s.date ASC, s.id ASC"
|
ORDER BY s.date ASC, s.id ASC"
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -252,7 +252,7 @@ class StockManager {
|
|||||||
* @param array $logging Audit entry to append to the log column.
|
* @param array $logging Audit entry to append to the log column.
|
||||||
* @param string $uuid UUID for this transaction (shared across transfer pairs).
|
* @param string $uuid UUID for this transaction (shared across transfer pairs).
|
||||||
*/
|
*/
|
||||||
public function saveStockIn(array $data, array $logging, string $uuid): void
|
public function saveStockIn(array $data, array $logging, string $uuid): int
|
||||||
{
|
{
|
||||||
$id = (int)($data['id'] ?? 0);
|
$id = (int)($data['id'] ?? 0);
|
||||||
$warehouse_id = (int)($data['warehouse'] ?? 0);
|
$warehouse_id = (int)($data['warehouse'] ?? 0);
|
||||||
@@ -282,6 +282,8 @@ class StockManager {
|
|||||||
':log' => json_encode($table_log),
|
':log' => json_encode($table_log),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
return 0; // update — no new row
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
|
|
||||||
$lot_number = $data['lot_number'] ?: null;
|
$lot_number = $data['lot_number'] ?: null;
|
||||||
@@ -304,10 +306,10 @@ class StockManager {
|
|||||||
$this->pdo->prepare(
|
$this->pdo->prepare(
|
||||||
"INSERT INTO `$table`
|
"INSERT INTO `$table`
|
||||||
(uuid, company_id, `date`, product_sku, `in`, zone, aisle, rack,
|
(uuid, company_id, `date`, product_sku, `in`, zone, aisle, rack,
|
||||||
contact_id, `description`, `log`, `type`, lot_number, serial_number)
|
contact_id, `description`, `log`, `type`, lot_number, serial_number, status)
|
||||||
VALUES
|
VALUES
|
||||||
(:uuid, :company_id, :date, :product_sku, :quantity, :zone, :aisle, :rack,
|
(:uuid, :company_id, :date, :product_sku, :quantity, :zone, :aisle, :rack,
|
||||||
:contact_id, :description, :log, 'in', :lot_number, :serial_number)"
|
:contact_id, :description, :log, 'in', :lot_number, :serial_number, 0)"
|
||||||
)->execute([
|
)->execute([
|
||||||
':uuid' => $uuid,
|
':uuid' => $uuid,
|
||||||
':company_id' => $this->company_id,
|
':company_id' => $this->company_id,
|
||||||
@@ -326,22 +328,8 @@ class StockManager {
|
|||||||
|
|
||||||
$td_stock_id = (int)$this->pdo->lastInsertId();
|
$td_stock_id = (int)$this->pdo->lastInsertId();
|
||||||
|
|
||||||
// Mark rack as occupied and link it to this stock row
|
// occupyRack and adjustBalance deferred — called from approveStock() only.
|
||||||
$whMgmt->occupyRack(
|
return $td_stock_id;
|
||||||
$warehouse_id,
|
|
||||||
$data['zone'], $data['aisle'], $data['rack'],
|
|
||||||
$data['product_sku'],
|
|
||||||
$td_stock_id
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update running balance (+quantity in this warehouse)
|
|
||||||
$whMgmt->adjustBalance(
|
|
||||||
'in',
|
|
||||||
$warehouse_id,
|
|
||||||
$data['product_sku'],
|
|
||||||
$row['in'] ?? 0,
|
|
||||||
$data['quantity']
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -365,7 +353,7 @@ class StockManager {
|
|||||||
* @param string $uuid UUID for this transaction.
|
* @param string $uuid UUID for this transaction.
|
||||||
* @throws Exception If the rack is empty, holds a different SKU/lot/serial.
|
* @throws Exception If the rack is empty, holds a different SKU/lot/serial.
|
||||||
*/
|
*/
|
||||||
public function saveStockOut(array $data, array $logging, string $uuid): void
|
public function saveStockOut(array $data, array $logging, string $uuid): int
|
||||||
{
|
{
|
||||||
$id = (int)($data['id'] ?? 0);
|
$id = (int)($data['id'] ?? 0);
|
||||||
$warehouse_id = (int)($data['warehouse'] ?? 0);
|
$warehouse_id = (int)($data['warehouse'] ?? 0);
|
||||||
@@ -395,6 +383,8 @@ class StockManager {
|
|||||||
':log' => json_encode($table_log),
|
':log' => json_encode($table_log),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
return 0; // update — no new row
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
|
|
||||||
// Validate rack holds the expected product / lot / serial
|
// Validate rack holds the expected product / lot / serial
|
||||||
@@ -436,10 +426,10 @@ class StockManager {
|
|||||||
$this->pdo->prepare(
|
$this->pdo->prepare(
|
||||||
"INSERT INTO `$table`
|
"INSERT INTO `$table`
|
||||||
(uuid, company_id, `date`, product_sku, `out`, zone, aisle, rack,
|
(uuid, company_id, `date`, product_sku, `out`, zone, aisle, rack,
|
||||||
contact_id, `description`, `log`, `type`, ref_id, lot_number, serial_number)
|
contact_id, `description`, `log`, `type`, ref_id, lot_number, serial_number, status)
|
||||||
VALUES
|
VALUES
|
||||||
(:uuid, :company_id, :date, :product_sku, :quantity, :zone, :aisle, :rack,
|
(:uuid, :company_id, :date, :product_sku, :quantity, :zone, :aisle, :rack,
|
||||||
:contact_id, :description, :log, 'out', :ref_id, :lot_number, :serial_number)"
|
:contact_id, :description, :log, 'out', :ref_id, :lot_number, :serial_number, 0)"
|
||||||
)->execute([
|
)->execute([
|
||||||
':uuid' => $uuid,
|
':uuid' => $uuid,
|
||||||
':company_id' => $this->company_id,
|
':company_id' => $this->company_id,
|
||||||
@@ -457,19 +447,10 @@ class StockManager {
|
|||||||
':serial_number' => $serial_number,
|
':serial_number' => $serial_number,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Release the source rack and reverse balance
|
$out_stock_id = (int)$this->pdo->lastInsertId();
|
||||||
$whMgmt->releaseRack(
|
|
||||||
$warehouse_id,
|
|
||||||
$data['zone'], $data['aisle'], $data['rack']
|
|
||||||
);
|
|
||||||
|
|
||||||
$whMgmt->adjustBalance(
|
// releaseRack and adjustBalance deferred — called from approveStock() only.
|
||||||
'out',
|
return $out_stock_id;
|
||||||
$warehouse_id,
|
|
||||||
$data['product_sku'],
|
|
||||||
$row['out'] ?? 0,
|
|
||||||
$quantity
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -500,7 +481,7 @@ class StockManager {
|
|||||||
* @param string $uuid UUID shared by both the from and to rows.
|
* @param string $uuid UUID shared by both the from and to rows.
|
||||||
* @throws Exception If source rack validation fails or paired record is missing on update.
|
* @throws Exception If source rack validation fails or paired record is missing on update.
|
||||||
*/
|
*/
|
||||||
public function saveStockTransfer(array $data, array $logging, string $uuid): void
|
public function saveStockTransfer(array $data, array $logging, string $uuid): int
|
||||||
{
|
{
|
||||||
$id = (int)($data['id'] ?? 0);
|
$id = (int)($data['id'] ?? 0);
|
||||||
$whMgmt = new WarehouseManager($this->pdo, $this->company_id);
|
$whMgmt = new WarehouseManager($this->pdo, $this->company_id);
|
||||||
@@ -564,7 +545,7 @@ class StockManager {
|
|||||||
':company_id' => $this->company_id,
|
':company_id' => $this->company_id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return;
|
return 0; // update — no new row
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert: validate source rack, then create paired rows
|
// Insert: validate source rack, then create paired rows
|
||||||
@@ -617,18 +598,18 @@ class StockManager {
|
|||||||
"INSERT INTO `$from_table`
|
"INSERT INTO `$from_table`
|
||||||
(uuid, company_id, `date`, product_sku, `out`,
|
(uuid, company_id, `date`, product_sku, `out`,
|
||||||
ref_warehouse, zone, aisle, rack,
|
ref_warehouse, zone, aisle, rack,
|
||||||
contact_id, `description`, `log`, `type`, lot_number, serial_number)
|
contact_id, `description`, `log`, `type`, lot_number, serial_number, status)
|
||||||
VALUES
|
VALUES
|
||||||
(:uuid, :company_id, :date, :product_sku, :quantity,
|
(:uuid, :company_id, :date, :product_sku, :quantity,
|
||||||
:ref_warehouse, :zone, :aisle, :rack,
|
:ref_warehouse, :zone, :aisle, :rack,
|
||||||
:contact_id, :description, :log, 'transfer', :lot_number, :serial_number)"
|
:contact_id, :description, :log, 'transfer', :lot_number, :serial_number, 0)"
|
||||||
)->execute([
|
)->execute([
|
||||||
':uuid' => $uuid,
|
':uuid' => $uuid,
|
||||||
':company_id' => $this->company_id,
|
':company_id' => $this->company_id,
|
||||||
':date' => date('Y-m-d H:i:s'),
|
':date' => date('Y-m-d H:i:s'),
|
||||||
':product_sku' => $data['product_sku'],
|
':product_sku' => $data['product_sku'],
|
||||||
':quantity' => $quantity,
|
':quantity' => $quantity,
|
||||||
':ref_warehouse' => $to_warehouse,
|
':ref_warehouse' => $whMgmt->getWarehouseName($to_warehouse),
|
||||||
':zone' => $from_zone,
|
':zone' => $from_zone,
|
||||||
':aisle' => $from_aisle,
|
':aisle' => $from_aisle,
|
||||||
':rack' => $from_rack,
|
':rack' => $from_rack,
|
||||||
@@ -645,18 +626,18 @@ class StockManager {
|
|||||||
"INSERT INTO `$to_table`
|
"INSERT INTO `$to_table`
|
||||||
(uuid, company_id, `date`, product_sku, `in`,
|
(uuid, company_id, `date`, product_sku, `in`,
|
||||||
ref_warehouse, ref_id, zone, aisle, rack,
|
ref_warehouse, ref_id, zone, aisle, rack,
|
||||||
contact_id, `description`, `log`, `type`, lot_number, serial_number)
|
contact_id, `description`, `log`, `type`, lot_number, serial_number, status)
|
||||||
VALUES
|
VALUES
|
||||||
(:uuid, :company_id, :date, :product_sku, :quantity,
|
(:uuid, :company_id, :date, :product_sku, :quantity,
|
||||||
:ref_warehouse, :ref_id, :zone, :aisle, :rack,
|
:ref_warehouse, :ref_id, :zone, :aisle, :rack,
|
||||||
:contact_id, :description, :log, 'transfer', :lot_number, :serial_number)"
|
:contact_id, :description, :log, 'transfer', :lot_number, :serial_number, 0)"
|
||||||
)->execute([
|
)->execute([
|
||||||
':uuid' => $uuid,
|
':uuid' => $uuid,
|
||||||
':company_id' => $this->company_id,
|
':company_id' => $this->company_id,
|
||||||
':date' => date('Y-m-d H:i:s'),
|
':date' => date('Y-m-d H:i:s'),
|
||||||
':product_sku' => $data['product_sku'],
|
':product_sku' => $data['product_sku'],
|
||||||
':quantity' => $quantity,
|
':quantity' => $quantity,
|
||||||
':ref_warehouse' => $from_warehouse,
|
':ref_warehouse' => $whMgmt->getWarehouseName($from_warehouse),
|
||||||
':ref_id' => $from_stock_id,
|
':ref_id' => $from_stock_id,
|
||||||
':zone' => $to_zone,
|
':zone' => $to_zone,
|
||||||
':aisle' => $to_aisle,
|
':aisle' => $to_aisle,
|
||||||
@@ -679,13 +660,160 @@ class StockManager {
|
|||||||
':company_id' => $this->company_id,
|
':company_id' => $this->company_id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Rack state: release source, occupy destination
|
// releaseRack, occupyRack and adjustBalance deferred — called from approveStock() only.
|
||||||
$whMgmt->releaseRack($from_warehouse, $from_zone, $from_aisle, $from_rack);
|
return $from_stock_id;
|
||||||
$whMgmt->occupyRack($to_warehouse, $to_zone, $to_aisle, $to_rack, $data['product_sku'], $to_stock_id);
|
}
|
||||||
|
|
||||||
// Balance: deduct from source, add to destination
|
// ─────────────────────────────────────────────────────────────
|
||||||
$whMgmt->adjustBalance('out', $from_warehouse, $data['product_sku'], 0, $quantity);
|
// Approve
|
||||||
$whMgmt->adjustBalance('in', $to_warehouse, $data['product_sku'], 0, $quantity);
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Approve a draft td_stock row (status 0 → 1) and update warehouse_balance.
|
||||||
|
*
|
||||||
|
* This is the single entry point for balance updates — both auto-approve
|
||||||
|
* (called immediately after save) and manual approve go through here.
|
||||||
|
* adjustBalance() is never called from anywhere else.
|
||||||
|
*
|
||||||
|
* For transfer rows, both the outbound (from) and inbound (to) rows share
|
||||||
|
* the same uuid. We approve both in one call so the pair is always consistent.
|
||||||
|
*
|
||||||
|
* @param int $id The td_stock row id.
|
||||||
|
* @param int $warehouse_id The warehouse the row belongs to.
|
||||||
|
* @param string $type 'in' | 'out' | 'transfer'
|
||||||
|
* @param WarehouseManager $whMgmt Injected to keep balance logic centralised.
|
||||||
|
* @throws Exception If the row is not found, already approved, or wrong company.
|
||||||
|
*/
|
||||||
|
public function approveStock(int $id, int $warehouse_id, string $type, WarehouseManager $whMgmt): void
|
||||||
|
{
|
||||||
|
$table = $this->resolveTable($warehouse_id);
|
||||||
|
|
||||||
|
// Load the row and validate ownership / status
|
||||||
|
$sth = $this->pdo->prepare(
|
||||||
|
"SELECT * FROM `{$table}`
|
||||||
|
WHERE id = :id AND company_id = :company_id
|
||||||
|
LIMIT 1"
|
||||||
|
);
|
||||||
|
$sth->execute([':id' => $id, ':company_id' => $this->company_id]);
|
||||||
|
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
if (!$row) {
|
||||||
|
throw new Exception('Stock record not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int)$row['status'] === 1) {
|
||||||
|
throw new Exception('Already approved.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Approve this row ──────────────────────────────────────────────
|
||||||
|
$this->pdo->prepare(
|
||||||
|
"UPDATE `{$table}` SET status = 1 WHERE id = :id AND company_id = :company_id"
|
||||||
|
)->execute([':id' => $id, ':company_id' => $this->company_id]);
|
||||||
|
|
||||||
|
// ── Rack state + balance ──────────────────────────────────────────
|
||||||
|
if ($type === 'in') {
|
||||||
|
|
||||||
|
// Occupy rack now that stock is approved
|
||||||
|
$whMgmt->occupyRack(
|
||||||
|
$warehouse_id,
|
||||||
|
$row['zone'], $row['aisle'], $row['rack'],
|
||||||
|
$row['product_sku'],
|
||||||
|
$id
|
||||||
|
);
|
||||||
|
$whMgmt->adjustBalance('in', $warehouse_id, $row['product_sku'], 0, (float)$row['in']);
|
||||||
|
|
||||||
|
} elseif ($type === 'out') {
|
||||||
|
|
||||||
|
// Release rack now that stock-out is approved
|
||||||
|
$whMgmt->releaseRack($warehouse_id, $row['zone'], $row['aisle'], $row['rack']);
|
||||||
|
$whMgmt->adjustBalance('out', $warehouse_id, $row['product_sku'], 0, (float)$row['out']);
|
||||||
|
|
||||||
|
} elseif ($type === 'transfer') {
|
||||||
|
|
||||||
|
// Transfer always has two rows in two different tables:
|
||||||
|
// outbound row → td_stock_<from> (out > 0, ref_warehouse = to_name)
|
||||||
|
// inbound row → td_stock_<to> (in > 0, ref_warehouse = from_name)
|
||||||
|
// Both rows share the same uuid. We always approve both atomically.
|
||||||
|
// Identify which side we were given and derive the other.
|
||||||
|
|
||||||
|
$is_outbound = (float)$row['out'] > 0;
|
||||||
|
|
||||||
|
// The outbound row lives in the from-warehouse table (already loaded as $row/$table).
|
||||||
|
// The inbound row lives in td_stock_<ref_warehouse>.
|
||||||
|
$from_row = $is_outbound ? $row : null;
|
||||||
|
$from_table = $is_outbound ? $table : null;
|
||||||
|
$from_wh_id = $is_outbound ? $warehouse_id : null;
|
||||||
|
|
||||||
|
// Resolve the paired table from ref_warehouse
|
||||||
|
$paired_wh_name = $row['ref_warehouse'];
|
||||||
|
$paired_safe = preg_replace('/[^a-zA-Z0-9_]/', '', $paired_wh_name);
|
||||||
|
$paired_table = "td_stock_{$paired_safe}";
|
||||||
|
$paired_wh_id = $whMgmt->getWarehouseIdByName($paired_wh_name);
|
||||||
|
|
||||||
|
// If we received the inbound side, swap so $from_* is always outbound
|
||||||
|
if (!$is_outbound) {
|
||||||
|
$from_table = $paired_table;
|
||||||
|
$from_wh_id = $paired_wh_id;
|
||||||
|
$paired_table = $table;
|
||||||
|
$paired_wh_id = $warehouse_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the inbound row from the paired table using uuid
|
||||||
|
$sth = $this->pdo->prepare(
|
||||||
|
"SELECT * FROM `{$paired_table}`
|
||||||
|
WHERE uuid = :uuid AND company_id = :company_id
|
||||||
|
LIMIT 1"
|
||||||
|
);
|
||||||
|
$sth->execute([':uuid' => $row['uuid'], ':company_id' => $this->company_id]);
|
||||||
|
$inbound_row = $sth->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
// Load outbound row if we were given the inbound side
|
||||||
|
if (!$is_outbound) {
|
||||||
|
$sth = $this->pdo->prepare(
|
||||||
|
"SELECT * FROM `{$from_table}`
|
||||||
|
WHERE uuid = :uuid AND company_id = :company_id
|
||||||
|
LIMIT 1"
|
||||||
|
);
|
||||||
|
$sth->execute([':uuid' => $row['uuid'], ':company_id' => $this->company_id]);
|
||||||
|
$from_row = $sth->fetch(PDO::FETCH_ASSOC);
|
||||||
|
} else {
|
||||||
|
$from_row = $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Approve outbound row
|
||||||
|
if ($from_row && (int)$from_row['status'] === 0) {
|
||||||
|
$this->pdo->prepare(
|
||||||
|
"UPDATE `{$from_table}` SET status = 1
|
||||||
|
WHERE id = :id AND company_id = :company_id"
|
||||||
|
)->execute([':id' => $from_row['id'], ':company_id' => $this->company_id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Approve inbound row
|
||||||
|
if ($inbound_row && (int)$inbound_row['status'] === 0) {
|
||||||
|
$this->pdo->prepare(
|
||||||
|
"UPDATE `{$paired_table}` SET status = 1
|
||||||
|
WHERE id = :id AND company_id = :company_id"
|
||||||
|
)->execute([':id' => $inbound_row['id'], ':company_id' => $this->company_id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rack state: release source, occupy destination
|
||||||
|
if ($from_row && $from_wh_id) {
|
||||||
|
$whMgmt->releaseRack(
|
||||||
|
$from_wh_id,
|
||||||
|
$from_row['zone'], $from_row['aisle'], $from_row['rack']
|
||||||
|
);
|
||||||
|
$whMgmt->adjustBalance('out', $from_wh_id, $from_row['product_sku'], 0, (float)$from_row['out']);
|
||||||
|
}
|
||||||
|
if ($inbound_row && $paired_wh_id) {
|
||||||
|
$whMgmt->occupyRack(
|
||||||
|
$paired_wh_id,
|
||||||
|
$inbound_row['zone'], $inbound_row['aisle'], $inbound_row['rack'],
|
||||||
|
$inbound_row['product_sku'],
|
||||||
|
$inbound_row['id']
|
||||||
|
);
|
||||||
|
$whMgmt->adjustBalance('in', $paired_wh_id, $inbound_row['product_sku'], 0, (float)$inbound_row['in']);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -216,7 +216,8 @@ class WarehouseManager {
|
|||||||
$unions = implode(' UNION ALL ', array_map(
|
$unions = implode(' UNION ALL ', array_map(
|
||||||
fn($t) => "SELECT `type`, `date` FROM `$t`
|
fn($t) => "SELECT `type`, `date` FROM `$t`
|
||||||
WHERE company_id = :company_id
|
WHERE company_id = :company_id
|
||||||
AND product_sku = :product_sku",
|
AND product_sku = :product_sku
|
||||||
|
AND status = 1",
|
||||||
$tables
|
$tables
|
||||||
));
|
));
|
||||||
|
|
||||||
@@ -428,6 +429,25 @@ class WarehouseManager {
|
|||||||
return $sth->fetchColumn();
|
return $sth->fetchColumn();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse lookup — return warehouse_id for a given warehouse_name.
|
||||||
|
* Used by approveStock() to resolve the destination warehouse on transfers.
|
||||||
|
*
|
||||||
|
* @param string $name The warehouse_name stored in td_stock.ref_warehouse.
|
||||||
|
* @return int|null The warehouse id, or null if not found.
|
||||||
|
*/
|
||||||
|
public function getWarehouseIdByName(string $name): ?int
|
||||||
|
{
|
||||||
|
$sth = $this->pdo->prepare(
|
||||||
|
"SELECT id FROM md_warehouse
|
||||||
|
WHERE company_id = :company_id AND warehouse_name = :name
|
||||||
|
LIMIT 1"
|
||||||
|
);
|
||||||
|
$sth->execute([':company_id' => $this->company_id, ':name' => $name]);
|
||||||
|
$id = $sth->fetchColumn();
|
||||||
|
return $id !== false ? (int)$id : null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Insert a new warehouse or update an existing one.
|
* Insert a new warehouse or update an existing one.
|
||||||
*
|
*
|
||||||
@@ -853,14 +873,35 @@ class WarehouseManager {
|
|||||||
*/
|
*/
|
||||||
public function getLotList(string $product_sku, string $keyword): array
|
public function getLotList(string $product_sku, string $keyword): array
|
||||||
{
|
{
|
||||||
|
// Only return lots that have at least one active td_stock row.
|
||||||
|
// Lots whose stock was fully soft-deleted (company_id negated) are excluded.
|
||||||
|
$sth = $this->pdo->prepare(
|
||||||
|
"SELECT id, warehouse_name FROM md_warehouse
|
||||||
|
WHERE company_id = :company_id AND status = 1"
|
||||||
|
);
|
||||||
|
$sth->execute([':company_id' => $this->company_id]);
|
||||||
|
$warehouses = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
if (empty($warehouses)) return [];
|
||||||
|
|
||||||
|
$cid = (int)$this->company_id;
|
||||||
|
|
||||||
|
// Build UNION EXISTS subquery across all td_stock_* tables
|
||||||
|
$unions = implode(' UNION ALL ', array_map(function($wh) use ($cid) {
|
||||||
|
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $wh['warehouse_name']);
|
||||||
|
return "SELECT lot_number FROM `td_stock_{$safe}`
|
||||||
|
WHERE company_id = {$cid} AND lot_number IS NOT NULL";
|
||||||
|
}, $warehouses));
|
||||||
|
|
||||||
$sth = $this->pdo->prepare(
|
$sth = $this->pdo->prepare(
|
||||||
"SELECT *
|
"SELECT *
|
||||||
FROM md_lot
|
FROM md_lot
|
||||||
WHERE company_id = :company_id
|
WHERE company_id = :company_id
|
||||||
AND product_sku = :product_sku
|
AND product_sku = :product_sku
|
||||||
AND lot_number LIKE :keyword
|
AND lot_number LIKE :keyword
|
||||||
ORDER BY lot_number ASC
|
AND lot_number IN (SELECT lot_number FROM ({$unions}) AS all_lots)
|
||||||
LIMIT 50"
|
ORDER BY lot_number ASC
|
||||||
|
LIMIT 50"
|
||||||
);
|
);
|
||||||
$sth->execute([
|
$sth->execute([
|
||||||
':company_id' => $this->company_id,
|
':company_id' => $this->company_id,
|
||||||
@@ -906,10 +947,12 @@ class WarehouseManager {
|
|||||||
WHERE s.company_id = :company_id
|
WHERE s.company_id = :company_id
|
||||||
AND s.product_sku = :product_sku
|
AND s.product_sku = :product_sku
|
||||||
AND s.type = 'in'
|
AND s.type = 'in'
|
||||||
|
AND s.status = 1
|
||||||
AND s.lot_number IS NOT NULL
|
AND s.lot_number IS NOT NULL
|
||||||
AND EXISTS (
|
AND EXISTS (
|
||||||
SELECT 1 FROM md_rack r
|
SELECT 1 FROM md_rack r
|
||||||
WHERE r.company_id = s.company_id
|
WHERE r.company_id = s.company_id
|
||||||
|
AND r.warehouse = :warehouse_id
|
||||||
AND r.td_stock_id = s.id
|
AND r.td_stock_id = s.id
|
||||||
AND r.product_sku IS NOT NULL
|
AND r.product_sku IS NOT NULL
|
||||||
)"
|
)"
|
||||||
@@ -917,6 +960,7 @@ class WarehouseManager {
|
|||||||
$sth->execute([
|
$sth->execute([
|
||||||
':company_id' => $this->company_id,
|
':company_id' => $this->company_id,
|
||||||
':product_sku' => $product_sku,
|
':product_sku' => $product_sku,
|
||||||
|
':warehouse_id' => $wh['id'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||||
@@ -953,15 +997,18 @@ class WarehouseManager {
|
|||||||
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $wh['warehouse_name']);
|
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $wh['warehouse_name']);
|
||||||
$table = "td_stock_{$safe}";
|
$table = "td_stock_{$safe}";
|
||||||
|
|
||||||
|
$wh_id = $wh['id'];
|
||||||
$sql = "SELECT DISTINCT s.serial_number
|
$sql = "SELECT DISTINCT s.serial_number
|
||||||
FROM `{$table}` s
|
FROM `{$table}` s
|
||||||
WHERE s.company_id = :company_id
|
WHERE s.company_id = :company_id
|
||||||
AND s.product_sku = :product_sku
|
AND s.product_sku = :product_sku
|
||||||
AND s.type = 'in'
|
AND s.type = 'in'
|
||||||
|
AND s.status = 1
|
||||||
AND s.serial_number IS NOT NULL
|
AND s.serial_number IS NOT NULL
|
||||||
AND EXISTS (
|
AND EXISTS (
|
||||||
SELECT 1 FROM md_rack r
|
SELECT 1 FROM md_rack r
|
||||||
WHERE r.company_id = s.company_id
|
WHERE r.company_id = s.company_id
|
||||||
|
AND r.warehouse = {$wh_id}
|
||||||
AND r.td_stock_id = s.id
|
AND r.td_stock_id = s.id
|
||||||
AND r.product_sku IS NOT NULL
|
AND r.product_sku IS NOT NULL
|
||||||
)";
|
)";
|
||||||
@@ -1765,11 +1812,11 @@ class WarehouseManager {
|
|||||||
WHERE id = :id AND company_id = :company_id"
|
WHERE id = :id AND company_id = :company_id"
|
||||||
)->execute([':id' => $stock_id, ':company_id' => $this->company_id]);
|
)->execute([':id' => $stock_id, ':company_id' => $this->company_id]);
|
||||||
|
|
||||||
// Release the rack back to empty
|
// Only reverse side effects if the row was approved — draft rows never had them applied
|
||||||
$this->releaseRack($warehouse_id, $row['zone'], $row['aisle'], $row['rack']);
|
if ((int)$row['status'] === 1) {
|
||||||
|
$this->releaseRack($warehouse_id, $row['zone'], $row['aisle'], $row['rack']);
|
||||||
// Reverse the balance (subtract the previously added quantity)
|
$this->adjustBalance('in', $warehouse_id, $product_sku, (int)$row['in'], 0);
|
||||||
$this->adjustBalance('in', $warehouse_id, $product_sku, (int)$row['in'], 0);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1809,16 +1856,17 @@ class WarehouseManager {
|
|||||||
WHERE id = :id AND company_id = :company_id"
|
WHERE id = :id AND company_id = :company_id"
|
||||||
)->execute([':id' => $stock_id, ':company_id' => $this->company_id]);
|
)->execute([':id' => $stock_id, ':company_id' => $this->company_id]);
|
||||||
|
|
||||||
// Re-occupy the rack with the original stock_in batch that was consumed
|
// Only reverse side effects if the row was approved — draft rows never had them applied
|
||||||
$this->occupyRack(
|
if ((int)$row['status'] === 1) {
|
||||||
$warehouse_id,
|
// Re-occupy the rack with the original stock_in batch that was consumed
|
||||||
$row['zone'], $row['aisle'], $row['rack'],
|
$this->occupyRack(
|
||||||
$product_sku,
|
$warehouse_id,
|
||||||
(int)$row['ref_id']
|
$row['zone'], $row['aisle'], $row['rack'],
|
||||||
);
|
$product_sku,
|
||||||
|
(int)$row['ref_id']
|
||||||
// Reverse the balance (subtract the previously deducted quantity)
|
);
|
||||||
$this->adjustBalance('out', $warehouse_id, $product_sku, (int)$row['out'], 0);
|
$this->adjustBalance('out', $warehouse_id, $product_sku, (int)$row['out'], 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1877,30 +1925,92 @@ class WarehouseManager {
|
|||||||
WHERE id = :id AND company_id = :company_id"
|
WHERE id = :id AND company_id = :company_id"
|
||||||
)->execute([':id' => $ref_id, ':company_id' => $this->company_id]);
|
)->execute([':id' => $ref_id, ':company_id' => $this->company_id]);
|
||||||
|
|
||||||
// Destination rack was occupied by transfer_in — release it
|
// Only reverse side effects if approved — draft rows never had them applied
|
||||||
$this->releaseRack(
|
if ((int)$from_row['status'] === 1) {
|
||||||
$to_warehouse,
|
// Destination rack was occupied by transfer_in — release it
|
||||||
$to_row['zone'], $to_row['aisle'], $to_row['rack']
|
$this->releaseRack(
|
||||||
);
|
$to_warehouse,
|
||||||
|
$to_row['zone'], $to_row['aisle'], $to_row['rack']
|
||||||
|
);
|
||||||
|
|
||||||
// Source rack was released by transfer_out — re-occupy with original batch
|
// Source rack was released by transfer_out — re-occupy with original batch
|
||||||
$this->occupyRack(
|
$this->occupyRack(
|
||||||
$from_warehouse_id,
|
$from_warehouse_id,
|
||||||
$from_row['zone'], $from_row['aisle'], $from_row['rack'],
|
$from_row['zone'], $from_row['aisle'], $from_row['rack'],
|
||||||
$product_sku,
|
$product_sku,
|
||||||
$from_stock_id
|
$from_stock_id
|
||||||
);
|
);
|
||||||
|
|
||||||
// Reverse balances on both sides
|
// Reverse balances on both sides
|
||||||
$quantity = (int)$from_row['out'];
|
$quantity = (int)$from_row['out'];
|
||||||
$this->adjustBalance('out', $from_warehouse_id, $product_sku, $quantity, 0);
|
$this->adjustBalance('out', $from_warehouse_id, $product_sku, $quantity, 0);
|
||||||
$this->adjustBalance('in', $to_warehouse, $product_sku, $quantity, 0);
|
$this->adjustBalance('in', $to_warehouse, $product_sku, $quantity, 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
// REPORT BASIS — Warehouse list queries
|
// REPORT BASIS — Warehouse list queries
|
||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return warehouse ids that currently hold an approved, rack-occupied stock-in
|
||||||
|
* row for a given product_sku + lot_number combination.
|
||||||
|
*
|
||||||
|
* Used to filter the warehouse dropdown in stock-out/transfer when a lot is selected,
|
||||||
|
* so only warehouses actually holding that lot are shown.
|
||||||
|
*
|
||||||
|
* @param string $product_sku
|
||||||
|
* @param string $lot_number
|
||||||
|
* @return int[] Array of warehouse ids.
|
||||||
|
*/
|
||||||
|
public function getWarehousesByLot(string $product_sku, string $lot_number): array
|
||||||
|
{
|
||||||
|
$sth = $this->pdo->prepare(
|
||||||
|
"SELECT id, warehouse_name FROM md_warehouse
|
||||||
|
WHERE company_id = :company_id AND status = 1"
|
||||||
|
);
|
||||||
|
$sth->execute([':company_id' => $this->company_id]);
|
||||||
|
$warehouses = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
$result = [];
|
||||||
|
$cid = (int)$this->company_id;
|
||||||
|
|
||||||
|
foreach ($warehouses as $wh) {
|
||||||
|
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $wh['warehouse_name']);
|
||||||
|
$table = "td_stock_{$safe}";
|
||||||
|
$wh_id = (int)$wh['id'];
|
||||||
|
|
||||||
|
$sth = $this->pdo->prepare(
|
||||||
|
"SELECT 1 FROM `{$table}` s
|
||||||
|
WHERE s.company_id = :company_id
|
||||||
|
AND s.product_sku = :product_sku
|
||||||
|
AND s.lot_number = :lot_number
|
||||||
|
AND s.type = 'in'
|
||||||
|
AND s.status = 1
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM md_rack r
|
||||||
|
WHERE r.company_id = s.company_id
|
||||||
|
AND r.warehouse = :warehouse_id
|
||||||
|
AND r.td_stock_id = s.id
|
||||||
|
AND r.product_sku IS NOT NULL
|
||||||
|
)
|
||||||
|
LIMIT 1"
|
||||||
|
);
|
||||||
|
$sth->execute([
|
||||||
|
':company_id' => $cid,
|
||||||
|
':product_sku' => $product_sku,
|
||||||
|
':lot_number' => $lot_number,
|
||||||
|
':warehouse_id' => $wh_id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($sth->fetchColumn() !== false) {
|
||||||
|
$result[] = ['id' => $wh_id, 'warehouse_name' => $wh['warehouse_name']];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return warehouses filtered by rack availability — for stock movement warehouse selectors.
|
* Return warehouses filtered by rack availability — for stock movement warehouse selectors.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* approve_stock.php — Approve a draft td_stock row (status 0 → 1).
|
||||||
|
*
|
||||||
|
* Flips status to 1 and calls adjustBalance() so warehouse_balance
|
||||||
|
* reflects the transaction. This is the ONLY place adjustBalance() is
|
||||||
|
* called — both auto-approve (on save) and manual approve go through here.
|
||||||
|
*
|
||||||
|
* Expected $data keys:
|
||||||
|
* id int td_stock row id
|
||||||
|
* warehouse int warehouse_id (to resolve the dynamic table)
|
||||||
|
* type string 'in' | 'out' | 'transfer'
|
||||||
|
*/
|
||||||
|
session_start();
|
||||||
|
require '../../../assets/utils/db_auth.php';
|
||||||
|
require '../../../assets/utils/classes/StockManager.php';
|
||||||
|
require '../../../assets/utils/classes/WarehouseManager.php';
|
||||||
|
|
||||||
|
$id = (int)($data['id'] ?? 0);
|
||||||
|
$warehouse_id = (int)($data['warehouse'] ?? 0);
|
||||||
|
$type = $data['type'] ?? '';
|
||||||
|
|
||||||
|
if (!$id || !$warehouse_id || !in_array($type, ['in', 'out', 'transfer'])) {
|
||||||
|
http_response_code(400);
|
||||||
|
exit(json_encode(['success' => 0, 'message' => 'Invalid parameters.']));
|
||||||
|
}
|
||||||
|
|
||||||
|
$stock = new StockManager($pdo2, $company_id);
|
||||||
|
$whMgmt = new WarehouseManager($pdo2, $company_id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo2->beginTransaction();
|
||||||
|
|
||||||
|
$result = $stock->approveStock($id, $warehouse_id, $type, $whMgmt);
|
||||||
|
|
||||||
|
$pdo2->commit();
|
||||||
|
|
||||||
|
$answer['success'] = 1;
|
||||||
|
$answer['message'] = 'Stock approved.';
|
||||||
|
exit(json_encode($answer));
|
||||||
|
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$pdo2->rollBack();
|
||||||
|
http_response_code(400);
|
||||||
|
exit(json_encode(['success' => 0, 'message' => $e->getMessage()]));
|
||||||
|
}
|
||||||
@@ -3,14 +3,26 @@
|
|||||||
require '../../../assets/utils/db_auth.php';
|
require '../../../assets/utils/db_auth.php';
|
||||||
require '../../../assets/utils/classes/StockManager.php';
|
require '../../../assets/utils/classes/StockManager.php';
|
||||||
require '../../../assets/utils/classes/WarehouseManager.php';
|
require '../../../assets/utils/classes/WarehouseManager.php';
|
||||||
|
require '../../../assets/utils/classes/CompanySettingManager.php';
|
||||||
|
|
||||||
|
$csm = new CompanySettingManager($pdo1, $company_id);
|
||||||
|
$auto_approve = (int)$csm->get('default_stock_status') === 1;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
dbTransaction($pdo2, function($pdo) use ($data, $company_id, $logging, $uuid) {
|
$new_id = null;
|
||||||
$stock = new StockManager($pdo, $company_id);
|
|
||||||
$stock->saveStockIn($data, $logging, $uuid);
|
dbTransaction($pdo2, function($pdo) use ($data, $company_id, $logging, $uuid, $auto_approve, &$new_id) {
|
||||||
|
$stock = new StockManager($pdo, $company_id);
|
||||||
|
$whMgmt = new WarehouseManager($pdo, $company_id);
|
||||||
|
$new_id = $stock->saveStockIn($data, $logging, $uuid);
|
||||||
|
|
||||||
|
if ($auto_approve && $new_id > 0) {
|
||||||
|
$stock->approveStock($new_id, (int)$data['warehouse'], 'in', $whMgmt);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$answer['success'] = 1;
|
$answer['success'] = 1;
|
||||||
|
$answer['auto_approved'] = $auto_approve;
|
||||||
|
|
||||||
} catch (PDOException $e) {
|
} catch (PDOException $e) {
|
||||||
$answer['success'] = 0;
|
$answer['success'] = 0;
|
||||||
|
|||||||
@@ -3,14 +3,26 @@
|
|||||||
require '../../../assets/utils/db_auth.php';
|
require '../../../assets/utils/db_auth.php';
|
||||||
require '../../../assets/utils/classes/StockManager.php';
|
require '../../../assets/utils/classes/StockManager.php';
|
||||||
require '../../../assets/utils/classes/WarehouseManager.php';
|
require '../../../assets/utils/classes/WarehouseManager.php';
|
||||||
|
require '../../../assets/utils/classes/CompanySettingManager.php';
|
||||||
|
|
||||||
|
$csm = new CompanySettingManager($pdo1, $company_id);
|
||||||
|
$auto_approve = (int)$csm->get('default_stock_status') === 1;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
dbTransaction($pdo2, function($pdo) use ($data, $company_id, $logging, $uuid) {
|
$new_id = null;
|
||||||
$stock = new StockManager($pdo, $company_id);
|
|
||||||
$stock->saveStockOut($data, $logging, $uuid);
|
dbTransaction($pdo2, function($pdo) use ($data, $company_id, $logging, $uuid, $auto_approve, &$new_id) {
|
||||||
|
$stock = new StockManager($pdo, $company_id);
|
||||||
|
$whMgmt = new WarehouseManager($pdo, $company_id);
|
||||||
|
$new_id = $stock->saveStockOut($data, $logging, $uuid);
|
||||||
|
|
||||||
|
if ($auto_approve && $new_id > 0) {
|
||||||
|
$stock->approveStock($new_id, (int)$data['warehouse'], 'out', $whMgmt);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$answer['success'] = 1;
|
$answer['success'] = 1;
|
||||||
|
$answer['auto_approved'] = $auto_approve;
|
||||||
|
|
||||||
} catch (PDOException $e) {
|
} catch (PDOException $e) {
|
||||||
$answer['success'] = 0;
|
$answer['success'] = 0;
|
||||||
|
|||||||
@@ -3,14 +3,26 @@
|
|||||||
require '../../../assets/utils/db_auth.php';
|
require '../../../assets/utils/db_auth.php';
|
||||||
require '../../../assets/utils/classes/StockManager.php';
|
require '../../../assets/utils/classes/StockManager.php';
|
||||||
require '../../../assets/utils/classes/WarehouseManager.php';
|
require '../../../assets/utils/classes/WarehouseManager.php';
|
||||||
|
require '../../../assets/utils/classes/CompanySettingManager.php';
|
||||||
|
|
||||||
|
$csm = new CompanySettingManager($pdo1, $company_id);
|
||||||
|
$auto_approve = (int)$csm->get('default_stock_status') === 1;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
dbTransaction($pdo2, function($pdo) use ($data, $company_id, $logging, $uuid) {
|
$new_id = null;
|
||||||
$stock = new StockManager($pdo, $company_id);
|
|
||||||
$stock->saveStockTransfer($data, $logging, $uuid);
|
dbTransaction($pdo2, function($pdo) use ($data, $company_id, $logging, $uuid, $auto_approve, &$new_id) {
|
||||||
|
$stock = new StockManager($pdo, $company_id);
|
||||||
|
$whMgmt = new WarehouseManager($pdo, $company_id);
|
||||||
|
$new_id = $stock->saveStockTransfer($data, $logging, $uuid);
|
||||||
|
|
||||||
|
if ($auto_approve && $new_id > 0) {
|
||||||
|
$stock->approveStock($new_id, (int)$data['warehouse_from'], 'transfer', $whMgmt);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$answer['success'] = 1;
|
$answer['success'] = 1;
|
||||||
|
$answer['auto_approved'] = $auto_approve;
|
||||||
|
|
||||||
} catch (PDOException $e) {
|
} catch (PDOException $e) {
|
||||||
$answer['success'] = 0;
|
$answer['success'] = 0;
|
||||||
|
|||||||
@@ -3,12 +3,20 @@
|
|||||||
require '../../../assets/utils/db_auth.php';
|
require '../../../assets/utils/db_auth.php';
|
||||||
require '../../../assets/utils/classes/WarehouseManager.php';
|
require '../../../assets/utils/classes/WarehouseManager.php';
|
||||||
|
|
||||||
$wh = new WarehouseManager($pdo2, $company_id);
|
$wh = new WarehouseManager($pdo2, $company_id);
|
||||||
$answer['output'] = $wh->getWarehouseList(
|
$type = $data['type'] ?? '';
|
||||||
$data['type'] ?? '',
|
$sku = $data['product_sku'] ?? '';
|
||||||
$data['product_sku'] ?? '',
|
$lot = $data['lot_number'] ?? '';
|
||||||
(int)($data['id'] ?? 0)
|
$id = (int)($data['id'] ?? 0);
|
||||||
);
|
|
||||||
|
// When type=from and a lot is selected, filter warehouses to only those
|
||||||
|
// holding that specific lot in an approved, rack-occupied stock-in row.
|
||||||
|
if ($type === 'from' && $sku && $lot) {
|
||||||
|
$answer['output'] = $wh->getWarehousesByLot($sku, $lot);
|
||||||
|
} else {
|
||||||
|
$answer['output'] = $wh->getWarehouseList($type, $sku, $id);
|
||||||
|
}
|
||||||
|
|
||||||
$answer['success'] = 1;
|
$answer['success'] = 1;
|
||||||
exit(json_encode($answer));
|
exit(json_encode($answer));
|
||||||
?>
|
?>
|
||||||
@@ -289,8 +289,8 @@
|
|||||||
.attr('data-id', data.contact_id);
|
.attr('data-id', data.contact_id);
|
||||||
|
|
||||||
$('#product_name').val(data.product_name);
|
$('#product_name').val(data.product_name);
|
||||||
$('#lot_number').val(data.lot_number || '');
|
$('#lot_number').val(data.lot_number || '').prop('disabled', true);
|
||||||
$('#serial_number').val(data.serial_number || '');
|
$('#serial_number').val(data.serial_number || '').prop('disabled', true);
|
||||||
if (data.lot_number) {
|
if (data.lot_number) {
|
||||||
if (expiryPicker) { expiryPicker.setDate(data.expiry_date || '', true, 'Y-m-d'); }
|
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')
|
||||||
|
|||||||
@@ -198,7 +198,7 @@
|
|||||||
autoPrepare: true,
|
autoPrepare: true,
|
||||||
checkRequired: 0,
|
checkRequired: 0,
|
||||||
noLoading: true,
|
noLoading: true,
|
||||||
data: { "type": 'from' },
|
data: { "type": 'from', "lot_number": $('#lot_number').val() },
|
||||||
action: 'read',
|
action: 'read',
|
||||||
onSuccess: function(res) {
|
onSuccess: function(res) {
|
||||||
var option = '<option value="">Select warehouse...</option>';
|
var option = '<option value="">Select warehouse...</option>';
|
||||||
|
|||||||
@@ -225,7 +225,7 @@
|
|||||||
autoPrepare: true,
|
autoPrepare: true,
|
||||||
checkRequired: 0,
|
checkRequired: 0,
|
||||||
noLoading: true,
|
noLoading: true,
|
||||||
data: { "type": type },
|
data: { "type": type, "lot_number": type === 'from' ? $('#lot_number').val() : '' },
|
||||||
action: 'read',
|
action: 'read',
|
||||||
debugMode: 0,
|
debugMode: 0,
|
||||||
onSuccess: function(res) {
|
onSuccess: function(res) {
|
||||||
|
|||||||
+47
-2
@@ -133,10 +133,16 @@
|
|||||||
<td class="py-3">${item['aisle']}</td>
|
<td class="py-3">${item['aisle']}</td>
|
||||||
<td class="py-3">${item['rack']}</td>
|
<td class="py-3">${item['rack']}</td>
|
||||||
<td class="py-3">
|
<td class="py-3">
|
||||||
${(item['status'])?'<span class="badge bg-success">Active</span>':'<span class="badge bg-secondary">Inactive</span>'}
|
${item['status'] == 1 ? '<span class="badge bg-success">Approved</span>' : '<span class="badge bg-warning text-dark">Draft</span>'}
|
||||||
</td>
|
</td>
|
||||||
<td class="py-3">
|
<td class="py-3">
|
||||||
<a href="<?php echo $server_url?>ics/manage_stock_in.php?id=${item['id']}&wh=${warehouse}" class=""><i class="ti ti-eye fs-5"></i></a>
|
<a href="<?php echo $server_url?>ics/manage_stock_in.php?id=${item['id']}&wh=${warehouse}" class=""><i class="ti ti-eye fs-5"></i></a>
|
||||||
|
${item['status'] == 0
|
||||||
|
? (auto_approve
|
||||||
|
? '<button class="btn btn-sm btn-outline-secondary ms-2" disabled title="Auto-approved on save — no manual approval needed"><i class=\"ti ti-check\"></i></button>'
|
||||||
|
: '<button class="btn btn-sm btn-outline-success ms-2" onclick=\"approve_stock('+item['id']+', '+item['warehouse_id']+', \'in\')\" title="Approve"><i class=\"ti ti-check\"></i></button>'
|
||||||
|
)
|
||||||
|
: ''}
|
||||||
<a href="javascript:void(0);" class="link-danger"
|
<a href="javascript:void(0);" class="link-danger"
|
||||||
onclick="delete_stock_in($(this),${item['id']})">
|
onclick="delete_stock_in($(this),${item['id']})">
|
||||||
<i class="ti ti-trash ms-2 fs-5"></i>
|
<i class="ti ti-trash ms-2 fs-5"></i>
|
||||||
@@ -189,13 +195,52 @@
|
|||||||
|
|
||||||
$(async function() {
|
$(async function() {
|
||||||
try {
|
try {
|
||||||
|
await load_company_setting();
|
||||||
await retrieve_warehouse();
|
await retrieve_warehouse();
|
||||||
await retrieve_stock_in();
|
await retrieve_stock_in();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(e);
|
console.log(e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
|
||||||
|
// ── Stock approval ────────────────────────────────────────────────────────
|
||||||
|
var auto_approve = false; // set from company_setting on load
|
||||||
|
|
||||||
|
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) {
|
||||||
|
auto_approve = res.output.default_stock_status == 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function approve_stock(id, warehouse_id, type) {
|
||||||
|
bootbox.confirm({
|
||||||
|
message: 'Approve this stock record?',
|
||||||
|
buttons: { confirm: { label: 'Approve', className: 'btn-success' }, cancel: { label: 'Cancel', className: 'btn-secondary' } },
|
||||||
|
callback: function(result) {
|
||||||
|
if (!result) return;
|
||||||
|
ajax_request({
|
||||||
|
url: server_url + 'ics/api/engine/approve_stock.php',
|
||||||
|
autoPrepare: true,
|
||||||
|
checkRequired: 0,
|
||||||
|
action: 'update',
|
||||||
|
id: id,
|
||||||
|
warehouse: warehouse_id,
|
||||||
|
type: type,
|
||||||
|
onSuccess: function(res) {
|
||||||
|
retrieve_stock_list();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|||||||
+47
-2
@@ -133,10 +133,16 @@
|
|||||||
<td class="py-3">${item['aisle']}</td>
|
<td class="py-3">${item['aisle']}</td>
|
||||||
<td class="py-3">${item['rack']}</td>
|
<td class="py-3">${item['rack']}</td>
|
||||||
<td class="py-3">
|
<td class="py-3">
|
||||||
${(item['status'])?'<span class="badge bg-success">Active</span>':'<span class="badge bg-secondary">Inactive</span>'}
|
${item['status'] == 1 ? '<span class="badge bg-success">Approved</span>' : '<span class="badge bg-warning text-dark">Draft</span>'}
|
||||||
</td>
|
</td>
|
||||||
<td class="py-3">
|
<td class="py-3">
|
||||||
<a href="<?php echo $server_url?>ics/manage_stock_out.php?id=${item['id']}&wh=${warehouse}" class=""><i class="ti ti-eye fs-5"></i></a>
|
<a href="<?php echo $server_url?>ics/manage_stock_out.php?id=${item['id']}&wh=${warehouse}" class=""><i class="ti ti-eye fs-5"></i></a>
|
||||||
|
${item['status'] == 0
|
||||||
|
? (auto_approve
|
||||||
|
? '<button class="btn btn-sm btn-outline-secondary ms-2" disabled title="Auto-approved on save — no manual approval needed"><i class=\"ti ti-check\"></i></button>'
|
||||||
|
: '<button class="btn btn-sm btn-outline-success ms-2" onclick=\"approve_stock('+item['id']+', '+item['warehouse_id']+', \'out\')\" title="Approve"><i class=\"ti ti-check\"></i></button>'
|
||||||
|
)
|
||||||
|
: ''}
|
||||||
<a href="javascript:void(0);" class="link-danger"
|
<a href="javascript:void(0);" class="link-danger"
|
||||||
onclick="delete_stock_out($(this),${item['id']})">
|
onclick="delete_stock_out($(this),${item['id']})">
|
||||||
<i class="ti ti-trash ms-2 fs-5"></i>
|
<i class="ti ti-trash ms-2 fs-5"></i>
|
||||||
@@ -189,13 +195,52 @@
|
|||||||
|
|
||||||
$(async function() {
|
$(async function() {
|
||||||
try {
|
try {
|
||||||
|
await load_company_setting();
|
||||||
await retrieve_warehouse();
|
await retrieve_warehouse();
|
||||||
await retrieve_stock_out();
|
await retrieve_stock_out();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(e);
|
console.log(e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
|
||||||
|
// ── Stock approval ────────────────────────────────────────────────────────
|
||||||
|
var auto_approve = false; // set from company_setting on load
|
||||||
|
|
||||||
|
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) {
|
||||||
|
auto_approve = res.output.default_stock_status == 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function approve_stock(id, warehouse_id, type) {
|
||||||
|
bootbox.confirm({
|
||||||
|
message: 'Approve this stock record?',
|
||||||
|
buttons: { confirm: { label: 'Approve', className: 'btn-success' }, cancel: { label: 'Cancel', className: 'btn-secondary' } },
|
||||||
|
callback: function(result) {
|
||||||
|
if (!result) return;
|
||||||
|
ajax_request({
|
||||||
|
url: server_url + 'ics/api/engine/approve_stock.php',
|
||||||
|
autoPrepare: true,
|
||||||
|
checkRequired: 0,
|
||||||
|
action: 'update',
|
||||||
|
id: id,
|
||||||
|
warehouse: warehouse_id,
|
||||||
|
type: type,
|
||||||
|
onSuccess: function(res) {
|
||||||
|
retrieve_stock_list();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|||||||
@@ -129,10 +129,16 @@
|
|||||||
<td class="py-3">${item['aisle']}</td>
|
<td class="py-3">${item['aisle']}</td>
|
||||||
<td class="py-3">${item['rack']}</td>
|
<td class="py-3">${item['rack']}</td>
|
||||||
<td class="py-3">
|
<td class="py-3">
|
||||||
${(item['status'])?'<span class="badge bg-success">Active</span>':'<span class="badge bg-secondary">Inactive</span>'}
|
${item['status'] == 1 ? '<span class="badge bg-success">Approved</span>' : '<span class="badge bg-warning text-dark">Draft</span>'}
|
||||||
</td>
|
</td>
|
||||||
<td class="py-3">
|
<td class="py-3">
|
||||||
<a href="<?php echo $server_url?>ics/manage_stock_transfer.php?id=${item['id']}&wh=${warehouse}" class=""><i class="ti ti-eye fs-5"></i></a>
|
<a href="<?php echo $server_url?>ics/manage_stock_transfer.php?id=${item['id']}&wh=${warehouse}" class=""><i class="ti ti-eye fs-5"></i></a>
|
||||||
|
${item['status'] == 0
|
||||||
|
? (auto_approve
|
||||||
|
? '<button class="btn btn-sm btn-outline-secondary ms-2" disabled title="Auto-approved on save — no manual approval needed"><i class=\"ti ti-check\"></i></button>'
|
||||||
|
: '<button class="btn btn-sm btn-outline-success ms-2" onclick=\"approve_stock('+item['id']+', '+item['warehouse_id']+', \'transfer\')\" title="Approve"><i class=\"ti ti-check\"></i></button>'
|
||||||
|
)
|
||||||
|
: ''}
|
||||||
<a href="javascript:void(0);" class="link-danger"
|
<a href="javascript:void(0);" class="link-danger"
|
||||||
onclick="delete_stock_transfer($(this),${item['id']})">
|
onclick="delete_stock_transfer($(this),${item['id']})">
|
||||||
<i class="ti ti-trash ms-2 fs-5"></i>
|
<i class="ti ti-trash ms-2 fs-5"></i>
|
||||||
@@ -187,13 +193,52 @@
|
|||||||
|
|
||||||
$(async function() {
|
$(async function() {
|
||||||
try {
|
try {
|
||||||
|
await load_company_setting();
|
||||||
await retrieve_warehouse();
|
await retrieve_warehouse();
|
||||||
await retrieve_stock_transfer();
|
await retrieve_stock_transfer();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(e);
|
console.log(e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
|
||||||
|
// ── Stock approval ────────────────────────────────────────────────────────
|
||||||
|
var auto_approve = false; // set from company_setting on load
|
||||||
|
|
||||||
|
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) {
|
||||||
|
auto_approve = res.output.default_stock_status == 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function approve_stock(id, warehouse_id, type) {
|
||||||
|
bootbox.confirm({
|
||||||
|
message: 'Approve this stock record?',
|
||||||
|
buttons: { confirm: { label: 'Approve', className: 'btn-success' }, cancel: { label: 'Cancel', className: 'btn-secondary' } },
|
||||||
|
callback: function(result) {
|
||||||
|
if (!result) return;
|
||||||
|
ajax_request({
|
||||||
|
url: server_url + 'ics/api/engine/approve_stock.php',
|
||||||
|
autoPrepare: true,
|
||||||
|
checkRequired: 0,
|
||||||
|
action: 'update',
|
||||||
|
id: id,
|
||||||
|
warehouse: warehouse_id,
|
||||||
|
type: type,
|
||||||
|
onSuccess: function(res) {
|
||||||
|
retrieve_stock_list();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,8 @@
|
|||||||
|
|
||||||
<!-- theme script -->
|
<!-- theme script -->
|
||||||
<link rel="stylesheet" href="<?php echo $server_url?>assets/css/main.css">
|
<link rel="stylesheet" href="<?php echo $server_url?>assets/css/main.css">
|
||||||
|
<!-- Popper UMD: must load before main.js so window.Popper exists globally -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.8/dist/umd/popper.min.js" crossorigin="anonymous"></script>
|
||||||
<script type="module" src="<?php echo $server_url?>assets/js/main.js"></script>
|
<script type="module" src="<?php echo $server_url?>assets/js/main.js"></script>
|
||||||
<link rel="stylesheet" href="<?php echo $server_url?>assets/css/custom.css">
|
<link rel="stylesheet" href="<?php echo $server_url?>assets/css/custom.css">
|
||||||
<script src="<?php echo $server_url?>assets/js/custom.js"></script>
|
<script src="<?php echo $server_url?>assets/js/custom.js"></script>
|
||||||
|
|||||||
@@ -56,6 +56,14 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<a class="nav-link <?php echo $current_page === 'system_config.php' ? 'active' : ''; ?>"
|
||||||
|
href="<?php echo $server_url?>setting/system_config.php">
|
||||||
|
<i class="ti ti-adjustments-cog"></i>
|
||||||
|
<span class="nav-text">System Configuration</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
<!-- Back -->
|
<!-- Back -->
|
||||||
<li class="nav-text-space mt-2">
|
<li class="nav-text-space mt-2">
|
||||||
<small class="nav-text text-muted">Navigation</small>
|
<small class="nav-text text-muted">Navigation</small>
|
||||||
|
|||||||
+91
-63
@@ -7,6 +7,13 @@ if(!isset($_SESSION["otp"])){
|
|||||||
|
|
||||||
require 'preset.php';
|
require 'preset.php';
|
||||||
|
|
||||||
|
// ── User display data from session ────────────────────────────────────────────
|
||||||
|
$topbar_name = htmlspecialchars(trim(($_SESSION['login_name'] ?? '') . ' ' . ($_SESSION['login_surname'] ?? '')), ENT_QUOTES, 'UTF-8');
|
||||||
|
$topbar_username = htmlspecialchars($_SESSION['login_username'] ?? '', ENT_QUOTES, 'UTF-8');
|
||||||
|
$topbar_picture = $_SESSION['login_profile_picture'] ?? '';
|
||||||
|
$topbar_avatar_src = $topbar_picture
|
||||||
|
? htmlspecialchars($server_url . 'uploads/profile/' . $topbar_picture, ENT_QUOTES, 'UTF-8')
|
||||||
|
: htmlspecialchars($server_url . 'assets/images/avatar-1.jpg', ENT_QUOTES, 'UTF-8');
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<div id="overlay" class="overlay"></div>
|
<div id="overlay" class="overlay"></div>
|
||||||
@@ -17,6 +24,23 @@ require 'preset.php';
|
|||||||
data-otp="<?php echo htmlspecialchars($_SESSION['otp'] ?? '', ENT_QUOTES, 'UTF-8'); ?>">
|
data-otp="<?php echo htmlspecialchars($_SESSION['otp'] ?? '', ENT_QUOTES, 'UTF-8'); ?>">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Switch Branch Modal -->
|
||||||
|
<div class="modal fade" id="switchBranchModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered" style="max-width: 360px;">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header border-dashed">
|
||||||
|
<h6 class="modal-title mb-0"><i class="ti ti-building me-2"></i>Switch Branch</h6>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body p-0">
|
||||||
|
<div id="branch_list" class="d-flex flex-column">
|
||||||
|
<div class="text-center text-muted small py-4">Loading...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- TOPBAR -->
|
<!-- TOPBAR -->
|
||||||
<nav id="topbar" class="navbar bg-white border-bottom fixed-top topbar px-3">
|
<nav id="topbar" class="navbar bg-white border-bottom fixed-top topbar px-3">
|
||||||
<button id="toggleBtn" class="d-none d-lg-inline-flex btn btn-light btn-icon btn-sm ">
|
<button id="toggleBtn" class="d-none d-lg-inline-flex btn btn-light btn-icon btn-sm ">
|
||||||
@@ -30,7 +54,6 @@ require 'preset.php';
|
|||||||
<div>
|
<div>
|
||||||
<!-- Navbar nav -->
|
<!-- Navbar nav -->
|
||||||
<ul class="list-unstyled d-flex align-items-center mb-0 gap-1">
|
<ul class="list-unstyled d-flex align-items-center mb-0 gap-1">
|
||||||
<!-- Pages link -->
|
|
||||||
|
|
||||||
<!-- Bell icon -->
|
<!-- Bell icon -->
|
||||||
<li>
|
<li>
|
||||||
@@ -43,85 +66,41 @@ require 'preset.php';
|
|||||||
<path d="M10 5a2 2 0 1 1 4 0a7 7 0 0 1 4 6v3a4 4 0 0 0 2 3h-16a4 4 0 0 0 2 -3v-3a7 7 0 0 1 4 -6" />
|
<path d="M10 5a2 2 0 1 1 4 0a7 7 0 0 1 4 6v3a4 4 0 0 0 2 3h-16a4 4 0 0 0 2 -3v-3a7 7 0 0 1 4 -6" />
|
||||||
<path d="M9 17v1a3 3 0 0 0 6 0v-1" />
|
<path d="M9 17v1a3 3 0 0 0 6 0v-1" />
|
||||||
</svg>
|
</svg>
|
||||||
<span class="position-absolute top-0 start-100 translate-middle badge rounded-pill bg-danger mt-2 ms-n2">
|
|
||||||
2
|
|
||||||
<span class="visually-hidden">unread messages</span>
|
|
||||||
</span>
|
|
||||||
</a>
|
</a>
|
||||||
<div class="dropdown-menu dropdown-menu-end dropdown-menu-md p-0">
|
<div class="dropdown-menu dropdown-menu-end dropdown-menu-md p-0">
|
||||||
<ul class="list-unstyled p-0 m-0">
|
<ul class="list-unstyled p-0 m-0">
|
||||||
<li class="p-3 border-bottom ">
|
<li class="px-4 py-3 text-center text-muted small">No notifications</li>
|
||||||
<div class="d-flex gap-3">
|
|
||||||
<img src="<?php echo $server_url?>assets/images/avatar-1.jpg" alt=""
|
|
||||||
class="avatar avatar-sm rounded-circle" />
|
|
||||||
<div class="flex-grow-1 small">
|
|
||||||
<p class="mb-0">New order received</p>
|
|
||||||
<p class="mb-1">Order #12345 has been placed</p>
|
|
||||||
<div class="text-secondary">5 minutes ago</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
<li class="p-3 border-bottom ">
|
|
||||||
<div class="d-flex gap-3">
|
|
||||||
<img src="<?php echo $server_url?>assets/images/avatar-4.jpg" alt=""
|
|
||||||
class="avatar avatar-sm rounded-circle" />
|
|
||||||
<div class="flex-grow-1 small">
|
|
||||||
<p class="mb-0">New user registered</p>
|
|
||||||
<p class="mb-1">User @john_doe has signed up</p>
|
|
||||||
<div class="text-secondary">30 minutes ago</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li class="p-3 border-bottom">
|
|
||||||
<div class="d-flex gap-3">
|
|
||||||
<img src="<?php echo $server_url?>assets/images/avatar-2.jpg" alt=""
|
|
||||||
class="avatar avatar-sm rounded-circle" />
|
|
||||||
<div class="flex-grow-1 small">
|
|
||||||
<p class="mb-0">Payment confirmed</p>
|
|
||||||
<p class="mb-1">Payment of $299 has been received</p>
|
|
||||||
<div class="text-secondary">1 hour ago</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
<li class="px-4 py-3 text-center">
|
|
||||||
<a href="#" class="text-primary ">View all notifications</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<!-- Dropdown -->
|
|
||||||
|
<!-- User dropdown -->
|
||||||
<li class="ms-3 dropdown">
|
<li class="ms-3 dropdown">
|
||||||
<a href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
<a href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
<img src="<?php echo $server_url?>assets/images/avatar-1.jpg" alt=""
|
<img src="<?php echo $topbar_avatar_src; ?>" alt=""
|
||||||
class="avatar avatar-sm rounded-circle" />
|
class="avatar avatar-sm rounded-circle" />
|
||||||
</a>
|
</a>
|
||||||
<div class="dropdown-menu dropdown-menu-end p-0" style="min-width: 200px;">
|
<div class="dropdown-menu dropdown-menu-end p-0" style="min-width: 200px;">
|
||||||
<div>
|
<div>
|
||||||
<div class="d-flex gap-3 align-items-center border-dashed border-bottom px-3 py-3">
|
<div class="d-flex gap-3 align-items-center border-dashed border-bottom px-3 py-3">
|
||||||
<img src="<?php echo $server_url?>assets/images/avatar-1.jpg" alt=""
|
<img src="<?php echo $topbar_avatar_src; ?>" alt=""
|
||||||
class="avatar avatar-md rounded-circle" />
|
class="avatar avatar-md rounded-circle" />
|
||||||
<div>
|
<div>
|
||||||
<h4 class="mb-0 small">Shrina Tesla</h4>
|
<h4 class="mb-0 small"><?php echo $topbar_name; ?></h4>
|
||||||
<p class="mb-0 small">@imshrina</p>
|
<p class="mb-0 small">@<?php echo $topbar_username; ?></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-3 d-flex flex-column gap-1 small lh-lg">
|
<div class="p-3 d-flex flex-column gap-1 small lh-lg">
|
||||||
<a href="javascript" class="" onclick="switch_branch();">
|
<a href="javascript:;" onclick="switch_branch();">
|
||||||
|
|
||||||
<span>Switch Branch</span>
|
<span>Switch Branch</span>
|
||||||
</a>
|
</a>
|
||||||
|
<a href="<?php echo $server_url?>setting/profile.php">
|
||||||
<a href="<?php echo $server_url?>setting/profile.php" class="">
|
<span>Setting</span>
|
||||||
|
|
||||||
<span> Setting</span>
|
|
||||||
</a>
|
</a>
|
||||||
|
<a href="javascript:;" onclick="log_out();">
|
||||||
<a href="javascript:;" class="" onclick="log_out();">
|
<span>Log Out</span>
|
||||||
|
|
||||||
<span> Log Out</span>
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@@ -136,22 +115,71 @@ require 'preset.php';
|
|||||||
var server_url = '<?php echo $server_url; ?>';
|
var server_url = '<?php echo $server_url; ?>';
|
||||||
var prop_limit = '<?php echo $prop['limit']; ?>';
|
var prop_limit = '<?php echo $prop['limit']; ?>';
|
||||||
|
|
||||||
// log_out function
|
// ── Log out ───────────────────────────────────────────────────────────────────
|
||||||
function log_out() {
|
function log_out() {
|
||||||
|
|
||||||
return ajax_request({
|
return ajax_request({
|
||||||
url: "<?php echo $server_url?>login/api/engine/back.php",
|
url: server_url + 'login/api/engine/back.php',
|
||||||
|
autoPrepare: true,
|
||||||
|
checkRequired: 0,
|
||||||
|
action: 'read',
|
||||||
|
onSuccess: function(res) {
|
||||||
|
window.location.href = server_url + 'index.php';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Switch Branch ─────────────────────────────────────────────────────────────
|
||||||
|
function switch_branch() {
|
||||||
|
|
||||||
|
$('#switchBranchModal').modal('show');
|
||||||
|
|
||||||
|
ajax_request({
|
||||||
|
url: server_url + 'setting/api/engine/switch_branch.php',
|
||||||
autoPrepare: true,
|
autoPrepare: true,
|
||||||
checkRequired: 0,
|
checkRequired: 0,
|
||||||
action: 'read',
|
action: 'read',
|
||||||
onSuccess: function(res) {
|
onSuccess: function(res) {
|
||||||
|
|
||||||
window.location.href = "<?php echo $server_url?>index.php";
|
var current = res.current;
|
||||||
|
var companies = res.output || [];
|
||||||
|
|
||||||
|
if (companies.length <= 1) {
|
||||||
|
$('#branch_list').html(
|
||||||
|
'<div class="text-center text-muted small py-4">You only belong to one branch.</div>'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var html = '';
|
||||||
|
companies.forEach(function(c) {
|
||||||
|
var is_active = c.company_id == current;
|
||||||
|
html += '<a href="javascript:;" class="d-flex align-items-center gap-3 px-3 py-2 border-bottom ' +
|
||||||
|
(is_active ? 'bg-light' : '') + '" onclick="do_switch_branch(' + c.company_id + ')">' +
|
||||||
|
'<span class="d-flex align-items-center justify-content-center rounded-circle bg-label-primary flex-shrink-0" style="width:36px;height:36px;">' +
|
||||||
|
'<i class="ti ti-building small"></i></span>' +
|
||||||
|
'<div class="flex-grow-1 overflow-hidden">' +
|
||||||
|
'<p class="mb-0 small fw-semibold text-truncate">' + $('<span>').text(c.company_name).html() + '</p>' +
|
||||||
|
'<p class="mb-0 x-small text-muted">' + $('<span>').text(c.branch || c.role).html() + '</p>' +
|
||||||
|
'</div>' +
|
||||||
|
(is_active ? '<i class="ti ti-check text-success ms-auto"></i>' : '') +
|
||||||
|
'</a>';
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#branch_list').html(html);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function do_switch_branch(company_id) {
|
||||||
|
ajax_request({
|
||||||
|
url: server_url + 'setting/api/engine/switch_branch.php',
|
||||||
|
autoPrepare: true,
|
||||||
|
checkRequired: 0,
|
||||||
|
action: 'update',
|
||||||
|
company_id: company_id,
|
||||||
|
onSuccess: function(res) {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -116,11 +116,13 @@ $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
|||||||
// ── Step 5c: Write authenticated login session ────────────────────────────────
|
// ── Step 5c: Write authenticated login session ────────────────────────────────
|
||||||
// These keys are read by db_auth.php on every subsequent request to gate access.
|
// These keys are read by db_auth.php on every subsequent request to gate access.
|
||||||
// login_company_id is the user's default_company — used to scope all DB queries.
|
// login_company_id is the user's default_company — used to scope all DB queries.
|
||||||
$_SESSION["login_status"] = 1;
|
$_SESSION["login_status"] = 1;
|
||||||
$_SESSION["login_username"] = $temp["username"];
|
$_SESSION["login_user_id"] = (int)$temp["user_id"];
|
||||||
$_SESSION["login_name"] = $temp["name"];
|
$_SESSION["login_username"] = $temp["username"];
|
||||||
$_SESSION["login_surname"] = $temp["surname"];
|
$_SESSION["login_name"] = $temp["name"];
|
||||||
$_SESSION["login_company_id"] = $temp["default_company"];
|
$_SESSION["login_surname"] = $temp["surname"];
|
||||||
|
$_SESSION["login_company_id"] = $temp["default_company"];
|
||||||
|
$_SESSION["login_profile_picture"] = $temp["profile_picture"] ?? '';
|
||||||
|
|
||||||
// ── Step 6: Respond ───────────────────────────────────────────────────────────
|
// ── Step 6: Respond ───────────────────────────────────────────────────────────
|
||||||
$answer["success"] = 1;
|
$answer["success"] = 1;
|
||||||
|
|||||||
+18
-28
@@ -18,6 +18,9 @@
|
|||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
<!-- zxcvbn JS: client-side password strength scoring, no server round-trip -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/zxcvbn@4.4.2/dist/zxcvbn.js"></script>
|
||||||
|
|
||||||
<div class="container d-flex align-items-center justify-content-center min-vh-100 py-5">
|
<div class="container d-flex align-items-center justify-content-center min-vh-100 py-5">
|
||||||
<div class="card" style="max-width:520px; width:100%;">
|
<div class="card" style="max-width:520px; width:100%;">
|
||||||
<div class="card-body p-5">
|
<div class="card-body p-5">
|
||||||
@@ -137,9 +140,7 @@
|
|||||||
{ label: 'Very strong', color: '#0d6efd', pct: 100 },
|
{ label: 'Very strong', color: '#0d6efd', pct: 100 },
|
||||||
];
|
];
|
||||||
|
|
||||||
var pw_score = -1;
|
var pw_score = -1;
|
||||||
var pw_debounce = null;
|
|
||||||
var pw_xhr = null;
|
|
||||||
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════
|
// ═══════════════════════════════════════════════
|
||||||
@@ -156,38 +157,27 @@
|
|||||||
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════
|
// ═══════════════════════════════════════════════
|
||||||
// Password strength
|
// Password strength (JS zxcvbn — no server round-trip)
|
||||||
// ═══════════════════════════════════════════════
|
// ═══════════════════════════════════════════════
|
||||||
function on_password_input(pw) {
|
function on_password_input(pw) {
|
||||||
clearTimeout(pw_debounce);
|
|
||||||
if (!pw) { reset_strength_ui(); check_confirm_match(); return; }
|
if (!pw) { reset_strength_ui(); check_confirm_match(); return; }
|
||||||
pw_debounce = setTimeout(() => check_strength(pw), 350);
|
check_strength(pw);
|
||||||
}
|
}
|
||||||
|
|
||||||
function check_strength(pw) {
|
function check_strength(pw) {
|
||||||
if (pw_xhr) pw_xhr.abort();
|
const user_inputs = [
|
||||||
|
$('#name').val(), $('#surname').val(),
|
||||||
|
$('#username').val(), $('#email').val()
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
pw_xhr = $.ajax({
|
const result = zxcvbn(pw, user_inputs);
|
||||||
url: '<?php echo $server_url?>setting/api/engine/check_password.php',
|
pw_score = result.score;
|
||||||
type: 'POST',
|
|
||||||
dataType: 'json',
|
const lvl = STRENGTH_LEVELS[pw_score];
|
||||||
data: { json: JSON.stringify({
|
$('#pw_strength_bar').css({ width: lvl.pct + '%', backgroundColor: lvl.color });
|
||||||
otp: '',
|
$('#pw_strength_label').text(lvl.label).css('color', lvl.color);
|
||||||
company_id: 0,
|
$('#pw_feedback').text(result.feedback.warning || result.feedback.suggestions[0] || '');
|
||||||
action: 'read',
|
check_confirm_match();
|
||||||
password: pw,
|
|
||||||
})},
|
|
||||||
headers: { 'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content') },
|
|
||||||
success: function (res) {
|
|
||||||
pw_score = res.score ?? -1;
|
|
||||||
if (pw_score < 0) { reset_strength_ui(); return; }
|
|
||||||
const lvl = STRENGTH_LEVELS[pw_score];
|
|
||||||
$('#pw_strength_bar').css({ width: lvl.pct + '%', backgroundColor: lvl.color });
|
|
||||||
$('#pw_strength_label').text(lvl.label).css('color', lvl.color);
|
|
||||||
$('#pw_feedback').text(res.feedback || '');
|
|
||||||
check_confirm_match();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function reset_strength_ui() {
|
function reset_strength_ui() {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
exit(json_encode($answer));
|
exit(json_encode($answer));
|
||||||
}
|
}
|
||||||
|
|
||||||
$report = new ReportManager($pdo2, $company_id);
|
$report = new ReportManager($pdo2, $company_id, $db_database);
|
||||||
$answer['output'] = $report->getRackLog($rack_id);
|
$answer['output'] = $report->getRackLog($rack_id);
|
||||||
$answer['success'] = 1;
|
$answer['success'] = 1;
|
||||||
exit(json_encode($answer));
|
exit(json_encode($answer));
|
||||||
|
|||||||
@@ -229,14 +229,14 @@
|
|||||||
// ── Render both tables from filtered data ────────────────────────────
|
// ── Render both tables from filtered data ────────────────────────────
|
||||||
function render_tables() {
|
function render_tables() {
|
||||||
|
|
||||||
var kw = search_term.toLowerCase();
|
var kw = search_term;
|
||||||
var fs = filter_status;
|
var fs = filter_status;
|
||||||
|
|
||||||
var filtered = all_items.filter(function(item) {
|
var filtered = all_items.filter(function(item) {
|
||||||
var match_search = !kw ||
|
var match_search = !kw ||
|
||||||
item.product_name.toLowerCase().includes(kw) ||
|
item.product_name || ''.includes(kw) ||
|
||||||
item.product_sku.toLowerCase().includes(kw) ||
|
item.product_sku || ''.includes(kw) ||
|
||||||
(item.lot_number || '').toLowerCase().includes(kw);
|
item.lot_number || ''.includes(kw);
|
||||||
var match_status = !fs || item.status === fs;
|
var match_status = !fs || item.status === fs;
|
||||||
return match_search && match_status;
|
return match_search && match_status;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -472,8 +472,7 @@
|
|||||||
|
|
||||||
$('#rackLogModalLabel').text('Rack Log — ' + label);
|
$('#rackLogModalLabel').text('Rack Log — ' + label);
|
||||||
$('#rack_log_body').html('<tr><td colspan="4" class="text-center py-4 text-muted">Loading...</td></tr>');
|
$('#rack_log_body').html('<tr><td colspan="4" class="text-center py-4 text-muted">Loading...</td></tr>');
|
||||||
var modal = new bootstrap.Modal(document.getElementById('rackLogModal'));
|
$('#rackLogModal').modal('show');
|
||||||
modal.show();
|
|
||||||
|
|
||||||
ajax_request({
|
ajax_request({
|
||||||
url: "<?php echo $server_url?>reports/api/engine_report/rack_log.php",
|
url: "<?php echo $server_url?>reports/api/engine_report/rack_log.php",
|
||||||
|
|||||||
@@ -124,6 +124,7 @@
|
|||||||
<th>Expiry Date</th>
|
<th>Expiry Date</th>
|
||||||
<th>Days Remaining</th>
|
<th>Days Remaining</th>
|
||||||
<th>Balance</th>
|
<th>Balance</th>
|
||||||
|
<th>Active</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Action</th>
|
<th>Action</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -259,6 +260,9 @@
|
|||||||
<td class="py-3">${item.expiry_date || '<span class="text-muted">—</span>'}</td>
|
<td class="py-3">${item.expiry_date || '<span class="text-muted">—</span>'}</td>
|
||||||
<td class="py-3">${days_lbl}</td>
|
<td class="py-3">${days_lbl}</td>
|
||||||
<td class="py-3">${Number(item.balance).toLocaleString()}</td>
|
<td class="py-3">${Number(item.balance).toLocaleString()}</td>
|
||||||
|
<td class="py-3">${item.is_active == 1
|
||||||
|
? '<span class="badge bg-success">Active</span>'
|
||||||
|
: '<span class="badge bg-secondary">Inactive</span>'}</td>
|
||||||
<td class="py-3">${status_badge[item.status] || ''}</td>
|
<td class="py-3">${status_badge[item.status] || ''}</td>
|
||||||
<td class="py-3">
|
<td class="py-3">
|
||||||
<a href="javascript:;" onclick="view_lot_log('${item.product_sku}','${item.lot_number}')">
|
<a href="javascript:;" onclick="view_lot_log('${item.product_sku}','${item.lot_number}')">
|
||||||
|
|||||||
@@ -28,6 +28,32 @@
|
|||||||
<button class="btn btn-primary btn-sm text-nowrap" onclick="do_search();">
|
<button class="btn btn-primary btn-sm text-nowrap" onclick="do_search();">
|
||||||
<i class="ti ti-search"></i> Search
|
<i class="ti ti-search"></i> Search
|
||||||
</button>
|
</button>
|
||||||
|
<div class="dropdown">
|
||||||
|
<a href="#" role="button" class="btn btn-outline-secondary btn-sm text-nowrap" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
|
<i class="ti ti-download me-1"></i> Export
|
||||||
|
</a>
|
||||||
|
<div class="dropdown-menu dropdown-menu-end p-0" style="min-width: 160px;">
|
||||||
|
<div>
|
||||||
|
<div class="d-flex gap-3 align-items-center border-dashed border-bottom px-3 py-3">
|
||||||
|
<i class="ti ti-download"></i>
|
||||||
|
<div>
|
||||||
|
<h4 class="mb-0 small">Export</h4>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 d-flex flex-column gap-1 small lh-lg">
|
||||||
|
<a href="javascript:;" onclick="export_report('csv')">
|
||||||
|
<span><i class="ti ti-file-type-csv me-2"></i>CSV</span>
|
||||||
|
</a>
|
||||||
|
<a href="javascript:;" onclick="export_report('xlsx')">
|
||||||
|
<span><i class="ti ti-file-type-xls me-2"></i>Excel</span>
|
||||||
|
</a>
|
||||||
|
<a href="javascript:;" onclick="export_report('pdf')">
|
||||||
|
<span><i class="ti ti-file-type-pdf me-2"></i>PDF</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -194,6 +220,7 @@
|
|||||||
sku_queue = [];
|
sku_queue = [];
|
||||||
sku_index = 0;
|
sku_index = 0;
|
||||||
is_loading = false;
|
is_loading = false;
|
||||||
|
export_data = [];
|
||||||
search_params = { warehouse_id: warehouse_id, from_month: from_month, to_month: to_month };
|
search_params = { warehouse_id: warehouse_id, from_month: from_month, to_month: to_month };
|
||||||
|
|
||||||
// Reset UI
|
// Reset UI
|
||||||
@@ -295,9 +322,31 @@
|
|||||||
|
|
||||||
// ── Render one SKU group into the table ───────────────────────────────
|
// ── Render one SKU group into the table ───────────────────────────────
|
||||||
function append_sku_group(d) {
|
function append_sku_group(d) {
|
||||||
var bf = d.brought_forward;
|
var bf = d.brought_forward;
|
||||||
var rows = d.rows;
|
var rows = d.rows;
|
||||||
|
|
||||||
|
// Populate export_data — brought forward row first, then transactions
|
||||||
|
export_data.push({
|
||||||
|
sku: d.sku, product: d.product_name, date: 'Brought Forward', type: '',
|
||||||
|
lot: '', location: '', contact: '',
|
||||||
|
stock_in: bf.in > 0 ? bf.in : '', stock_out: bf.out > 0 ? bf.out : '',
|
||||||
|
running_total: bf.balance,
|
||||||
|
});
|
||||||
|
$.each(rows, function(i, row) {
|
||||||
|
export_data.push({
|
||||||
|
sku: row.product_sku,
|
||||||
|
product: d.product_name,
|
||||||
|
date: row.date,
|
||||||
|
type: row.type,
|
||||||
|
lot: row.lot_number || '',
|
||||||
|
location: row.zone + '-' + row.aisle + '-' + row.rack,
|
||||||
|
contact: row.contact_name || '',
|
||||||
|
stock_in: row.stock_in > 0 ? row.stock_in : '',
|
||||||
|
stock_out: row.stock_out > 0 ? row.stock_out : '',
|
||||||
|
running_total: row.running_total,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// SKU header row
|
// SKU header row
|
||||||
var header = `<tr class="table-secondary">
|
var header = `<tr class="table-secondary">
|
||||||
<td colspan="9" class="py-2 fw-semibold">
|
<td colspan="9" class="py-2 fw-semibold">
|
||||||
@@ -364,11 +413,77 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ── Boot ──────────────────────────────────────────────────────────────
|
// ── Export ───────────────────────────────────────────────────────────
|
||||||
|
// export_data holds all rows accumulated across all loaded SKU groups.
|
||||||
|
// It is populated by append_sku_group() alongside the table render.
|
||||||
|
var export_data = [];
|
||||||
|
|
||||||
|
function export_report(format) {
|
||||||
|
|
||||||
|
if (export_data.length === 0) {
|
||||||
|
alert('No data to export. Please search first.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var warehouse = $('#warehouse option:selected').text();
|
||||||
|
var from = $('#from_month').val();
|
||||||
|
var to = $('#to_month').val();
|
||||||
|
var filename = 'stock_movement_' + warehouse + '_' + from + '_' + to;
|
||||||
|
|
||||||
|
if (format === 'csv') {
|
||||||
|
alasql('SELECT * INTO CSV("' + filename + '.csv", {headers:true}) FROM ?', [export_data]);
|
||||||
|
|
||||||
|
} else if (format === 'xlsx') {
|
||||||
|
alasql('SELECT * INTO XLSX("' + filename + '.xlsx", {headers:true}) FROM ?', [export_data]);
|
||||||
|
|
||||||
|
} else if (format === 'pdf') {
|
||||||
|
var { jsPDF } = window.jspdf;
|
||||||
|
var doc = new jsPDF({ orientation: 'landscape' });
|
||||||
|
|
||||||
|
doc.setFontSize(12);
|
||||||
|
doc.text('Stock Movement — ' + warehouse + ' (' + from + ' to ' + to + ')', 14, 15);
|
||||||
|
|
||||||
|
var headers = [['SKU', 'Product', 'Date', 'Type', 'Lot', 'Location', 'Contact', 'Stock In', 'Stock Out', 'Running Total']];
|
||||||
|
var rows = export_data.map(function(r) {
|
||||||
|
return [
|
||||||
|
r.sku || '',
|
||||||
|
r.product || '',
|
||||||
|
r.date || '',
|
||||||
|
r.type || '',
|
||||||
|
r.lot || '',
|
||||||
|
r.location || '',
|
||||||
|
r.contact || '',
|
||||||
|
r.stock_in || '',
|
||||||
|
r.stock_out || '',
|
||||||
|
r.running_total || '',
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
doc.autoTable({
|
||||||
|
head: headers,
|
||||||
|
body: rows,
|
||||||
|
startY: 22,
|
||||||
|
styles: { fontSize: 7 },
|
||||||
|
headStyles: { fillColor: [41, 128, 185] },
|
||||||
|
});
|
||||||
|
|
||||||
|
doc.save(filename + '.pdf');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
$(async function() {
|
$(async function() {
|
||||||
try { await retrieve_warehouse(); } catch(e) { console.log(e); }
|
try { await retrieve_warehouse(); } catch(e) { console.log(e); }
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- SheetJS — required by AlaSQL for Excel (.xlsx) export -->
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script>
|
||||||
|
|
||||||
|
<!-- jsPDF + autoTable — for PDF export on stock movement report -->
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf-autotable/3.8.2/jspdf.plugin.autotable.min.js"></script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<?php
|
|
||||||
session_start();
|
|
||||||
require '../../../assets/utils/db_auth.php';
|
|
||||||
require '../../../assets/utils/classes/PasswordManager.php';
|
|
||||||
|
|
||||||
$pm = new PasswordManager($pdo1, $include_url);
|
|
||||||
$pm->handleCheck($data);
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?php
|
||||||
|
session_start();
|
||||||
|
require '../../../assets/utils/db_auth.php';
|
||||||
|
require '../../../assets/utils/classes/CompanySettingManager.php';
|
||||||
|
|
||||||
|
$csm = new CompanySettingManager($pdo1, $company_id);
|
||||||
|
$csm->handle($data);
|
||||||
@@ -60,8 +60,9 @@
|
|||||||
db_check($sth, $answer);
|
db_check($sth, $answer);
|
||||||
|
|
||||||
// ── Refresh session ───────────────────────────────────────
|
// ── Refresh session ───────────────────────────────────────
|
||||||
$_SESSION['login_name'] = trim($data['name'] ?? '');
|
$_SESSION['login_name'] = trim($data['name'] ?? '');
|
||||||
$_SESSION['login_surname'] = trim($data['surname'] ?? '');
|
$_SESSION['login_surname'] = trim($data['surname'] ?? '');
|
||||||
|
$_SESSION['login_profile_picture'] = $db_picture;
|
||||||
|
|
||||||
$answer['success'] = 1;
|
$answer['success'] = 1;
|
||||||
$answer['message'] = 'Profile updated.';
|
$answer['message'] = 'Profile updated.';
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* switch_branch.php — Switch the active company for the current session.
|
||||||
|
*
|
||||||
|
* action: 'read' → return list of companies the user belongs to
|
||||||
|
* action: 'update' → switch to the requested company_id
|
||||||
|
*
|
||||||
|
* Both actions verify that the user is actually a member of the target company
|
||||||
|
* via company_map_user before touching the session.
|
||||||
|
*/
|
||||||
|
session_start();
|
||||||
|
require '../../../assets/utils/db_auth.php';
|
||||||
|
|
||||||
|
$action = $data['action'] ?? '';
|
||||||
|
|
||||||
|
// ── READ: return all companies this user belongs to ───────────────────────────
|
||||||
|
if ($action === 'read') {
|
||||||
|
|
||||||
|
$sth = $pdo1->prepare(
|
||||||
|
"SELECT cl.company_id, cl.company_name, cl.branch, cmu.role
|
||||||
|
FROM company_map_user cmu
|
||||||
|
JOIN company_list cl ON cl.company_id = cmu.company_id
|
||||||
|
WHERE cmu.user_id = :user_id
|
||||||
|
ORDER BY cl.company_name ASC"
|
||||||
|
);
|
||||||
|
$sth->execute([':user_id' => $user_id]);
|
||||||
|
$companies = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
$answer['success'] = 1;
|
||||||
|
$answer['output'] = $companies;
|
||||||
|
$answer['current'] = (int) $_SESSION['login_company_id'];
|
||||||
|
exit(json_encode($answer));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── UPDATE: switch to a different company ─────────────────────────────────────
|
||||||
|
if ($action === 'update') {
|
||||||
|
|
||||||
|
$target_company_id = (int)($data['company_id'] ?? 0);
|
||||||
|
|
||||||
|
if (!$target_company_id) {
|
||||||
|
http_response_code(400);
|
||||||
|
$answer['message'] = 'Invalid company.';
|
||||||
|
exit(json_encode($answer));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify user actually belongs to the requested company
|
||||||
|
$sth = $pdo1->prepare(
|
||||||
|
"SELECT company_id FROM company_map_user
|
||||||
|
WHERE company_id = :company_id AND user_id = :user_id
|
||||||
|
LIMIT 1"
|
||||||
|
);
|
||||||
|
$sth->execute([':company_id' => $target_company_id, ':user_id' => $user_id]);
|
||||||
|
|
||||||
|
if (!$sth->fetch()) {
|
||||||
|
http_response_code(403);
|
||||||
|
$answer['message'] = 'You do not have access to this company.';
|
||||||
|
exit(json_encode($answer));
|
||||||
|
}
|
||||||
|
|
||||||
|
$_SESSION['login_company_id'] = $target_company_id;
|
||||||
|
|
||||||
|
$answer['success'] = 1;
|
||||||
|
$answer['message'] = 'Switched successfully.';
|
||||||
|
exit(json_encode($answer));
|
||||||
|
}
|
||||||
|
|
||||||
|
http_response_code(400);
|
||||||
|
$answer['message'] = 'Invalid action.';
|
||||||
|
exit(json_encode($answer));
|
||||||
+26
-44
@@ -5,6 +5,9 @@
|
|||||||
?>
|
?>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
<!-- zxcvbn JS: client-side password strength scoring, no server round-trip -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/zxcvbn@4.4.2/dist/zxcvbn.js"></script>
|
||||||
<?php require '../include_topbar.php'; ?>
|
<?php require '../include_topbar.php'; ?>
|
||||||
<?php require '../include_setting_sidebar.php'; ?>
|
<?php require '../include_setting_sidebar.php'; ?>
|
||||||
|
|
||||||
@@ -318,12 +321,8 @@
|
|||||||
// ═══════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════
|
||||||
// State
|
// State
|
||||||
// ═══════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════
|
||||||
var pw_score = -1;
|
var pw_score = -1;
|
||||||
var pw_debounce = null;
|
var reset_pw_score = -1;
|
||||||
var pw_check_xhr = null;
|
|
||||||
var reset_pw_score = -1;
|
|
||||||
var reset_pw_debounce = null;
|
|
||||||
var reset_pw_check_xhr = null;
|
|
||||||
var user_email = ''; // stored on retrieve, used for modal masking
|
var user_email = ''; // stored on retrieve, used for modal masking
|
||||||
|
|
||||||
function mask_email(email) {
|
function mask_email(email) {
|
||||||
@@ -448,9 +447,8 @@
|
|||||||
// CHANGE PASSWORD
|
// CHANGE PASSWORD
|
||||||
// ═══════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════
|
||||||
function on_new_password_input(pw) {
|
function on_new_password_input(pw) {
|
||||||
clearTimeout(pw_debounce);
|
|
||||||
if (!pw) { reset_strength_ui('pw'); update_pw_button(); return; }
|
if (!pw) { reset_strength_ui('pw'); update_pw_button(); return; }
|
||||||
pw_debounce = setTimeout(() => check_strength(pw, 'pw'), 350);
|
check_strength(pw, 'pw');
|
||||||
}
|
}
|
||||||
|
|
||||||
function check_confirm_match() {
|
function check_confirm_match() {
|
||||||
@@ -474,6 +472,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function change_password() {
|
function change_password() {
|
||||||
|
if (pw_score < 3) { bootbox.alert('Please choose a stronger password.'); return; }
|
||||||
return ajax_request({
|
return ajax_request({
|
||||||
url: '<?php echo $server_url?>setting/api/engine/change_password.php',
|
url: '<?php echo $server_url?>setting/api/engine/change_password.php',
|
||||||
autoPrepare: false,
|
autoPrepare: false,
|
||||||
@@ -542,9 +541,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function on_reset_password_input(pw) {
|
function on_reset_password_input(pw) {
|
||||||
clearTimeout(reset_pw_debounce);
|
|
||||||
if (!pw) { reset_strength_ui('reset'); update_reset_button(); return; }
|
if (!pw) { reset_strength_ui('reset'); update_reset_button(); return; }
|
||||||
reset_pw_debounce = setTimeout(() => check_strength(pw, 'reset'), 350);
|
check_strength(pw, 'reset');
|
||||||
}
|
}
|
||||||
|
|
||||||
function check_reset_confirm_match() {
|
function check_reset_confirm_match() {
|
||||||
@@ -568,6 +566,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function confirm_reset_password() {
|
function confirm_reset_password() {
|
||||||
|
if (reset_pw_score < 3) { bootbox.alert('Please choose a stronger password.'); return; }
|
||||||
return ajax_request({
|
return ajax_request({
|
||||||
url: '<?php echo $server_url?>setting/api/engine/reset_password_otp.php',
|
url: '<?php echo $server_url?>setting/api/engine/reset_password_otp.php',
|
||||||
autoPrepare: false,
|
autoPrepare: false,
|
||||||
@@ -595,45 +594,28 @@
|
|||||||
// prefix = 'pw' | 'reset'
|
// prefix = 'pw' | 'reset'
|
||||||
// ═══════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════
|
||||||
function check_strength(pw, prefix) {
|
function check_strength(pw, prefix) {
|
||||||
// Abort pending request
|
const user_inputs = [
|
||||||
if (prefix === 'pw' && pw_check_xhr) pw_check_xhr.abort();
|
$('#name').val(), $('#surname').val(),
|
||||||
if (prefix === 'reset' && reset_pw_check_xhr) reset_pw_check_xhr.abort();
|
$('#username').val(), user_email
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
const xhr = $.ajax({
|
const result = zxcvbn(pw, user_inputs);
|
||||||
url: '<?php echo $server_url?>setting/api/engine/check_password.php',
|
const score = result.score;
|
||||||
type: 'POST',
|
|
||||||
dataType: 'json',
|
|
||||||
data: { json: JSON.stringify({
|
|
||||||
otp: document.getElementById('session-context').dataset.otp,
|
|
||||||
company_id: document.getElementById('session-context').dataset.companyId,
|
|
||||||
action: 'read',
|
|
||||||
password: pw,
|
|
||||||
})},
|
|
||||||
headers: { 'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content') },
|
|
||||||
success: function(res) {
|
|
||||||
const score = res.score ?? -1;
|
|
||||||
|
|
||||||
if (prefix === 'pw') pw_score = score;
|
if (prefix === 'pw') pw_score = score;
|
||||||
if (prefix === 'reset') reset_pw_score = score;
|
if (prefix === 'reset') reset_pw_score = score;
|
||||||
|
|
||||||
const bar = prefix === 'pw' ? '#pw_strength_bar' : '#reset_strength_bar';
|
const bar = prefix === 'pw' ? '#pw_strength_bar' : '#reset_strength_bar';
|
||||||
const label = prefix === 'pw' ? '#pw_strength_label' : '#reset_strength_label';
|
const label = prefix === 'pw' ? '#pw_strength_label' : '#reset_strength_label';
|
||||||
const feedback = prefix === 'pw' ? '#pw_feedback' : '#reset_feedback';
|
const feedback = prefix === 'pw' ? '#pw_feedback' : '#reset_feedback';
|
||||||
|
|
||||||
if (score < 0) { reset_strength_ui(prefix); return; }
|
const lvl = STRENGTH_LEVELS[score];
|
||||||
|
$(bar).css({ width: lvl.pct + '%', backgroundColor: lvl.color });
|
||||||
|
$(label).text(lvl.label).css('color', lvl.color);
|
||||||
|
$(feedback).text(result.feedback.warning || result.feedback.suggestions[0] || '');
|
||||||
|
|
||||||
const lvl = STRENGTH_LEVELS[score];
|
if (prefix === 'pw') { check_confirm_match(); update_pw_button(); }
|
||||||
$(bar).css({ width: lvl.pct + '%', backgroundColor: lvl.color });
|
if (prefix === 'reset') { check_reset_confirm_match(); update_reset_button(); }
|
||||||
$(label).text(lvl.label).css('color', lvl.color);
|
|
||||||
$(feedback).text(res.feedback || '');
|
|
||||||
|
|
||||||
if (prefix === 'pw') { check_confirm_match(); update_pw_button(); }
|
|
||||||
if (prefix === 'reset') { check_reset_confirm_match(); update_reset_button(); }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (prefix === 'pw') pw_check_xhr = xhr;
|
|
||||||
if (prefix === 'reset') reset_pw_check_xhr = xhr;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function reset_strength_ui(prefix) {
|
function reset_strength_ui(prefix) {
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<?php
|
||||||
|
session_start();
|
||||||
|
require '../config.php';
|
||||||
|
require '../include_header.php';
|
||||||
|
?>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<?php require '../include_topbar.php'; ?>
|
||||||
|
<?php require '../include_setting_sidebar.php'; ?>
|
||||||
|
|
||||||
|
<main id="content" class="content py-15">
|
||||||
|
<div class="container-fluid">
|
||||||
|
|
||||||
|
<!-- Page header -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center mb-6 gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="fs-3 mb-1">System Configuration</h1>
|
||||||
|
<p class="mb-0">Per-company settings that control application behaviour</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stock Settings -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12 col-lg-7">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-header bg-transparent border-bottom">
|
||||||
|
<h6 class="mb-0"><i class="ti ti-package me-2"></i>Stock Settings</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label fw-semibold">Default Stock Status</label>
|
||||||
|
<p class="text-muted small mb-2">
|
||||||
|
Controls whether new stock-in, stock-out, and transfer records are saved
|
||||||
|
as <strong>Draft</strong> (requires manual approval) or
|
||||||
|
<strong>Auto-approved</strong> (approved immediately on save).
|
||||||
|
Only approved records appear in reports and balance calculations.
|
||||||
|
</p>
|
||||||
|
<div class="d-flex flex-column gap-2" id="default_stock_status_group">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="radio" name="default_stock_status"
|
||||||
|
id="status_auto" value="1">
|
||||||
|
<label class="form-check-label" for="status_auto">
|
||||||
|
<span class="fw-semibold">Auto-approve</span>
|
||||||
|
<span class="text-muted ms-1 small">— records are approved immediately on save</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="radio" name="default_stock_status"
|
||||||
|
id="status_draft" value="0">
|
||||||
|
<label class="form-check-label" for="status_draft">
|
||||||
|
<span class="fw-semibold">Draft</span>
|
||||||
|
<span class="text-muted ms-1 small">— records must be manually approved before affecting reports</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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<?php require '../include_ending.php'; ?>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
// ── Load ──────────────────────────────────────────────────────────────────
|
||||||
|
$(function() {
|
||||||
|
ajax_request({
|
||||||
|
url: server_url + 'setting/api/engine/company_setting.php',
|
||||||
|
autoPrepare: true,
|
||||||
|
checkRequired: 0,
|
||||||
|
action: 'read',
|
||||||
|
onSuccess: function(res) {
|
||||||
|
var val = res.output.default_stock_status ?? 1;
|
||||||
|
$('input[name="default_stock_status"][value="' + val + '"]').prop('checked', true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Save ──────────────────────────────────────────────────────────────────
|
||||||
|
function save_config() {
|
||||||
|
var status = $('input[name="default_stock_status"]:checked').val();
|
||||||
|
|
||||||
|
if (status === undefined) {
|
||||||
|
bootbox.alert('Please select a default stock status.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ajax_request({
|
||||||
|
url: server_url + 'setting/api/engine/company_setting.php',
|
||||||
|
autoPrepare: true,
|
||||||
|
checkRequired: 0,
|
||||||
|
action: 'update',
|
||||||
|
data: {
|
||||||
|
default_stock_status: status,
|
||||||
|
},
|
||||||
|
onSuccess: function(res) {
|
||||||
|
bootbox.alert('Settings saved.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -301,7 +301,7 @@
|
|||||||
function open_invite_modal() {
|
function open_invite_modal() {
|
||||||
$('#invite_email').val('').removeClass('is-invalid is-valid');
|
$('#invite_email').val('').removeClass('is-invalid is-valid');
|
||||||
$('#invite_role').val('').removeClass('is-invalid');
|
$('#invite_role').val('').removeClass('is-invalid');
|
||||||
new bootstrap.Modal('#inviteModal').show();
|
$('#inviteModal').modal('show');
|
||||||
}
|
}
|
||||||
|
|
||||||
function send_invite() {
|
function send_invite() {
|
||||||
@@ -330,7 +330,7 @@
|
|||||||
action: 'create',
|
action: 'create',
|
||||||
data: { invite_email: email, invite_role: role },
|
data: { invite_email: email, invite_role: role },
|
||||||
onSuccess: function (r) {
|
onSuccess: function (r) {
|
||||||
bootstrap.Modal.getInstance('#inviteModal')?.hide();
|
$('#inviteModal').modal('hide');
|
||||||
bootbox.alert(r.message || 'Invitation sent.');
|
bootbox.alert(r.message || 'Invitation sent.');
|
||||||
load_users();
|
load_users();
|
||||||
},
|
},
|
||||||
@@ -353,7 +353,7 @@
|
|||||||
$('#edit_role').val(u.role);
|
$('#edit_role').val(u.role);
|
||||||
$('#edit_avatar').attr('src', u.profile_picture ? img_base + u.profile_picture : avatar_ph);
|
$('#edit_avatar').attr('src', u.profile_picture ? img_base + u.profile_picture : avatar_ph);
|
||||||
|
|
||||||
new bootstrap.Modal('#editRoleModal').show();
|
$('#editRoleModal').modal('show');
|
||||||
}
|
}
|
||||||
|
|
||||||
function save_role() {
|
function save_role() {
|
||||||
@@ -364,7 +364,7 @@
|
|||||||
action: 'update',
|
action: 'update',
|
||||||
data: { map_id: $('#edit_map_id').val(), role: $('#edit_role').val() },
|
data: { map_id: $('#edit_map_id').val(), role: $('#edit_role').val() },
|
||||||
onSuccess: function (r) {
|
onSuccess: function (r) {
|
||||||
bootstrap.Modal.getInstance('#editRoleModal')?.hide();
|
$('#editRoleModal').modal('hide');
|
||||||
bootbox.alert(r.message || 'Role updated.');
|
bootbox.alert(r.message || 'Role updated.');
|
||||||
load_users();
|
load_users();
|
||||||
},
|
},
|
||||||
|
|||||||
Vendored
Vendored
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user