Compare commits
26
Commits
501c70050f
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6e5ab2841 | ||
|
|
9bb92bc20a | ||
|
|
f11af6e949 | ||
|
|
8705be0d1b | ||
|
|
73c680e844 | ||
|
|
ae98dcdcdd | ||
|
|
e579dd596c | ||
|
|
5cc43cff92 | ||
|
|
c89b28da4c | ||
|
|
de760d02da | ||
|
|
3668f22e55 | ||
|
|
114cf73748 | ||
|
|
9a8998796c | ||
|
|
80c7eab0d2 | ||
|
|
033846dece | ||
|
|
c14822add6 | ||
|
|
c60b705d98 | ||
|
|
d8363f6ca7 | ||
|
|
f70f226bd1 | ||
|
|
f14c850c70 | ||
|
|
c915379e2e | ||
|
|
78d69d81df | ||
|
|
693c72f9ea | ||
|
|
9512d440dd | ||
|
|
45331948c1 | ||
|
|
ba73456185 |
@@ -0,0 +1,53 @@
|
|||||||
|
# wms-app — web server rules for the repository root.
|
||||||
|
#
|
||||||
|
# The whole repository sits under the web root (/wms-app/), so everything that is
|
||||||
|
# not part of the running app must be refused here: git history, .env files,
|
||||||
|
# deployment and build folders, SDLC documents, the Node server source, CLI-only
|
||||||
|
# PHP scripts and library internals. Needs AllowOverride All (docker/php/apache-wms.conf
|
||||||
|
# enables it for the container) plus mod_rewrite and mod_headers.
|
||||||
|
|
||||||
|
Options -Indexes
|
||||||
|
|
||||||
|
<IfModule mod_rewrite.c>
|
||||||
|
RewriteEngine On
|
||||||
|
|
||||||
|
# HTTP → HTTPS when the deployment says TLS is available (FORCE_HTTPS=true in the
|
||||||
|
# environment). Honours X-Forwarded-Proto so it also works behind a TLS proxy.
|
||||||
|
RewriteCond %{ENV:FORCE_HTTPS} ^true$
|
||||||
|
RewriteCond %{HTTPS} !=on
|
||||||
|
RewriteCond %{HTTP:X-Forwarded-Proto} !=https
|
||||||
|
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
|
||||||
|
|
||||||
|
# Dotfiles and dot-folders anywhere: .git, .env, .claude, .htaccess, .mcp.json …
|
||||||
|
RewriteRule (^|/)\. - [R=404,L]
|
||||||
|
|
||||||
|
# Folders that are never served.
|
||||||
|
RewriteRule ^(nodejs|docker|sdlc|sdlc-delivery|scripts|lib|notes|docs|vendor|node_modules)(/|$) - [R=404,L]
|
||||||
|
|
||||||
|
# Repository files at the root: build/deploy config, CLI scripts, archives, docs.
|
||||||
|
RewriteRule ^(composer\.(json|lock)|docker-compose\.ya?ml|setup\.php|demo_seed[^/]*\.php)$ - [R=404,L]
|
||||||
|
RewriteRule \.(zip|tar|gz|tgz|sql|sh|md|log|bak|old|orig|swp|dist|example|ini|yml|yaml|lock|env|pem|key|crt|map)$ - [R=404,L]
|
||||||
|
|
||||||
|
# App internals included by the entry points, never requested directly: config,
|
||||||
|
# DB connection, shared utilities, manager classes, bundled libraries (PHPMailer
|
||||||
|
# ships get_oauth_token.php), and the page fragments.
|
||||||
|
RewriteRule ^app/(config[^/]*\.php|dbconn\.php|preset\.php)$ - [R=404,L]
|
||||||
|
RewriteRule ^app/assets/utils/ - [R=404,L]
|
||||||
|
RewriteRule ^app/include_[^/]+\.php$ - [R=404,L]
|
||||||
|
|
||||||
|
# Uploaded files are served through a PHP gate that requires a signed-in session.
|
||||||
|
RewriteRule ^app/uploads/(.+)$ app/file.php?path=$1 [L,QSA,B]
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
|
<IfModule mod_headers.c>
|
||||||
|
# Sent on every response (pages, API JSON, static files). Pages add a
|
||||||
|
# Content-Security-Policy of their own from include_header.php.
|
||||||
|
Header always set X-Content-Type-Options "nosniff"
|
||||||
|
Header always set X-Frame-Options "SAMEORIGIN"
|
||||||
|
Header always set Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
|
Header always set Permissions-Policy "geolocation=(), microphone=(), payment=(), usb=()"
|
||||||
|
Header always unset X-Powered-By
|
||||||
|
Header unset X-Powered-By
|
||||||
|
# HSTS only means anything over HTTPS; browsers ignore it on plain HTTP.
|
||||||
|
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains" "expr=%{HTTPS} == 'on' || %{HTTP:X-Forwarded-Proto} == 'https'"
|
||||||
|
</IfModule>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
session_start();
|
require_once __DIR__ . '/../../../session.php';
|
||||||
require_once '../../../assets/utils/db_auth.php';
|
require_once '../../../assets/utils/db_auth.php';
|
||||||
require_once '../../../assets/utils/classes/UsageGuard.php';
|
require_once '../../../assets/utils/classes/UsageGuard.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
session_start();
|
require_once __DIR__ . '/../../../session.php';
|
||||||
require_once '../../../assets/utils/db_auth.php';
|
require_once '../../../assets/utils/db_auth.php';
|
||||||
require_once '../../../assets/utils/classes/UsageGuard.php';
|
require_once '../../../assets/utils/classes/UsageGuard.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
session_start();
|
require_once __DIR__ . '/../../../session.php';
|
||||||
require_once '../../../assets/utils/db_auth.php';
|
require_once '../../../assets/utils/db_auth.php';
|
||||||
require_once '../../../assets/utils/classes/UsageGuard.php';
|
require_once '../../../assets/utils/classes/UsageGuard.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
session_start();
|
require_once __DIR__ . '/../../../session.php';
|
||||||
require_once '../../../assets/utils/db_auth.php';
|
require_once '../../../assets/utils/db_auth.php';
|
||||||
require_once '../../../assets/utils/classes/CompanySettingManager.php';
|
require_once '../../../assets/utils/classes/CompanySettingManager.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
session_start();
|
require_once __DIR__ . '/../../../session.php';
|
||||||
require_once '../../../assets/utils/db_auth.php';
|
require_once '../../../assets/utils/db_auth.php';
|
||||||
require_once '../../../assets/utils/classes/UsageGuard.php';
|
require_once '../../../assets/utils/classes/UsageGuard.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
session_start();
|
require_once __DIR__ . '/../../../session.php';
|
||||||
require_once '../../../assets/utils/db_auth.php';
|
require_once '../../../assets/utils/db_auth.php';
|
||||||
require_once '../../../assets/utils/classes/UsageGuard.php';
|
require_once '../../../assets/utils/classes/UsageGuard.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
session_start();
|
require_once __DIR__ . '/../session.php';
|
||||||
require '../config.php';
|
require '../config.php';
|
||||||
require '../include_header.php';
|
require '../include_header.php';
|
||||||
?>
|
?>
|
||||||
@@ -179,7 +179,7 @@
|
|||||||
|
|
||||||
<?php require '../include_ending.php'; ?>
|
<?php require '../include_ending.php'; ?>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
|
<script src="<?php echo $server_url?>assets/vendor/chart.js/4.5.1/chart.umd.min.js"></script>
|
||||||
<style>
|
<style>
|
||||||
/* Brief yellow flash when a card updates silently via WebSocket */
|
/* Brief yellow flash when a card updates silently via WebSocket */
|
||||||
@keyframes card-flash {
|
@keyframes card-flash {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
session_start();
|
require_once __DIR__ . '/../session.php';
|
||||||
require '../config.php';
|
require '../config.php';
|
||||||
require '../include_header.php';
|
require '../include_header.php';
|
||||||
?>
|
?>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
session_start();
|
require_once __DIR__ . '/../../../session.php';
|
||||||
require_once '../../../assets/utils/db_auth.php';
|
require_once '../../../assets/utils/db_auth.php';
|
||||||
require_once '../../../assets/utils/classes_ac/ChartOfAccounts.php';
|
require_once '../../../assets/utils/classes_ac/ChartOfAccounts.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
session_start();
|
require_once __DIR__ . '/../../../session.php';
|
||||||
require_once '../../../assets/utils/db_auth.php';
|
require_once '../../../assets/utils/db_auth.php';
|
||||||
require_role($user_role, ['owner', 'admin', 'staff']);
|
require_role($user_role, ['owner', 'admin', 'staff']);
|
||||||
require_once '../../../assets/utils/classes_ac/AccountFormulaManager.php';
|
require_once '../../../assets/utils/classes_ac/AccountFormulaManager.php';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
session_start();
|
require_once __DIR__ . '/../../../session.php';
|
||||||
require_once '../../../assets/utils/db_auth.php';
|
require_once '../../../assets/utils/db_auth.php';
|
||||||
require_once '../../../assets/utils/classes/UsageGuard.php';
|
require_once '../../../assets/utils/classes/UsageGuard.php';
|
||||||
require_once '../../../assets/utils/classes_ac/ChartOfAccounts.php';
|
require_once '../../../assets/utils/classes_ac/ChartOfAccounts.php';
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
session_start();
|
require_once __DIR__ . '/../../../session.php';
|
||||||
require_once '../../../assets/utils/db_auth.php';
|
require_once '../../../assets/utils/db_auth.php';
|
||||||
require_once '../../../assets/utils/classes/OperationLockManager.php';
|
require_once '../../../assets/utils/classes/OperationLockManager.php';
|
||||||
|
|
||||||
|
// Batch GL posting takes this lock; posting itself is owner/admin only.
|
||||||
|
require_role($user_role, ['owner', 'admin']);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$lock_manager = new OperationLockManager($pdo2, $company_id, $user_id);
|
$lock_manager = new OperationLockManager($pdo2, $company_id, $user_id);
|
||||||
$result = $lock_manager->acquire(
|
$result = $lock_manager->acquire(
|
||||||
@@ -10,7 +13,15 @@ try {
|
|||||||
(int)($data['ttl_minutes'] ?? 120)
|
(int)($data['ttl_minutes'] ?? 120)
|
||||||
);
|
);
|
||||||
$answer = array_merge($answer, $result);
|
$answer = array_merge($answer, $result);
|
||||||
|
if (empty($result['success'])) {
|
||||||
|
http_response_code(409); // another tab or user holds the lock
|
||||||
|
}
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
error_log('[acquire_op_lock] ' . $e->getMessage());
|
||||||
|
http_response_code(500);
|
||||||
|
$answer['message'] = 'Database error, please try again.';
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
|
http_response_code(400);
|
||||||
$answer['message'] = $e->getMessage();
|
$answer['message'] = $e->getMessage();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
session_start();
|
require_once __DIR__ . '/../../../session.php';
|
||||||
require_once '../../../assets/utils/db_auth.php';
|
require_once '../../../assets/utils/db_auth.php';
|
||||||
require_once '../../../assets/utils/classes_ac/DepartmentManager.php';
|
require_once '../../../assets/utils/classes_ac/DepartmentManager.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
session_start();
|
require_once __DIR__ . '/../../../session.php';
|
||||||
require_once '../../../assets/utils/db_auth.php';
|
require_once '../../../assets/utils/db_auth.php';
|
||||||
require_once '../../../assets/utils/classes/UsageGuard.php';
|
require_once '../../../assets/utils/classes/UsageGuard.php';
|
||||||
require_once '../../../assets/utils/classes_ac/DepartmentManager.php';
|
require_once '../../../assets/utils/classes_ac/DepartmentManager.php';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
session_start();
|
require_once __DIR__ . '/../../../session.php';
|
||||||
require_once '../../../assets/utils/db_auth.php';
|
require_once '../../../assets/utils/db_auth.php';
|
||||||
require_once '../../../assets/utils/classes/UsageGuard.php';
|
require_once '../../../assets/utils/classes/UsageGuard.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
session_start();
|
require_once __DIR__ . '/../../../session.php';
|
||||||
require_once '../../../assets/utils/db_auth.php';
|
require_once '../../../assets/utils/db_auth.php';
|
||||||
require_once '../../../assets/utils/classes_ac/GlManager.php';
|
require_once '../../../assets/utils/classes_ac/GlManager.php';
|
||||||
require_once '../../../assets/utils/classes_ac/GlQueryManager.php';
|
require_once '../../../assets/utils/classes_ac/GlQueryManager.php';
|
||||||
@@ -8,6 +8,7 @@ $doc_type = trim((string)($data['doc_type'] ?? ''));
|
|||||||
$source_id = (int)($data['source_id'] ?? 0);
|
$source_id = (int)($data['source_id'] ?? 0);
|
||||||
|
|
||||||
if (!$doc_type || $source_id <= 0) {
|
if (!$doc_type || $source_id <= 0) {
|
||||||
|
http_response_code(400);
|
||||||
$answer['message'] = 'doc_type and source_id required.';
|
$answer['message'] = 'doc_type and source_id required.';
|
||||||
exit(json_encode($answer));
|
exit(json_encode($answer));
|
||||||
}
|
}
|
||||||
@@ -27,8 +28,13 @@ try {
|
|||||||
|
|
||||||
$answer['success'] = 1;
|
$answer['success'] = 1;
|
||||||
$answer['output'] = $detail;
|
$answer['output'] = $detail;
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
error_log('[' . basename(__FILE__) . '] ' . $e->getMessage());
|
||||||
|
$answer['message'] = 'Database error, please try again.';
|
||||||
|
http_response_code(500);
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
$answer['message'] = $e->getMessage();
|
$answer['message'] = $e->getMessage();
|
||||||
|
http_response_code(400);
|
||||||
}
|
}
|
||||||
|
|
||||||
exit(json_encode($answer));
|
exit(json_encode($answer));
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
session_start();
|
require_once __DIR__ . '/../../../session.php';
|
||||||
require_once '../../../assets/utils/db_auth.php';
|
require_once '../../../assets/utils/db_auth.php';
|
||||||
require_once '../../../assets/utils/classes_ac/GlQueryManager.php';
|
require_once '../../../assets/utils/classes_ac/GlQueryManager.php';
|
||||||
|
|
||||||
@@ -12,8 +12,13 @@ try {
|
|||||||
(int)($data['formula_id'] ?? 0)
|
(int)($data['formula_id'] ?? 0)
|
||||||
);
|
);
|
||||||
$answer['success'] = 1;
|
$answer['success'] = 1;
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
error_log('[' . basename(__FILE__) . '] ' . $e->getMessage());
|
||||||
|
$answer['message'] = 'Database error, please try again.';
|
||||||
|
http_response_code(500);
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
$answer['message'] = $e->getMessage();
|
$answer['message'] = $e->getMessage();
|
||||||
|
http_response_code(400);
|
||||||
}
|
}
|
||||||
|
|
||||||
exit(json_encode($answer));
|
exit(json_encode($answer));
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
session_start();
|
require_once __DIR__ . '/../../../session.php';
|
||||||
require_once '../../../assets/utils/db_auth.php';
|
require_once '../../../assets/utils/db_auth.php';
|
||||||
require_once '../../../assets/utils/classes/UsageGuard.php';
|
require_once '../../../assets/utils/classes/UsageGuard.php';
|
||||||
|
|
||||||
@@ -12,6 +12,7 @@ $to_date = trim((string)($data['to_date'] ?? ($data['to_period'] ??
|
|||||||
$dept_id = (int)($data['department_id'] ?? 0);
|
$dept_id = (int)($data['department_id'] ?? 0);
|
||||||
|
|
||||||
if ($account_code === '') {
|
if ($account_code === '') {
|
||||||
|
http_response_code(400);
|
||||||
$answer['message'] = 'account_code is required.';
|
$answer['message'] = 'account_code is required.';
|
||||||
exit(json_encode($answer));
|
exit(json_encode($answer));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
session_start();
|
require_once __DIR__ . '/../../../session.php';
|
||||||
require_once '../../../assets/utils/db_auth.php';
|
require_once '../../../assets/utils/db_auth.php';
|
||||||
require_once '../../../assets/utils/classes_ac/GlQueryManager.php';
|
require_once '../../../assets/utils/classes_ac/GlQueryManager.php';
|
||||||
|
|
||||||
@@ -7,8 +7,13 @@ try {
|
|||||||
$gl_query = new GlQueryManager($pdo2, $company_id);
|
$gl_query = new GlQueryManager($pdo2, $company_id);
|
||||||
$answer['output'] = $gl_query->getJournalDetail((int)($data['gl_id'] ?? 0));
|
$answer['output'] = $gl_query->getJournalDetail((int)($data['gl_id'] ?? 0));
|
||||||
$answer['success'] = 1;
|
$answer['success'] = 1;
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
error_log('[' . basename(__FILE__) . '] ' . $e->getMessage());
|
||||||
|
$answer['message'] = 'Database error, please try again.';
|
||||||
|
http_response_code(500);
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
$answer['message'] = $e->getMessage();
|
$answer['message'] = $e->getMessage();
|
||||||
|
http_response_code(400);
|
||||||
}
|
}
|
||||||
|
|
||||||
exit(json_encode($answer));
|
exit(json_encode($answer));
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
session_start();
|
require_once __DIR__ . '/../../../session.php';
|
||||||
require_once '../../../assets/utils/db_auth.php';
|
require_once '../../../assets/utils/db_auth.php';
|
||||||
require_once '../../../assets/utils/classes_ac/GlQueryManager.php';
|
require_once '../../../assets/utils/classes_ac/GlQueryManager.php';
|
||||||
|
|
||||||
@@ -11,8 +11,13 @@ try {
|
|||||||
trim((string)($data['date_to'] ?? ''))
|
trim((string)($data['date_to'] ?? ''))
|
||||||
);
|
);
|
||||||
$answer['success'] = 1;
|
$answer['success'] = 1;
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
error_log('[' . basename(__FILE__) . '] ' . $e->getMessage());
|
||||||
|
$answer['message'] = 'Database error, please try again.';
|
||||||
|
http_response_code(500);
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
$answer['message'] = $e->getMessage();
|
$answer['message'] = $e->getMessage();
|
||||||
|
http_response_code(400);
|
||||||
}
|
}
|
||||||
|
|
||||||
exit(json_encode($answer));
|
exit(json_encode($answer));
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
session_start();
|
require_once __DIR__ . '/../../../session.php';
|
||||||
require_once '../../../assets/utils/db_auth.php';
|
require_once '../../../assets/utils/db_auth.php';
|
||||||
require_once '../../../assets/utils/classes/UsageGuard.php';
|
require_once '../../../assets/utils/classes/UsageGuard.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
session_start();
|
require_once __DIR__ . '/../../../session.php';
|
||||||
require_once '../../../assets/utils/db_auth.php';
|
require_once '../../../assets/utils/db_auth.php';
|
||||||
require_once '../../../assets/utils/classes/UsageGuard.php';
|
require_once '../../../assets/utils/classes/UsageGuard.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
session_start();
|
require_once __DIR__ . '/../../../session.php';
|
||||||
require_once '../../../assets/utils/db_auth.php';
|
require_once '../../../assets/utils/db_auth.php';
|
||||||
require_once '../../../assets/utils/classes/UsageGuard.php';
|
require_once '../../../assets/utils/classes/UsageGuard.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,22 @@
|
|||||||
<?php
|
<?php
|
||||||
session_start();
|
require_once __DIR__ . '/../../../session.php';
|
||||||
require_once '../../../assets/utils/db_auth.php';
|
require_once '../../../assets/utils/db_auth.php';
|
||||||
require_once '../../../assets/utils/classes/BatchActionManager.php';
|
require_once '../../../assets/utils/classes/BatchActionManager.php';
|
||||||
|
|
||||||
|
// Logged at the end of a batch GL posting run, which is owner/admin only.
|
||||||
|
require_role($user_role, ['owner', 'admin']);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$batch_action = new BatchActionManager($pdo2, $company_id, $user_id);
|
$batch_action = new BatchActionManager($pdo2, $company_id, $user_id);
|
||||||
$batch_action->log($data);
|
$batch_action->log($data);
|
||||||
$answer['success'] = 1;
|
$answer['success'] = 1;
|
||||||
$answer['message'] = 'Batch action logged.';
|
$answer['message'] = 'Batch action logged.';
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
error_log('[log_batch_action] ' . $e->getMessage());
|
||||||
|
http_response_code(500);
|
||||||
|
$answer['message'] = 'Database error, please try again.';
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
|
http_response_code(400);
|
||||||
$answer['message'] = $e->getMessage();
|
$answer['message'] = $e->getMessage();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
<?php
|
<?php
|
||||||
session_start();
|
require_once __DIR__ . '/../../../session.php';
|
||||||
require_once '../../../assets/utils/db_auth.php';
|
require_once '../../../assets/utils/db_auth.php';
|
||||||
require_once '../../../assets/utils/classes_ac/ChartOfAccounts.php';
|
require_once '../../../assets/utils/classes_ac/ChartOfAccounts.php';
|
||||||
|
|
||||||
require_role($user_role, ['owner', 'admin']);
|
require_role($user_role, ['owner', 'admin']);
|
||||||
|
|
||||||
if (empty($data['account_code']) || empty($data['account_name']) || empty($data['account_type'])) {
|
if (empty($data['account_code']) || empty($data['account_name']) || empty($data['account_type'])) {
|
||||||
|
http_response_code(400);
|
||||||
$answer['message'] = 'Account code, name, and type are required';
|
$answer['message'] = 'Account code, name, and type are required';
|
||||||
exit(json_encode($answer));
|
exit(json_encode($answer));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
<?php
|
<?php
|
||||||
session_start();
|
require_once __DIR__ . '/../../../session.php';
|
||||||
require_once '../../../assets/utils/db_auth.php';
|
require_once '../../../assets/utils/db_auth.php';
|
||||||
require_once '../../../assets/utils/classes_ac/DepartmentManager.php';
|
require_once '../../../assets/utils/classes_ac/DepartmentManager.php';
|
||||||
|
|
||||||
require_role($user_role, ['owner', 'admin']);
|
require_role($user_role, ['owner', 'admin']);
|
||||||
|
|
||||||
if (empty($data['dept_code']) || empty($data['dept_name'])) {
|
if (empty($data['dept_code']) || empty($data['dept_name'])) {
|
||||||
|
http_response_code(400);
|
||||||
$answer['message'] = 'Department code and name are required';
|
$answer['message'] = 'Department code and name are required';
|
||||||
exit(json_encode($answer));
|
exit(json_encode($answer));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
session_start();
|
require_once __DIR__ . '/../../../session.php';
|
||||||
require_once '../../../assets/utils/db_auth.php';
|
require_once '../../../assets/utils/db_auth.php';
|
||||||
require_once '../../../assets/utils/classes_ac/PostingWindowGuard.php';
|
require_once '../../../assets/utils/classes_ac/PostingWindowGuard.php';
|
||||||
require_once '../../../assets/utils/classes_ac/GlManager.php';
|
require_once '../../../assets/utils/classes_ac/GlManager.php';
|
||||||
@@ -30,6 +30,7 @@ $posting_map = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
if (!isset($posting_map[$doc_type]) || $id <= 0) {
|
if (!isset($posting_map[$doc_type]) || $id <= 0) {
|
||||||
|
http_response_code(400);
|
||||||
$answer['message'] = 'Invalid doc_type or id.';
|
$answer['message'] = 'Invalid doc_type or id.';
|
||||||
exit(json_encode($answer));
|
exit(json_encode($answer));
|
||||||
}
|
}
|
||||||
@@ -73,9 +74,15 @@ try {
|
|||||||
'has_expense' => $has_expense,
|
'has_expense' => $has_expense,
|
||||||
], $company_id);
|
], $company_id);
|
||||||
|
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
if ($pdo2->inTransaction()) $pdo2->rollBack();
|
||||||
|
error_log('[' . basename(__FILE__) . '] ' . $e->getMessage());
|
||||||
|
$answer['message'] = 'Database error, please try again.';
|
||||||
|
http_response_code(500);
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
if ($pdo2->inTransaction()) $pdo2->rollBack();
|
if ($pdo2->inTransaction()) $pdo2->rollBack();
|
||||||
$answer['message'] = $e->getMessage();
|
$answer['message'] = $e->getMessage();
|
||||||
|
http_response_code(400);
|
||||||
}
|
}
|
||||||
|
|
||||||
exit(json_encode($answer));
|
exit(json_encode($answer));
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
session_start();
|
require_once __DIR__ . '/../../../session.php';
|
||||||
require_once '../../../assets/utils/db_auth.php';
|
require_once '../../../assets/utils/db_auth.php';
|
||||||
require_once '../../../assets/utils/classes/CompanySettingManager.php';
|
require_once '../../../assets/utils/classes/CompanySettingManager.php';
|
||||||
require_role($user_role, ['owner', 'admin']);
|
require_role($user_role, ['owner', 'admin']);
|
||||||
@@ -30,14 +30,17 @@ if ($data['action'] === 'save') {
|
|||||||
$to = trim((string)($data['open_to'] ?? ''));
|
$to = trim((string)($data['open_to'] ?? ''));
|
||||||
|
|
||||||
if ($from !== '' && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $from)) {
|
if ($from !== '' && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $from)) {
|
||||||
|
http_response_code(400);
|
||||||
$answer['message'] = 'Invalid open_from date. Use YYYY-MM-DD.';
|
$answer['message'] = 'Invalid open_from date. Use YYYY-MM-DD.';
|
||||||
exit(json_encode($answer));
|
exit(json_encode($answer));
|
||||||
}
|
}
|
||||||
if ($to !== '' && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $to)) {
|
if ($to !== '' && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $to)) {
|
||||||
|
http_response_code(400);
|
||||||
$answer['message'] = 'Invalid open_to date. Use YYYY-MM-DD.';
|
$answer['message'] = 'Invalid open_to date. Use YYYY-MM-DD.';
|
||||||
exit(json_encode($answer));
|
exit(json_encode($answer));
|
||||||
}
|
}
|
||||||
if ($from && $to && $from > $to) {
|
if ($from && $to && $from > $to) {
|
||||||
|
http_response_code(400);
|
||||||
$answer['message'] = 'Open From must be on or before Open To.';
|
$answer['message'] = 'Open From must be on or before Open To.';
|
||||||
exit(json_encode($answer));
|
exit(json_encode($answer));
|
||||||
}
|
}
|
||||||
@@ -50,5 +53,6 @@ if ($data['action'] === 'save') {
|
|||||||
exit(json_encode($answer));
|
exit(json_encode($answer));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
http_response_code(400);
|
||||||
$answer['message'] = 'Invalid action.';
|
$answer['message'] = 'Invalid action.';
|
||||||
exit(json_encode($answer));
|
exit(json_encode($answer));
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
session_start();
|
require_once __DIR__ . '/../../../session.php';
|
||||||
require_once '../../../assets/utils/db_auth.php';
|
require_once '../../../assets/utils/db_auth.php';
|
||||||
require_once '../../../assets/utils/classes/ProductManager.php';
|
require_once '../../../assets/utils/classes/ProductManager.php';
|
||||||
require_once '../../../assets/utils/classes_ac/ChartOfAccounts.php';
|
require_once '../../../assets/utils/classes_ac/ChartOfAccounts.php';
|
||||||
|
|||||||
@@ -1,14 +1,22 @@
|
|||||||
<?php
|
<?php
|
||||||
session_start();
|
require_once __DIR__ . '/../../../session.php';
|
||||||
require_once '../../../assets/utils/db_auth.php';
|
require_once '../../../assets/utils/db_auth.php';
|
||||||
require_once '../../../assets/utils/classes/OperationLockManager.php';
|
require_once '../../../assets/utils/classes/OperationLockManager.php';
|
||||||
|
|
||||||
|
// Batch GL posting takes this lock; posting itself is owner/admin only.
|
||||||
|
require_role($user_role, ['owner', 'admin']);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$lock_manager = new OperationLockManager($pdo2, $company_id, $user_id);
|
$lock_manager = new OperationLockManager($pdo2, $company_id, $user_id);
|
||||||
$lock_manager->release(trim((string)($data['operation_type'] ?? '')));
|
$lock_manager->release(trim((string)($data['operation_type'] ?? '')));
|
||||||
$answer['success'] = 1;
|
$answer['success'] = 1;
|
||||||
$answer['message'] = 'Lock released.';
|
$answer['message'] = 'Lock released.';
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
error_log('[release_op_lock] ' . $e->getMessage());
|
||||||
|
http_response_code(500);
|
||||||
|
$answer['message'] = 'Database error, please try again.';
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
|
http_response_code(400);
|
||||||
$answer['message'] = $e->getMessage();
|
$answer['message'] = $e->getMessage();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
session_start();
|
require_once __DIR__ . '/../../../session.php';
|
||||||
require_once '../../../assets/utils/db_auth.php';
|
require_once '../../../assets/utils/db_auth.php';
|
||||||
require_once '../../../assets/utils/classes_ac/ChartOfAccounts.php';
|
require_once '../../../assets/utils/classes_ac/ChartOfAccounts.php';
|
||||||
|
|
||||||
@@ -7,6 +7,7 @@ require_role($user_role, ['owner', 'admin']);
|
|||||||
|
|
||||||
$id = (int)($data['id'] ?? 0);
|
$id = (int)($data['id'] ?? 0);
|
||||||
if (!$id) {
|
if (!$id) {
|
||||||
|
http_response_code(400);
|
||||||
$answer['message'] = 'Missing id';
|
$answer['message'] = 'Missing id';
|
||||||
exit(json_encode($answer));
|
exit(json_encode($answer));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<?php
|
<?php
|
||||||
session_start();
|
require_once __DIR__ . '/../../../session.php';
|
||||||
require_once '../../../assets/utils/db_auth.php';
|
require_once '../../../assets/utils/db_auth.php';
|
||||||
require_once '../../../assets/utils/classes_ac/DepartmentManager.php';
|
require_once '../../../assets/utils/classes_ac/DepartmentManager.php';
|
||||||
|
|
||||||
require_role($user_role, ['owner', 'admin']);
|
require_role($user_role, ['owner', 'admin']);
|
||||||
|
|
||||||
$id = (int)($data['id'] ?? 0);
|
$id = (int)($data['id'] ?? 0);
|
||||||
if (!$id) { $answer['message'] = 'Missing id'; exit(json_encode($answer)); }
|
if (!$id) { http_response_code(400); $answer['message'] = 'Missing id'; exit(json_encode($answer)); }
|
||||||
|
|
||||||
$dept = new DepartmentManager($pdo2, $company_id);
|
$dept = new DepartmentManager($pdo2, $company_id);
|
||||||
$dept->delete($id);
|
$dept->delete($id);
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
session_start();
|
require_once __DIR__ . '/../../../session.php';
|
||||||
require_once '../../../assets/utils/db_auth.php';
|
require_once '../../../assets/utils/db_auth.php';
|
||||||
require_once '../../../assets/utils/classes_ac/ChartOfAccounts.php';
|
require_once '../../../assets/utils/classes_ac/ChartOfAccounts.php';
|
||||||
|
|
||||||
$id = (int)($data['id'] ?? 0);
|
$id = (int)($data['id'] ?? 0);
|
||||||
if (!$id) {
|
if (!$id) {
|
||||||
|
http_response_code(400);
|
||||||
$answer['message'] = 'Missing id';
|
$answer['message'] = 'Missing id';
|
||||||
exit(json_encode($answer));
|
exit(json_encode($answer));
|
||||||
}
|
}
|
||||||
@@ -12,6 +13,7 @@ if (!$id) {
|
|||||||
$coa = new ChartOfAccounts($pdo2, $company_id);
|
$coa = new ChartOfAccounts($pdo2, $company_id);
|
||||||
$row = $coa->getById($id);
|
$row = $coa->getById($id);
|
||||||
if (!$row) {
|
if (!$row) {
|
||||||
|
http_response_code(404);
|
||||||
$answer['message'] = 'Account not found';
|
$answer['message'] = 'Account not found';
|
||||||
exit(json_encode($answer));
|
exit(json_encode($answer));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
<?php
|
<?php
|
||||||
session_start();
|
require_once __DIR__ . '/../../../session.php';
|
||||||
require_once '../../../assets/utils/db_auth.php';
|
require_once '../../../assets/utils/db_auth.php';
|
||||||
require_once '../../../assets/utils/classes_ac/DepartmentManager.php';
|
require_once '../../../assets/utils/classes_ac/DepartmentManager.php';
|
||||||
|
|
||||||
$id = (int)($data['id'] ?? 0);
|
$id = (int)($data['id'] ?? 0);
|
||||||
if (!$id) { $answer['message'] = 'Missing id'; exit(json_encode($answer)); }
|
if (!$id) { http_response_code(400); $answer['message'] = 'Missing id'; exit(json_encode($answer)); }
|
||||||
|
|
||||||
$dept = new DepartmentManager($pdo2, $company_id);
|
$dept = new DepartmentManager($pdo2, $company_id);
|
||||||
$row = $dept->getById($id);
|
$row = $dept->getById($id);
|
||||||
if (!$row) { $answer['message'] = 'Department not found'; exit(json_encode($answer)); }
|
if (!$row) { http_response_code(404); $answer['message'] = 'Department not found'; exit(json_encode($answer)); }
|
||||||
|
|
||||||
$answer['output'] = $row;
|
$answer['output'] = $row;
|
||||||
$answer['success'] = 1;
|
$answer['success'] = 1;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
session_start();
|
require_once __DIR__ . '/../../../session.php';
|
||||||
require_once '../../../assets/utils/db_auth.php';
|
require_once '../../../assets/utils/db_auth.php';
|
||||||
require_once '../../../assets/utils/classes/DocumentNumberManager.php';
|
require_once '../../../assets/utils/classes/DocumentNumberManager.php';
|
||||||
require_once '../../../assets/utils/classes_ac/PostingWindowGuard.php';
|
require_once '../../../assets/utils/classes_ac/PostingWindowGuard.php';
|
||||||
@@ -25,6 +25,7 @@ if (preg_match('#^(\d{2})/(\d{2})/(\d{4})$#', $journal_date, $m)) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!$journal_date || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $journal_date)) {
|
if (!$journal_date || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $journal_date)) {
|
||||||
|
http_response_code(400);
|
||||||
$answer['message'] = 'Valid journal date is required.';
|
$answer['message'] = 'Valid journal date is required.';
|
||||||
exit(json_encode($answer));
|
exit(json_encode($answer));
|
||||||
}
|
}
|
||||||
@@ -51,11 +52,13 @@ foreach ($lines_raw as $l) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (count($lines) < 2) {
|
if (count($lines) < 2) {
|
||||||
|
http_response_code(400);
|
||||||
$answer['message'] = 'At least two journal lines are required.';
|
$answer['message'] = 'At least two journal lines are required.';
|
||||||
exit(json_encode($answer));
|
exit(json_encode($answer));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (abs($total_debit - $total_credit) > 0.005) {
|
if (abs($total_debit - $total_credit) > 0.005) {
|
||||||
|
http_response_code(400);
|
||||||
$answer['message'] = 'Journal is not balanced. Debit ' . number_format($total_debit, 2) . ' ≠ Credit ' . number_format($total_credit, 2) . '.';
|
$answer['message'] = 'Journal is not balanced. Debit ' . number_format($total_debit, 2) . ' ≠ Credit ' . number_format($total_credit, 2) . '.';
|
||||||
exit(json_encode($answer));
|
exit(json_encode($answer));
|
||||||
}
|
}
|
||||||
@@ -83,9 +86,15 @@ try {
|
|||||||
$answer['success'] = 1;
|
$answer['success'] = 1;
|
||||||
$answer['gl_id'] = $gl_id;
|
$answer['gl_id'] = $gl_id;
|
||||||
notify_node('gl_posted', gl_posted_payload('manual', (int)$gl_id, $event_action, $lines), $company_id);
|
notify_node('gl_posted', gl_posted_payload('manual', (int)$gl_id, $event_action, $lines), $company_id);
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
if ($pdo2->inTransaction()) $pdo2->rollBack();
|
||||||
|
error_log('[' . basename(__FILE__) . '] ' . $e->getMessage());
|
||||||
|
$answer['message'] = 'Database error, please try again.';
|
||||||
|
http_response_code(500);
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
if ($pdo2->inTransaction()) $pdo2->rollBack();
|
if ($pdo2->inTransaction()) $pdo2->rollBack();
|
||||||
$answer['message'] = $e->getMessage();
|
$answer['message'] = $e->getMessage();
|
||||||
|
http_response_code(400);
|
||||||
}
|
}
|
||||||
|
|
||||||
exit(json_encode($answer));
|
exit(json_encode($answer));
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
session_start();
|
require_once __DIR__ . '/../session.php';
|
||||||
require '../config.php';
|
require '../config.php';
|
||||||
require '../include_header.php';
|
require '../include_header.php';
|
||||||
?>
|
?>
|
||||||
@@ -221,9 +221,9 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script>
|
<script src="<?php echo $server_url?>assets/vendor/xlsx/0.18.5/xlsx.full.min.js"></script>
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
|
<script src="<?php echo $server_url?>assets/vendor/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>
|
<script src="<?php echo $server_url?>assets/vendor/jspdf-autotable/3.8.2/jspdf.plugin.autotable.min.js"></script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
session_start();
|
require_once __DIR__ . '/../session.php';
|
||||||
require '../config.php';
|
require '../config.php';
|
||||||
require '../include_header.php';
|
require '../include_header.php';
|
||||||
?>
|
?>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
session_start();
|
require_once __DIR__ . '/../session.php';
|
||||||
require '../config.php';
|
require '../config.php';
|
||||||
require '../include_header.php';
|
require '../include_header.php';
|
||||||
?>
|
?>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
session_start();
|
require_once __DIR__ . '/../session.php';
|
||||||
require '../config.php';
|
require '../config.php';
|
||||||
require '../include_header.php';
|
require '../include_header.php';
|
||||||
?>
|
?>
|
||||||
@@ -262,7 +262,7 @@
|
|||||||
'<td>' + escape_html(r.doc_number) + '</td>' +
|
'<td>' + escape_html(r.doc_number) + '</td>' +
|
||||||
'<td>' + escape_html(r.contact_name || '—') + '</td>' +
|
'<td>' + escape_html(r.contact_name || '—') + '</td>' +
|
||||||
'<td class="text-end">' + format_number(r.grand_total, 2) + '</td>' +
|
'<td class="text-end">' + format_number(r.grand_total, 2) + '</td>' +
|
||||||
'<td>' + escape_html(r.doc_date || '—') + '</td>' +
|
'<td>' + escape_html(format_date(r.doc_date)) + '</td>' +
|
||||||
'<td>' + mapping_badge + '</td>' +
|
'<td>' + mapping_badge + '</td>' +
|
||||||
'<td>' + status_badge + '</td>' +
|
'<td>' + status_badge + '</td>' +
|
||||||
'</tr>';
|
'</tr>';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
session_start();
|
require_once __DIR__ . '/../session.php';
|
||||||
require '../config.php';
|
require '../config.php';
|
||||||
require '../include_header.php';
|
require '../include_header.php';
|
||||||
?>
|
?>
|
||||||
@@ -198,7 +198,7 @@
|
|||||||
export_data.push({ date:r.entry_date||'', period:r.period||'', department:r.dept_code||'', reference:r.reference||'', description:r.line_description||r.gl_description||'', debit:dr||'', credit:cr||'', balance:running });
|
export_data.push({ date:r.entry_date||'', period:r.period||'', department:r.dept_code||'', reference:r.reference||'', description:r.line_description||r.gl_description||'', debit:dr||'', credit:cr||'', balance:running });
|
||||||
var bal_color = running >= 0 ? '' : 'text-danger';
|
var bal_color = running >= 0 ? '' : 'text-danger';
|
||||||
html += '<tr>' +
|
html += '<tr>' +
|
||||||
'<td class="small">' + escape_html(r.entry_date || '—') + '</td>' +
|
'<td class="small">' + escape_html(format_date(r.entry_date)) + '</td>' +
|
||||||
'<td class="small">' + escape_html(r.period) + '</td>' +
|
'<td class="small">' + escape_html(r.period) + '</td>' +
|
||||||
'<td class="small">' + (r.dept_code ? escape_html(r.dept_code) : '<span class="text-muted">—</span>') + '</td>' +
|
'<td class="small">' + (r.dept_code ? escape_html(r.dept_code) : '<span class="text-muted">—</span>') + '</td>' +
|
||||||
'<td class="small">' + escape_html(r.reference || '—') + '</td>' +
|
'<td class="small">' + escape_html(r.reference || '—') + '</td>' +
|
||||||
@@ -246,9 +246,9 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script>
|
<script src="<?php echo $server_url?>assets/vendor/xlsx/0.18.5/xlsx.full.min.js"></script>
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
|
<script src="<?php echo $server_url?>assets/vendor/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>
|
<script src="<?php echo $server_url?>assets/vendor/jspdf-autotable/3.8.2/jspdf.plugin.autotable.min.js"></script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
session_start();
|
require_once __DIR__ . '/../session.php';
|
||||||
require '../config.php';
|
require '../config.php';
|
||||||
require '../include_header.php';
|
require '../include_header.php';
|
||||||
?>
|
?>
|
||||||
@@ -281,7 +281,7 @@
|
|||||||
'<td class="text-muted small">' + escape_html(r.formula_name || '—') + '</td>' +
|
'<td class="text-muted small">' + escape_html(r.formula_name || '—') + '</td>' +
|
||||||
'<td class="text-end">' + format_number(r.total_debit, 2) + '</td>' +
|
'<td class="text-end">' + format_number(r.total_debit, 2) + '</td>' +
|
||||||
'<td class="text-end">' + format_number(r.total_credit, 2) + '</td>' +
|
'<td class="text-end">' + format_number(r.total_credit, 2) + '</td>' +
|
||||||
'<td class="text-muted small">' + escape_html(r.posted_at) + '</td>' +
|
'<td class="text-muted small">' + escape_html(format_date(r.posted_at)) + '</td>' +
|
||||||
'<td>' +
|
'<td>' +
|
||||||
'<a href="javascript:;" onclick="show_journal_detail(' + r.id + ',\'' + escape_html(r.doc_number || '') + '\')" title="View lines">' +
|
'<a href="javascript:;" onclick="show_journal_detail(' + r.id + ',\'' + escape_html(r.doc_number || '') + '\')" title="View lines">' +
|
||||||
'<i class="ti ti-eye fs-5"></i></a>' +
|
'<i class="ti ti-eye fs-5"></i></a>' +
|
||||||
@@ -509,7 +509,7 @@
|
|||||||
|
|
||||||
var extra_fields = h.source_type === 'manual'
|
var extra_fields = h.source_type === 'manual'
|
||||||
? '<div class="col-sm-4"><div class="text-muted small">Reference</div><div class="fw-semibold">' + escape_html(h.reference || ('MJE-' + h.id)) + '</div></div>' +
|
? '<div class="col-sm-4"><div class="text-muted small">Reference</div><div class="fw-semibold">' + escape_html(h.reference || ('MJE-' + h.id)) + '</div></div>' +
|
||||||
'<div class="col-sm-4"><div class="text-muted small">Journal Date</div><div>' + escape_html(h.journal_date_fmt || '—') + '</div></div>' +
|
'<div class="col-sm-4"><div class="text-muted small">Journal Date</div><div>' + escape_html(format_date(h.journal_date)) + '</div></div>' +
|
||||||
'<div class="col-sm-4"><div class="text-muted small">Description</div><div>' + escape_html(h.description || '—') + '</div></div>'
|
'<div class="col-sm-4"><div class="text-muted small">Description</div><div>' + escape_html(h.description || '—') + '</div></div>'
|
||||||
: '<div class="col-sm-4"><div class="text-muted small">Formula</div><div>' + escape_html(h.formula_name) + '</div></div>' +
|
: '<div class="col-sm-4"><div class="text-muted small">Formula</div><div>' + escape_html(h.formula_name) + '</div></div>' +
|
||||||
'<div class="col-sm-4"><div class="text-muted small">Version</div><div>v' + h.current_version + (h.current_version > 1 ? ' <span class="text-muted small">(replaced)</span>' : '') + '</div></div>' +
|
'<div class="col-sm-4"><div class="text-muted small">Version</div><div>v' + h.current_version + (h.current_version > 1 ? ' <span class="text-muted small">(replaced)</span>' : '') + '</div></div>' +
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
session_start();
|
require_once __DIR__ . '/../session.php';
|
||||||
require '../config.php';
|
require '../config.php';
|
||||||
require '../include_header.php';
|
require '../include_header.php';
|
||||||
?>
|
?>
|
||||||
@@ -222,7 +222,7 @@
|
|||||||
autoPrepare: true,
|
autoPrepare: true,
|
||||||
checkRequired: 0,
|
checkRequired: 0,
|
||||||
action: 'read',
|
action: 'read',
|
||||||
data: { id: <?php echo (int)$_GET['id']; ?> },
|
data: { id: <?php echo (int)($_GET['id'] ?? 0); ?> },
|
||||||
onSuccess: function(res) {
|
onSuccess: function(res) {
|
||||||
var item = res.output;
|
var item = res.output;
|
||||||
$('#id').val(item.id);
|
$('#id').val(item.id);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
session_start();
|
require_once __DIR__ . '/../session.php';
|
||||||
require '../config.php';
|
require '../config.php';
|
||||||
require '../include_header.php';
|
require '../include_header.php';
|
||||||
?>
|
?>
|
||||||
@@ -89,7 +89,7 @@
|
|||||||
autoPrepare: true,
|
autoPrepare: true,
|
||||||
checkRequired: 0,
|
checkRequired: 0,
|
||||||
action: 'read',
|
action: 'read',
|
||||||
data: { id: <?php echo (int)$_GET['id']; ?> },
|
data: { id: <?php echo (int)($_GET['id'] ?? 0); ?> },
|
||||||
onSuccess: function(res) {
|
onSuccess: function(res) {
|
||||||
var item = res.output;
|
var item = res.output;
|
||||||
$('#id').val(item.id);
|
$('#id').val(item.id);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
session_start();
|
require_once __DIR__ . '/../session.php';
|
||||||
require '../config.php';
|
require '../config.php';
|
||||||
require '../include_header.php';
|
require '../include_header.php';
|
||||||
?>
|
?>
|
||||||
@@ -204,9 +204,9 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script>
|
<script src="<?php echo $server_url?>assets/vendor/xlsx/0.18.5/xlsx.full.min.js"></script>
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
|
<script src="<?php echo $server_url?>assets/vendor/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>
|
<script src="<?php echo $server_url?>assets/vendor/jspdf-autotable/3.8.2/jspdf.plugin.autotable.min.js"></script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
session_start();
|
require_once __DIR__ . '/../session.php';
|
||||||
require '../config.php';
|
require '../config.php';
|
||||||
require '../include_header.php';
|
require '../include_header.php';
|
||||||
?>
|
?>
|
||||||
@@ -157,8 +157,10 @@
|
|||||||
autoPrepare: true,
|
autoPrepare: true,
|
||||||
checkRequired: 0,
|
checkRequired: 0,
|
||||||
action: 'save',
|
action: 'save',
|
||||||
open_from: from,
|
// Inside `data`: as top-level options these were ignored, and the save
|
||||||
open_to: to,
|
// only worked because autoPrepare happens to sweep the two inputs, whose
|
||||||
|
// ids match the field names.
|
||||||
|
data: { open_from: from, open_to: to },
|
||||||
onSuccess: function() {
|
onSuccess: function() {
|
||||||
render_display(from, to);
|
render_display(from, to);
|
||||||
}
|
}
|
||||||
@@ -173,8 +175,7 @@
|
|||||||
autoPrepare: true,
|
autoPrepare: true,
|
||||||
checkRequired: 0,
|
checkRequired: 0,
|
||||||
action: 'save',
|
action: 'save',
|
||||||
open_from: '',
|
data: { open_from: '', open_to: '' },
|
||||||
open_to: '',
|
|
||||||
onSuccess: function() {
|
onSuccess: function() {
|
||||||
render_display('', '');
|
render_display('', '');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
session_start();
|
require_once __DIR__ . '/../session.php';
|
||||||
require '../config.php';
|
require '../config.php';
|
||||||
require '../include_header.php';
|
require '../include_header.php';
|
||||||
?>
|
?>
|
||||||
@@ -209,9 +209,9 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script>
|
<script src="<?php echo $server_url?>assets/vendor/xlsx/0.18.5/xlsx.full.min.js"></script>
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
|
<script src="<?php echo $server_url?>assets/vendor/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>
|
<script src="<?php echo $server_url?>assets/vendor/jspdf-autotable/3.8.2/jspdf.plugin.autotable.min.js"></script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
session_start();
|
require_once __DIR__ . '/../session.php';
|
||||||
require '../config.php';
|
require '../config.php';
|
||||||
require '../include_header.php';
|
require '../include_header.php';
|
||||||
?>
|
?>
|
||||||
@@ -210,7 +210,7 @@
|
|||||||
var html = '';
|
var html = '';
|
||||||
rows.forEach(function(r) {
|
rows.forEach(function(r) {
|
||||||
html += '<tr>' +
|
html += '<tr>' +
|
||||||
'<td class="small">' + escape_html(r.entry_date || '—') + '</td>' +
|
'<td class="small">' + escape_html(format_date(r.entry_date)) + '</td>' +
|
||||||
'<td class="small">' + escape_html(r.period) + '</td>' +
|
'<td class="small">' + escape_html(r.period) + '</td>' +
|
||||||
'<td class="small"><span class="badge bg-secondary bg-opacity-10 text-secondary">' + escape_html(src_labels[r.source_type] || r.source_type) + '</span></td>' +
|
'<td class="small"><span class="badge bg-secondary bg-opacity-10 text-secondary">' + escape_html(src_labels[r.source_type] || r.source_type) + '</span></td>' +
|
||||||
'<td class="small">' + (r.dept_code ? escape_html(r.dept_code) : '<span class="text-muted">—</span>') + '</td>' +
|
'<td class="small">' + (r.dept_code ? escape_html(r.dept_code) : '<span class="text-muted">—</span>') + '</td>' +
|
||||||
@@ -252,9 +252,9 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script>
|
<script src="<?php echo $server_url?>assets/vendor/xlsx/0.18.5/xlsx.full.min.js"></script>
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
|
<script src="<?php echo $server_url?>assets/vendor/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>
|
<script src="<?php echo $server_url?>assets/vendor/jspdf-autotable/3.8.2/jspdf.plugin.autotable.min.js"></script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@charset "UTF-8";@import"https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap";@import"https://cdnjs.cloudflare.com/ajax/libs/tabler-icons/3.35.0/tabler-icons.min.css";/*!
|
@charset "UTF-8";@import"../vendor/fonts/poppins/poppins.css";@import"../vendor/tabler-icons/3.35.0/tabler-icons.min.css";/*!
|
||||||
* Bootstrap v5.3.8 (https://getbootstrap.com/)
|
* Bootstrap v5.3.8 (https://getbootstrap.com/)
|
||||||
* Copyright 2011-2025 The Bootstrap Authors
|
* Copyright 2011-2025 The Bootstrap Authors
|
||||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
|
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
|
||||||
|
|||||||
@@ -0,0 +1,370 @@
|
|||||||
|
/**
|
||||||
|
* ajax_core.js — helpers every page needs, including the sign-in pages:
|
||||||
|
* HTML escaping, form-data collection, the ajax_request() wrapper, the
|
||||||
|
* page-wide form-submit guard and live required-field validation.
|
||||||
|
*
|
||||||
|
* Loaded by include_header.php (before custom.js) and by the minimal
|
||||||
|
* login/include_login_header.php, so the sign-in pages no longer download
|
||||||
|
* custom.js with every feature's API URLs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function escape_html(value) {
|
||||||
|
return String(value ?? '').replace(/[&<>"']/g, function(c) {
|
||||||
|
return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text is stored HTML-escaped (see db_statement in dbconn.php), which is right
|
||||||
|
// for anything written into markup but wrong inside a form field: a note saved
|
||||||
|
// as 5" pipe <spare> came back as 5" pipe <spare>. Field values
|
||||||
|
// are never parsed as HTML, so decoding them here is safe.
|
||||||
|
function decode_html(value) {
|
||||||
|
if (typeof value !== 'string' || value.indexOf('&') === -1) return value;
|
||||||
|
return value.replace(/&(quot|#0*39|#x0*27|apos|lt|gt|amp);/gi, function (m, name) {
|
||||||
|
name = name.toLowerCase();
|
||||||
|
if (name === 'quot') return '"';
|
||||||
|
if (name === 'lt') return '<';
|
||||||
|
if (name === 'gt') return '>';
|
||||||
|
if (name === 'amp') return '&';
|
||||||
|
return "'";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
(function ($) {
|
||||||
|
if (!$ || !$.fn || $.fn.val.__decodes_html) return;
|
||||||
|
var original_val = $.fn.val;
|
||||||
|
$.fn.val = function (value) {
|
||||||
|
if (arguments.length && typeof value === 'string') {
|
||||||
|
// Only free-text fields; a <select> value must keep matching its option.
|
||||||
|
var text_fields = this.filter('input, textarea');
|
||||||
|
if (text_fields.length === this.length) return original_val.call(this, decode_html(value));
|
||||||
|
}
|
||||||
|
return original_val.apply(this, arguments);
|
||||||
|
};
|
||||||
|
$.fn.val.__decodes_html = true;
|
||||||
|
})(window.jQuery);
|
||||||
|
|
||||||
|
|
||||||
|
/** =========================
|
||||||
|
* FORMS
|
||||||
|
* ========================= */
|
||||||
|
|
||||||
|
// Prevent all forms from refreshing the page
|
||||||
|
$(function () {
|
||||||
|
$("form").on("submit", function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
function prepare_form_data(check_required, raw_data) {
|
||||||
|
|
||||||
|
var q = {};
|
||||||
|
|
||||||
|
// Get session context
|
||||||
|
const session_element = document.getElementById('session-context');
|
||||||
|
|
||||||
|
if (session_element) {
|
||||||
|
q['company_id'] = session_element.dataset.companyId;
|
||||||
|
q['otp'] = session_element.dataset.otp;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include GET parameters
|
||||||
|
const url_params = new URLSearchParams(window.location.search);
|
||||||
|
url_params.forEach((value, key) => {
|
||||||
|
q[key] = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Collect form inputs (exclude search inputs — UI-only filters, not API data)
|
||||||
|
$(".form-control:not([type=search]), .form-select").each(function () {
|
||||||
|
if (!$(this).attr("id")) return true;
|
||||||
|
var el = $(this).get(0);
|
||||||
|
if (!el || !el.nodeName) return true;
|
||||||
|
q[$(this).attr("id")] = $(this).val();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (check_required === 1) {
|
||||||
|
|
||||||
|
const required_inputs = document.querySelectorAll('[required]');
|
||||||
|
let is_valid = true;
|
||||||
|
|
||||||
|
required_inputs.forEach(input => {
|
||||||
|
if (!input.value.trim()) {
|
||||||
|
input.classList.add('is-invalid');
|
||||||
|
is_valid = false;
|
||||||
|
} else {
|
||||||
|
input.classList.remove('is-invalid');
|
||||||
|
input.classList.add('is-valid');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!is_valid) {
|
||||||
|
alert("Please fill in all mandatory fields.");
|
||||||
|
isAjaxProcessing = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (raw_data) ? q : JSON.stringify(q);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// server_url is set by include_topbar.php (app pages) and login/include_login_header.php.
|
||||||
|
function app_base_url() {
|
||||||
|
if (typeof server_url !== 'undefined' && server_url) return server_url;
|
||||||
|
return (document.body && document.body.dataset.serverUrl) || '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** =========================
|
||||||
|
* AJAX WRAPPER
|
||||||
|
* ========================= */
|
||||||
|
|
||||||
|
// Prevent double firing
|
||||||
|
let isAjaxProcessing = false;
|
||||||
|
|
||||||
|
function ajax_request(options) {
|
||||||
|
|
||||||
|
if (isAjaxProcessing && options.queueLock !== false) {
|
||||||
|
// Instead of rejecting, we just return a "never-ending" promise
|
||||||
|
// or a resolved promise that does nothing.
|
||||||
|
console.warn("Request is busy... ignoring click.");
|
||||||
|
return new Promise(() => { }); // This stays pending and won't trigger .then or .catch
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.queueLock !== false) {
|
||||||
|
isAjaxProcessing = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto prepare form data
|
||||||
|
if (options.autoPrepare === true) {
|
||||||
|
|
||||||
|
let payloadJson = prepare_form_data(options.checkRequired ?? 0, true);
|
||||||
|
if (payloadJson === false) {
|
||||||
|
isAjaxProcessing = false;
|
||||||
|
return Promise.reject("validation_failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.data) {
|
||||||
|
Object.entries(options.data).forEach(([key, value]) => {
|
||||||
|
payloadJson[key] = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.action) {
|
||||||
|
// modify action
|
||||||
|
if (options.action === 'manage') {
|
||||||
|
options.action = (payloadJson['id']) ? 'update' : 'create';
|
||||||
|
}
|
||||||
|
// add action to JSON
|
||||||
|
payloadJson['action'] = options.action;
|
||||||
|
} else {
|
||||||
|
isAjaxProcessing = false;
|
||||||
|
return Promise.reject("please_define_action");
|
||||||
|
}
|
||||||
|
|
||||||
|
options.data = { json: JSON.stringify(payloadJson) };
|
||||||
|
|
||||||
|
if (options.debugMode) {
|
||||||
|
isAjaxProcessing = false;
|
||||||
|
// Show FormData contents if applicable
|
||||||
|
if (options.formData instanceof FormData) {
|
||||||
|
// Log original formData before merging
|
||||||
|
for (let [key, value] of options.formData.entries()) {
|
||||||
|
console.log("FORMDATA: " + key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Show stringified JSON payload
|
||||||
|
console.log("REQUEST DATA:", options.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// IF formData exist, we pass as $_POST [not json]
|
||||||
|
if (options.formData instanceof FormData) {
|
||||||
|
// THE BYPASS: If formData exists, move all text data into it
|
||||||
|
Object.entries(payloadJson).forEach(([key, value]) => {
|
||||||
|
options.formData.append(key, value);
|
||||||
|
});
|
||||||
|
// Override options.data with the full FormData object
|
||||||
|
options.data = options.formData;
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if (
|
||||||
|
options.data && !(options.data instanceof FormData) &&
|
||||||
|
typeof options.data === 'object' && !('json' in options.data)
|
||||||
|
) {
|
||||||
|
|
||||||
|
// autoPrepare: false with a plain field map — e.g. the delete buttons'
|
||||||
|
// `data: { id: id }`. db_auth.php only accepts a `json` string or a
|
||||||
|
// FormData post carrying `otp`, so an unwrapped map was rejected outright
|
||||||
|
// with "Request denied: No valid JSON payload or Form Data detected.", and
|
||||||
|
// `options.action` was dropped because only the autoPrepare branch applied
|
||||||
|
// it. Wrap it the same way here, without touching callers that already
|
||||||
|
// pass a ready-made `{ json: ... }`.
|
||||||
|
const session_element = document.getElementById('session-context');
|
||||||
|
const payload = {};
|
||||||
|
|
||||||
|
if (session_element) {
|
||||||
|
payload['company_id'] = session_element.dataset.companyId;
|
||||||
|
payload['otp'] = session_element.dataset.otp;
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.entries(options.data).forEach(([key, value]) => {
|
||||||
|
payload[key] = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (options.action) {
|
||||||
|
payload['action'] = (options.action === 'manage')
|
||||||
|
? (payload['id'] ? 'update' : 'create')
|
||||||
|
: options.action;
|
||||||
|
}
|
||||||
|
|
||||||
|
options.data = { json: JSON.stringify(payload) };
|
||||||
|
|
||||||
|
if (options.debugMode) {
|
||||||
|
console.log("REQUEST DATA:", options.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- START MODIFIED $.AJAX BLOCK ---
|
||||||
|
let isSendingFiles = (options.data instanceof FormData);
|
||||||
|
|
||||||
|
// Show loading overlay
|
||||||
|
if (options.noLoading !== true) {
|
||||||
|
$.LoadingOverlay("show", {
|
||||||
|
imageColor: "#525252",
|
||||||
|
imageAnimation: "2s rotate_right",
|
||||||
|
background: "rgba(255,255,255,0.8)"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return $.ajax({
|
||||||
|
async: true,
|
||||||
|
type: options.type || "POST",
|
||||||
|
url: options.url,
|
||||||
|
data: options.data,
|
||||||
|
dataType: "json",
|
||||||
|
// These two settings are only triggered when sending files
|
||||||
|
processData: isSendingFiles ? false : true,
|
||||||
|
contentType: isSendingFiles ? false : "application/x-www-form-urlencoded; charset=UTF-8",
|
||||||
|
// for CSRF validation
|
||||||
|
headers: {
|
||||||
|
'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content')
|
||||||
|
}
|
||||||
|
|
||||||
|
})
|
||||||
|
.then(function (res) {
|
||||||
|
|
||||||
|
isAjaxProcessing = false;
|
||||||
|
if (options.noLoading !== true) $.LoadingOverlay("hide");
|
||||||
|
|
||||||
|
if (options.debugMode) {
|
||||||
|
console.log("RESPONSE:", res);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res || res.success != 1) {
|
||||||
|
if (options.noAlert !== true) bootbox.alert(res?.message || "Unexpected error");
|
||||||
|
options.onError?.(null, res?.message || 'api_failed');
|
||||||
|
throw new Error(res?.message || "api_failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
options.onSuccess?.(res);
|
||||||
|
return res;
|
||||||
|
|
||||||
|
})
|
||||||
|
.catch(function (xhr) {
|
||||||
|
|
||||||
|
isAjaxProcessing = false;
|
||||||
|
$.LoadingOverlay("hide");
|
||||||
|
|
||||||
|
// Errors re-thrown from .then() — pass through
|
||||||
|
if (xhr instanceof Error) {
|
||||||
|
throw xhr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session displaced — another login took over this account
|
||||||
|
if (xhr?.status === 401 && xhr?.responseJSON?.code === 'signed_elsewhere') {
|
||||||
|
bootbox.alert('<strong>Signed in from another device.</strong><br>Your session has been ended because this account was signed in elsewhere.', function() {
|
||||||
|
window.location.href = app_base_url() + 'index.php';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session ended on the server (idle timeout, not signed in, password changed):
|
||||||
|
// drop per-tab data and go back to the sign-in form.
|
||||||
|
const endedCodes = ['session_expired', 'auth_required', 'password_changed'];
|
||||||
|
if (xhr?.status === 401 && endedCodes.includes(xhr?.responseJSON?.code)) {
|
||||||
|
try { sessionStorage.clear(); } catch (e) {}
|
||||||
|
bootbox.alert(escape_html(xhr.responseJSON.message || 'Please sign in again.'), function() {
|
||||||
|
window.location.href = app_base_url() + 'login/index.php';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// File / payload too large (nginx 413)
|
||||||
|
if (xhr?.status === 413) {
|
||||||
|
bootbox.alert('The file you selected is too large. Please use an image under 2 MB and try again.');
|
||||||
|
options.onError?.(xhr, 'payload_too_large');
|
||||||
|
throw xhr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage limit reached — show upgrade notice instead of generic error
|
||||||
|
if (xhr?.status === 402) {
|
||||||
|
const d = xhr?.responseJSON ?? {};
|
||||||
|
const daily = d.daily_limit > 0 ? `Daily: <strong>${d.daily_count} / ${d.daily_limit}</strong>` : null;
|
||||||
|
const weekly = d.weekly_limit > 0 ? `Weekly: <strong>${d.weekly_count} / ${d.weekly_limit}</strong>` : null;
|
||||||
|
const detail = [daily, weekly].filter(Boolean).join(' | ');
|
||||||
|
bootbox.alert(
|
||||||
|
`<strong>Usage limit reached.</strong><br>${detail}<br><br>` +
|
||||||
|
`Reports are locked until the quota resets. Upgrade your package for higher limits.`
|
||||||
|
);
|
||||||
|
options.onError?.(xhr, 'limit_reached');
|
||||||
|
throw xhr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract server's error message from JSON response
|
||||||
|
let serverMessage = xhr?.responseJSON?.message;
|
||||||
|
|
||||||
|
// Fallback: parse responseText if responseJSON isn't set
|
||||||
|
if (!serverMessage && xhr?.responseText) {
|
||||||
|
try {
|
||||||
|
serverMessage = JSON.parse(xhr.responseText)?.message;
|
||||||
|
} catch (e) {
|
||||||
|
// Response wasn't JSON — real server crash or HTML error page
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (serverMessage) {
|
||||||
|
if (options.noAlert !== true) bootbox.alert(serverMessage);
|
||||||
|
options.onError?.(xhr, serverMessage);
|
||||||
|
} else {
|
||||||
|
console.error("AJAX Error:", xhr?.status, xhr?.responseText);
|
||||||
|
if (options.noAlert !== true) bootbox.alert("Server error occurred.");
|
||||||
|
options.onError?.(xhr, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw xhr;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** =========================
|
||||||
|
* REAL-TIME REQUIRED VALIDATION
|
||||||
|
* ========================= */
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
|
||||||
|
const required_inputs = document.querySelectorAll('[required]');
|
||||||
|
|
||||||
|
required_inputs.forEach(input => {
|
||||||
|
input.addEventListener('input', function () {
|
||||||
|
if (this.value.trim() !== "") {
|
||||||
|
this.classList.remove('is-invalid');
|
||||||
|
this.classList.add('is-valid');
|
||||||
|
} else {
|
||||||
|
this.classList.remove('is-valid');
|
||||||
|
this.classList.add('is-invalid');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
+72
-269
@@ -1,8 +1,3 @@
|
|||||||
function escape_html(value) {
|
|
||||||
return String(value ?? '').replace(/[&<>"']/g, function(c) {
|
|
||||||
return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** =========================
|
/** =========================
|
||||||
* SIDEBAR ACTIVE STATE
|
* SIDEBAR ACTIVE STATE
|
||||||
@@ -63,10 +58,6 @@ function debounce(fn, wait) {
|
|||||||
* ========================= */
|
* ========================= */
|
||||||
$(function () {
|
$(function () {
|
||||||
|
|
||||||
// Prevent all forms from refreshing the page
|
|
||||||
$("form").on("submit", function (e) {
|
|
||||||
e.preventDefault();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initialize autocomplete for product search inputs
|
// Initialize autocomplete for product search inputs
|
||||||
init_product_search_inputs();
|
init_product_search_inputs();
|
||||||
@@ -581,9 +572,19 @@ function load_formula_options(select_id, document_type, selected_id, onLoaded) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Line tax rate; derived from tax_amount / total_price when only the amount was stored
|
||||||
|
function line_tax_rate(item) {
|
||||||
|
var rate = parseFloat(item.tax_rate) || 0;
|
||||||
|
var amount = parseFloat(item.tax_amount) || 0;
|
||||||
|
var total = parseFloat(item.total_price) || 0;
|
||||||
|
if (rate === 0 && amount > 0 && total > 0) rate = round_dp(amount / total * 100, 2);
|
||||||
|
return rate;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the request promise so callers can await the options before selecting a value
|
||||||
function load_departments(select_id, selected_id) {
|
function load_departments(select_id, selected_id) {
|
||||||
var ctx = document.getElementById('session-context');
|
var ctx = document.getElementById('session-context');
|
||||||
ajax_request({
|
return ajax_request({
|
||||||
url: server_url + 'accounting/api/engine/department.php',
|
url: server_url + 'accounting/api/engine/department.php',
|
||||||
action: 'get',
|
action: 'get',
|
||||||
queueLock: false,
|
queueLock: false,
|
||||||
@@ -638,242 +639,6 @@ function populate_dept_filter(select_id) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function prepare_form_data(check_required, raw_data) {
|
|
||||||
|
|
||||||
var q = {};
|
|
||||||
|
|
||||||
// Get session context
|
|
||||||
const session_element = document.getElementById('session-context');
|
|
||||||
|
|
||||||
if (session_element) {
|
|
||||||
q['company_id'] = session_element.dataset.companyId;
|
|
||||||
q['otp'] = session_element.dataset.otp;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Include GET parameters
|
|
||||||
const url_params = new URLSearchParams(window.location.search);
|
|
||||||
url_params.forEach((value, key) => {
|
|
||||||
q[key] = value;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Collect form inputs (exclude search inputs — UI-only filters, not API data)
|
|
||||||
$(".form-control:not([type=search]), .form-select").each(function () {
|
|
||||||
if (!$(this).attr("id")) return true;
|
|
||||||
var el = $(this).get(0);
|
|
||||||
if (!el || !el.nodeName) return true;
|
|
||||||
q[$(this).attr("id")] = $(this).val();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Validate required fields
|
|
||||||
if (check_required === 1) {
|
|
||||||
|
|
||||||
const required_inputs = document.querySelectorAll('[required]');
|
|
||||||
let is_valid = true;
|
|
||||||
|
|
||||||
required_inputs.forEach(input => {
|
|
||||||
if (!input.value.trim()) {
|
|
||||||
input.classList.add('is-invalid');
|
|
||||||
is_valid = false;
|
|
||||||
} else {
|
|
||||||
input.classList.remove('is-invalid');
|
|
||||||
input.classList.add('is-valid');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!is_valid) {
|
|
||||||
alert("Please fill in all mandatory fields.");
|
|
||||||
isAjaxProcessing = false;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (raw_data) ? q : JSON.stringify(q);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/** =========================
|
|
||||||
* AJAX WRAPPER
|
|
||||||
* ========================= */
|
|
||||||
|
|
||||||
// Prevent double firing
|
|
||||||
let isAjaxProcessing = false;
|
|
||||||
|
|
||||||
function ajax_request(options) {
|
|
||||||
|
|
||||||
if (isAjaxProcessing && options.queueLock !== false) {
|
|
||||||
// Instead of rejecting, we just return a "never-ending" promise
|
|
||||||
// or a resolved promise that does nothing.
|
|
||||||
console.warn("Request is busy... ignoring click.");
|
|
||||||
return new Promise(() => { }); // This stays pending and won't trigger .then or .catch
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.queueLock !== false) {
|
|
||||||
isAjaxProcessing = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto prepare form data
|
|
||||||
if (options.autoPrepare === true) {
|
|
||||||
|
|
||||||
let payloadJson = prepare_form_data(options.checkRequired ?? 0, true);
|
|
||||||
if (payloadJson === false) {
|
|
||||||
isAjaxProcessing = false;
|
|
||||||
return Promise.reject("validation_failed");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.data) {
|
|
||||||
Object.entries(options.data).forEach(([key, value]) => {
|
|
||||||
payloadJson[key] = value;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.action) {
|
|
||||||
// modify action
|
|
||||||
if (options.action === 'manage') {
|
|
||||||
options.action = (payloadJson['id']) ? 'update' : 'create';
|
|
||||||
}
|
|
||||||
// add action to JSON
|
|
||||||
payloadJson['action'] = options.action;
|
|
||||||
} else {
|
|
||||||
isAjaxProcessing = false;
|
|
||||||
return Promise.reject("please_define_action");
|
|
||||||
}
|
|
||||||
|
|
||||||
options.data = { json: JSON.stringify(payloadJson) };
|
|
||||||
|
|
||||||
if (options.debugMode) {
|
|
||||||
isAjaxProcessing = false;
|
|
||||||
// Show FormData contents if applicable
|
|
||||||
if (options.formData instanceof FormData) {
|
|
||||||
// Log original formData before merging
|
|
||||||
for (let [key, value] of options.formData.entries()) {
|
|
||||||
console.log("FORMDATA: " + key, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Show stringified JSON payload
|
|
||||||
console.log("REQUEST DATA:", options.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
// IF formData exist, we pass as $_POST [not json]
|
|
||||||
if (options.formData instanceof FormData) {
|
|
||||||
// THE BYPASS: If formData exists, move all text data into it
|
|
||||||
Object.entries(payloadJson).forEach(([key, value]) => {
|
|
||||||
options.formData.append(key, value);
|
|
||||||
});
|
|
||||||
// Override options.data with the full FormData object
|
|
||||||
options.data = options.formData;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- START MODIFIED $.AJAX BLOCK ---
|
|
||||||
let isSendingFiles = (options.data instanceof FormData);
|
|
||||||
|
|
||||||
// Show loading overlay
|
|
||||||
if (options.noLoading !== true) {
|
|
||||||
$.LoadingOverlay("show", {
|
|
||||||
imageColor: "#525252",
|
|
||||||
imageAnimation: "2s rotate_right",
|
|
||||||
background: "rgba(255,255,255,0.8)"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return $.ajax({
|
|
||||||
async: true,
|
|
||||||
type: options.type || "POST",
|
|
||||||
url: options.url,
|
|
||||||
data: options.data,
|
|
||||||
dataType: "json",
|
|
||||||
// These two settings are only triggered when sending files
|
|
||||||
processData: isSendingFiles ? false : true,
|
|
||||||
contentType: isSendingFiles ? false : "application/x-www-form-urlencoded; charset=UTF-8",
|
|
||||||
// for CSRF validation
|
|
||||||
headers: {
|
|
||||||
'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content')
|
|
||||||
}
|
|
||||||
|
|
||||||
})
|
|
||||||
.then(function (res) {
|
|
||||||
|
|
||||||
isAjaxProcessing = false;
|
|
||||||
if (options.noLoading !== true) $.LoadingOverlay("hide");
|
|
||||||
|
|
||||||
if (options.debugMode) {
|
|
||||||
console.log("RESPONSE:", res);
|
|
||||||
return res;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!res || res.success != 1) {
|
|
||||||
if (options.noAlert !== true) bootbox.alert(res?.message || "Unexpected error");
|
|
||||||
options.onError?.(null, res?.message || 'api_failed');
|
|
||||||
throw new Error(res?.message || "api_failed");
|
|
||||||
}
|
|
||||||
|
|
||||||
options.onSuccess?.(res);
|
|
||||||
return res;
|
|
||||||
|
|
||||||
})
|
|
||||||
.catch(function (xhr) {
|
|
||||||
|
|
||||||
isAjaxProcessing = false;
|
|
||||||
$.LoadingOverlay("hide");
|
|
||||||
|
|
||||||
// Errors re-thrown from .then() — pass through
|
|
||||||
if (xhr instanceof Error) {
|
|
||||||
throw xhr;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Session displaced — another login took over this account
|
|
||||||
if (xhr?.status === 401 && xhr?.responseJSON?.code === 'signed_elsewhere') {
|
|
||||||
bootbox.alert('<strong>Signed in from another device.</strong><br>Your session has been ended because this account was signed in elsewhere.', function() {
|
|
||||||
window.location.href = server_url + 'index.php';
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// File / payload too large (nginx 413)
|
|
||||||
if (xhr?.status === 413) {
|
|
||||||
bootbox.alert('The file you selected is too large. Please use an image under 2 MB and try again.');
|
|
||||||
options.onError?.(xhr, 'payload_too_large');
|
|
||||||
throw xhr;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usage limit reached — show upgrade notice instead of generic error
|
|
||||||
if (xhr?.status === 402) {
|
|
||||||
const d = xhr?.responseJSON ?? {};
|
|
||||||
const daily = d.daily_limit > 0 ? `Daily: <strong>${d.daily_count} / ${d.daily_limit}</strong>` : null;
|
|
||||||
const weekly = d.weekly_limit > 0 ? `Weekly: <strong>${d.weekly_count} / ${d.weekly_limit}</strong>` : null;
|
|
||||||
const detail = [daily, weekly].filter(Boolean).join(' | ');
|
|
||||||
bootbox.alert(
|
|
||||||
`<strong>Usage limit reached.</strong><br>${detail}<br><br>` +
|
|
||||||
`Reports are locked until the quota resets. Upgrade your package for higher limits.`
|
|
||||||
);
|
|
||||||
options.onError?.(xhr, 'limit_reached');
|
|
||||||
throw xhr;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract server's error message from JSON response
|
|
||||||
let serverMessage = xhr?.responseJSON?.message;
|
|
||||||
|
|
||||||
// Fallback: parse responseText if responseJSON isn't set
|
|
||||||
if (!serverMessage && xhr?.responseText) {
|
|
||||||
try {
|
|
||||||
serverMessage = JSON.parse(xhr.responseText)?.message;
|
|
||||||
} catch (e) {
|
|
||||||
// Response wasn't JSON — real server crash or HTML error page
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (serverMessage) {
|
|
||||||
if (options.noAlert !== true) bootbox.alert(serverMessage);
|
|
||||||
options.onError?.(xhr, serverMessage);
|
|
||||||
} else {
|
|
||||||
console.error("AJAX Error:", xhr?.status, xhr?.responseText);
|
|
||||||
if (options.noAlert !== true) bootbox.alert("Server error occurred.");
|
|
||||||
options.onError?.(xhr, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw xhr;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/** =========================
|
/** =========================
|
||||||
@@ -970,40 +735,29 @@ flatpickr(".flatpickr", {
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
/** =========================
|
|
||||||
* REAL-TIME REQUIRED VALIDATION
|
|
||||||
* ========================= */
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
|
|
||||||
const required_inputs = document.querySelectorAll('[required]');
|
|
||||||
|
|
||||||
required_inputs.forEach(input => {
|
|
||||||
input.addEventListener('input', function () {
|
|
||||||
if (this.value.trim() !== "") {
|
|
||||||
this.classList.remove('is-invalid');
|
|
||||||
this.classList.add('is-valid');
|
|
||||||
} else {
|
|
||||||
this.classList.remove('is-valid');
|
|
||||||
this.classList.add('is-invalid');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper function to format date strings (assuming input is in ISO format)
|
* Helper function to format date strings (assuming input is in ISO format)
|
||||||
*/
|
*/
|
||||||
|
// Display format used across the app: YYYY-MM-DD, or YYYY-MM-DD HH:mm:ss when the value has a time.
|
||||||
function format_date(iso_string) {
|
function format_date(iso_string) {
|
||||||
if (!iso_string) return '—';
|
if (!iso_string) return '—';
|
||||||
var split = iso_string.split(" ");
|
var split = String(iso_string).split(" ");
|
||||||
var datePart = split[0].split("-");
|
var datePart = split[0].split("-");
|
||||||
if (datePart.length !== 3) return iso_string;
|
if (datePart.length !== 3) return iso_string;
|
||||||
var formatted = `${datePart[2]}/${datePart[1]}/${datePart[0]}`;
|
var formatted = `${datePart[0]}-${datePart[1]}-${datePart[2]}`;
|
||||||
return split.length === 2 ? `${formatted} ${split[1]}` : formatted;
|
return split.length === 2 ? `${formatted} ${split[1]}` : formatted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DD/MM/YYYY for filling date inputs (flatpickr dateFormat 'd/m/Y'); to_iso_date() reverses it.
|
||||||
|
function format_date_input(iso_string) {
|
||||||
|
if (!iso_string) return '';
|
||||||
|
var datePart = String(iso_string).split(" ")[0].split("-");
|
||||||
|
if (datePart.length !== 3) return iso_string;
|
||||||
|
return `${datePart[2]}/${datePart[1]}/${datePart[0]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function to_iso_date(dateStr) {
|
function to_iso_date(dateStr) {
|
||||||
if (!dateStr) return null;
|
if (!dateStr) return null;
|
||||||
@@ -1019,6 +773,36 @@ function to_iso_date(dateStr) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear every field inside a form or form-like container — the "Clear" buttons
|
||||||
|
* on the master-data pages. It was called by five pages but never defined, so
|
||||||
|
* each click threw a ReferenceError, and where the container is a <div> rather
|
||||||
|
* than a <form> (Chart of Accounts, Departments) the button did nothing at all.
|
||||||
|
* Disabled and hidden inputs are left alone: those carry the record id and
|
||||||
|
* locked values, not user input.
|
||||||
|
*/
|
||||||
|
function reset_input(selector) {
|
||||||
|
var $scope = $(selector);
|
||||||
|
if (!$scope.length) return;
|
||||||
|
|
||||||
|
$scope.find('input, textarea, select').each(function () {
|
||||||
|
if (this.disabled || this.type === 'hidden' || this.type === 'button' || this.type === 'submit') return;
|
||||||
|
|
||||||
|
if (this._flatpickr) {
|
||||||
|
this._flatpickr.clear();
|
||||||
|
} else if (this.type === 'checkbox' || this.type === 'radio') {
|
||||||
|
this.checked = this.defaultChecked;
|
||||||
|
} else if (this.tagName === 'SELECT') {
|
||||||
|
this.selectedIndex = 0;
|
||||||
|
} else {
|
||||||
|
this.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$(this).removeAttr('secondary').removeClass('is-invalid is-valid');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function round_dp(value, places) {
|
function round_dp(value, places) {
|
||||||
var factor = Math.pow(10, places);
|
var factor = Math.pow(10, places);
|
||||||
return Math.round((Number(value) + Number.EPSILON) * factor) / factor;
|
return Math.round((Number(value) + Number.EPSILON) * factor) / factor;
|
||||||
@@ -1054,6 +838,25 @@ function expand_exponential_number(value) {
|
|||||||
return sign + digits.slice(0, point) + '.' + digits.slice(point);
|
return sign + digits.slice(0, point) + '.' + digits.slice(point);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a stock quantity without hiding real data.
|
||||||
|
*
|
||||||
|
* Quantity columns are decimal(18,4), so a genuine 0.0001 exists. Formatting
|
||||||
|
* every quantity at 2 dp printed such a value as "0.00", which reads as "no
|
||||||
|
* data" — the stock popups and list pages all showed an empty-looking QTY for
|
||||||
|
* a receipt that had in fact been made. Show 2 dp normally, and the stored
|
||||||
|
* 4 dp whenever rounding to 2 would lose something.
|
||||||
|
*/
|
||||||
|
function format_quantity(value) {
|
||||||
|
var n = Number(value);
|
||||||
|
if (isNaN(n)) return '--';
|
||||||
|
|
||||||
|
return (round_dp(n, 2) !== round_dp(n, 4))
|
||||||
|
? format_number(n, 4)
|
||||||
|
: format_number(n, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function format_number(value, decimal) {
|
function format_number(value, decimal) {
|
||||||
var n = Number(value);
|
var n = Number(value);
|
||||||
if (isNaN(n)) return '--';
|
if (isNaN(n)) return '--';
|
||||||
@@ -1165,7 +968,7 @@ function show_stock_rows(source, source_id, label) {
|
|||||||
<td>${escape_html(r.warehouse_name)}</td>
|
<td>${escape_html(r.warehouse_name)}</td>
|
||||||
<td><small>${escape_html(location)}</small></td>
|
<td><small>${escape_html(location)}</small></td>
|
||||||
<td><small>${escape_html(r.lot_number || '—')}</small></td>
|
<td><small>${escape_html(r.lot_number || '—')}</small></td>
|
||||||
<td class="text-end fw-semibold">${format_number(r.quantity, 2)}</td>
|
<td class="text-end fw-semibold">${format_quantity(r.quantity)}</td>
|
||||||
<td>${status_badge}</td>
|
<td>${status_badge}</td>
|
||||||
<td><small>${format_date(r.date)}</small></td>
|
<td><small>${format_date(r.date)}</small></td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* app_access.php — which app (WMS / Accounting) a script belongs to, and whether
|
||||||
|
* the signed-in user's app_access allows it.
|
||||||
|
*
|
||||||
|
* app_access used to only choose which menus the topbar drew; a WMS-only user
|
||||||
|
* could still open the accounting pages and call their APIs directly. db_auth.php
|
||||||
|
* (API engines) and include_topbar.php (pages) now both enforce it through here.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Accounting endpoints the WMS screens also call (master-data lookups, the
|
||||||
|
// batch operation lock, and the GL panel on purchase invoices).
|
||||||
|
const APP_ACCESS_SHARED_ACCOUNTING = [
|
||||||
|
'accounting/api/engine/account.php',
|
||||||
|
'accounting/api/engine/account_formula.php',
|
||||||
|
'accounting/api/engine/department.php',
|
||||||
|
'accounting/api/engine/acquire_op_lock.php',
|
||||||
|
'accounting/api/engine/release_op_lock.php',
|
||||||
|
'accounting/api/engine/get_gl_by_source.php',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** The app a script under app/ belongs to: 'accounting', or null for WMS/shared. */
|
||||||
|
function app_access_app_for(string $script_name): ?string {
|
||||||
|
$path = str_replace('\\', '/', $script_name);
|
||||||
|
$pos = strpos($path, '/app/');
|
||||||
|
if ($pos === false) return null;
|
||||||
|
$rel = substr($path, $pos + 5);
|
||||||
|
|
||||||
|
if (in_array($rel, APP_ACCESS_SHARED_ACCOUNTING, true)) return null;
|
||||||
|
if (preg_match('#^(accounting|ac_dashboard|revenue|expense|finance|journal)/#', $rel)) return 'accounting';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether an app_access value ('wms', 'accounting', 'all') includes $app. */
|
||||||
|
function app_access_allows(string $access, string $app): bool {
|
||||||
|
return $access === 'all' || $access === $app;
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-side rules shared by the documents that carry priced lines: sales
|
||||||
|
* orders, purchase orders, quotations and purchase requests.
|
||||||
|
*
|
||||||
|
* The pages enforce the same limits, but only in JavaScript, so a request sent
|
||||||
|
* straight to the engine could store a 150% tax rate, a negative price or a
|
||||||
|
* line total that does not match quantity × price. Everything here throws a
|
||||||
|
* plain Exception, which the engines already report back as the alert text.
|
||||||
|
*/
|
||||||
|
class DocumentValidator
|
||||||
|
{
|
||||||
|
const MAX_TAX_RATE = 100;
|
||||||
|
// Far above any real unit price, low enough to stop a slipped keystroke
|
||||||
|
// (or a crafted request) from booking billions.
|
||||||
|
const MAX_UNIT_PRICE = 999999999.99;
|
||||||
|
const MAX_QUANTITY = 999999999.9999;
|
||||||
|
const MIN_QUANTITY = 0.0001;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate the lines and return them with total_price and tax_amount
|
||||||
|
* recomputed, using the same formula as the pages:
|
||||||
|
* total = quantity × unit_price, tax = total × tax_rate / 100 (4 dp).
|
||||||
|
*/
|
||||||
|
public static function normaliseLines(array $items, string $doc_label = 'Document'): array
|
||||||
|
{
|
||||||
|
$out = [];
|
||||||
|
foreach (array_values($items) as $i => $item) {
|
||||||
|
if (!is_array($item)) {
|
||||||
|
throw new Exception("{$doc_label} line #" . ($i + 1) . " is not valid.");
|
||||||
|
}
|
||||||
|
$name = trim((string)($item['product_name'] ?? '')) ?: trim((string)($item['product_sku'] ?? ''));
|
||||||
|
$label = 'Line #' . ($i + 1) . ($name !== '' ? " ({$name})" : '');
|
||||||
|
|
||||||
|
$qty = self::number($item['quantity'] ?? 0, "{$label}: quantity");
|
||||||
|
$price = self::number($item['unit_price'] ?? $item['price'] ?? 0, "{$label}: unit price");
|
||||||
|
$rate = self::number($item['tax_rate'] ?? 0, "{$label}: tax rate");
|
||||||
|
|
||||||
|
$qty = round($qty, 4);
|
||||||
|
if ($qty < self::MIN_QUANTITY) {
|
||||||
|
throw new Exception("{$label}: quantity must be greater than zero.");
|
||||||
|
}
|
||||||
|
if ($qty > self::MAX_QUANTITY) {
|
||||||
|
throw new Exception("{$label}: quantity is too large.");
|
||||||
|
}
|
||||||
|
if ($price < 0) {
|
||||||
|
throw new Exception("{$label}: unit price cannot be negative.");
|
||||||
|
}
|
||||||
|
if ($price > self::MAX_UNIT_PRICE) {
|
||||||
|
throw new Exception("{$label}: unit price cannot exceed " . number_format(self::MAX_UNIT_PRICE, 2) . ".");
|
||||||
|
}
|
||||||
|
if ($rate < 0 || $rate > self::MAX_TAX_RATE) {
|
||||||
|
throw new Exception("{$label}: tax rate must be between 0 and " . self::MAX_TAX_RATE . "%.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$total = round($qty * $price, 4);
|
||||||
|
$item['quantity'] = $qty;
|
||||||
|
$item['unit_price'] = round($price, 4);
|
||||||
|
$item['tax_rate'] = round($rate, 2);
|
||||||
|
$item['total_price'] = $total;
|
||||||
|
$item['tax_amount'] = round($total * $item['tax_rate'] / 100, 4);
|
||||||
|
$out[] = $item;
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Header amounts (discount, shipping fee): numeric and never negative. */
|
||||||
|
public static function amount($value, string $label): float
|
||||||
|
{
|
||||||
|
$n = self::number($value, $label);
|
||||||
|
if ($n < 0) {
|
||||||
|
throw new Exception("{$label} cannot be negative.");
|
||||||
|
}
|
||||||
|
if ($n > self::MAX_UNIT_PRICE * 1000) {
|
||||||
|
throw new Exception("{$label} is too large.");
|
||||||
|
}
|
||||||
|
return $n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A discount larger than the goods would turn the document negative. */
|
||||||
|
public static function discount($value, float $subtotal): float
|
||||||
|
{
|
||||||
|
$discount = self::amount($value, 'Discount');
|
||||||
|
if ($discount > $subtotal + 0.00005) {
|
||||||
|
throw new Exception('Discount cannot exceed the subtotal.');
|
||||||
|
}
|
||||||
|
return $discount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function requireId($value, string $message): int
|
||||||
|
{
|
||||||
|
$id = (int)$value;
|
||||||
|
if ($id <= 0) {
|
||||||
|
throw new Exception($message);
|
||||||
|
}
|
||||||
|
return $id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A department is mandatory once the company uses departments. A company
|
||||||
|
* that has never defined one keeps saving with "No Department".
|
||||||
|
*/
|
||||||
|
public static function requireDepartment(PDO $pdo, int $company_id, $value): int
|
||||||
|
{
|
||||||
|
$id = (int)$value;
|
||||||
|
if ($id > 0) {
|
||||||
|
$sth = $pdo->prepare("SELECT COUNT(*) FROM md_department WHERE company_id = :cid AND id = :id");
|
||||||
|
$sth->execute([':cid' => $company_id, ':id' => $id]);
|
||||||
|
if ((int)$sth->fetchColumn() === 0) {
|
||||||
|
throw new Exception('The selected department does not exist.');
|
||||||
|
}
|
||||||
|
return $id;
|
||||||
|
}
|
||||||
|
$sth = $pdo->prepare("SELECT COUNT(*) FROM md_department WHERE company_id = :cid AND status = 1");
|
||||||
|
$sth->execute([':cid' => $company_id]);
|
||||||
|
if ((int)$sth->fetchColumn() > 0) {
|
||||||
|
throw new Exception('Department is required.');
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function number($value, string $label): float
|
||||||
|
{
|
||||||
|
if ($value === '' || $value === null) return 0.0;
|
||||||
|
if (!is_numeric($value) || !is_finite((float)$value)) {
|
||||||
|
throw new Exception("{$label} must be a number.");
|
||||||
|
}
|
||||||
|
return (float)$value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -403,12 +403,47 @@ class InvoiceManager {
|
|||||||
* @param array $logging Audit entry.
|
* @param array $logging Audit entry.
|
||||||
* @throws Exception If invoice not found or not in draft status.
|
* @throws Exception If invoice not found or not in draft status.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Normalise a client-supplied date to ISO YYYY-MM-DD and reject anything
|
||||||
|
* that is not a real calendar date.
|
||||||
|
*
|
||||||
|
* The date pickers display d/m/Y, and a page that forgets to convert before
|
||||||
|
* posting sends that text straight through to a MySQL DATE column, where it
|
||||||
|
* fails as a PDOException and surfaces to the user as the opaque
|
||||||
|
* "Database error, please try again." Accepting both spellings here keeps
|
||||||
|
* the failure mode a named, actionable message instead.
|
||||||
|
*
|
||||||
|
* @param string $value ISO or d/m/Y date; '' is treated as "not set".
|
||||||
|
* @param string $label Field name used in the error message.
|
||||||
|
* @return string|null ISO date, or null when nothing was supplied.
|
||||||
|
* @throws Exception When the value is not a valid date.
|
||||||
|
*/
|
||||||
|
private function normaliseDate(string $value, string $label): ?string
|
||||||
|
{
|
||||||
|
$value = trim($value);
|
||||||
|
if ($value === '') return null;
|
||||||
|
|
||||||
|
// Strip a time part, if the caller passed a datetime.
|
||||||
|
$value = explode(' ', $value)[0];
|
||||||
|
|
||||||
|
foreach (['Y-m-d', 'd/m/Y'] as $format) {
|
||||||
|
$parsed = DateTime::createFromFormat('!' . $format, $value);
|
||||||
|
// createFromFormat() accepts overflowing values such as 32/01/2026
|
||||||
|
// and rolls them over, so compare the round-trip to reject those.
|
||||||
|
if ($parsed && $parsed->format($format) === $value) {
|
||||||
|
return $parsed->format('Y-m-d');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Exception("{$label} is not a valid date.");
|
||||||
|
}
|
||||||
|
|
||||||
public function saveInvoice(array $data, array $logging): void
|
public function saveInvoice(array $data, array $logging): void
|
||||||
{
|
{
|
||||||
$id = (int)($data['id'] ?? 0);
|
$id = (int)($data['id'] ?? 0);
|
||||||
|
|
||||||
$sth = $this->pdo->prepare(
|
$sth = $this->pdo->prepare(
|
||||||
"SELECT status, doc_type, issued_date, `log` FROM td_invoice
|
"SELECT status, doc_type, issued_date, due_date, `log` FROM td_invoice
|
||||||
WHERE company_id = :company_id AND id = :id"
|
WHERE company_id = :company_id AND id = :id"
|
||||||
);
|
);
|
||||||
$sth->execute([':company_id' => $this->company_id, ':id' => $id]);
|
$sth->execute([':company_id' => $this->company_id, ':id' => $id]);
|
||||||
@@ -428,8 +463,21 @@ class InvoiceManager {
|
|||||||
$formula_id = isset($data['formula_id']) && (int)$data['formula_id'] > 0
|
$formula_id = isset($data['formula_id']) && (int)$data['formula_id'] > 0
|
||||||
? (int)$data['formula_id'] : null;
|
? (int)$data['formula_id'] : null;
|
||||||
|
|
||||||
|
// Absent key means "not being edited" — keep what is stored rather than
|
||||||
|
// clearing it, so a caller that posts only tax_adjustment cannot wipe
|
||||||
|
// the agreed payment term.
|
||||||
|
$due_date = array_key_exists('due_date', $data)
|
||||||
|
? $this->normaliseDate((string)$data['due_date'], 'Due date')
|
||||||
|
: ($row['due_date'] ?: null);
|
||||||
|
|
||||||
|
$issued_date = $row['issued_date'] ?: null;
|
||||||
|
|
||||||
|
if ($due_date !== null && $issued_date !== null && $due_date < $issued_date) {
|
||||||
|
throw new Exception("The due date cannot be earlier than the issue date.");
|
||||||
|
}
|
||||||
|
|
||||||
$params = [
|
$params = [
|
||||||
':due_date' => $data['due_date'] ?: null,
|
':due_date' => $due_date,
|
||||||
':notes' => $data['notes'] ?? '',
|
':notes' => $data['notes'] ?? '',
|
||||||
':formula_id' => $formula_id,
|
':formula_id' => $formula_id,
|
||||||
':log' => json_encode($log),
|
':log' => json_encode($log),
|
||||||
@@ -525,6 +573,11 @@ class InvoiceManager {
|
|||||||
if (in_array($row['doc_type'], ['invoice', 'purchase_invoice']) && !$due_date) {
|
if (in_array($row['doc_type'], ['invoice', 'purchase_invoice']) && !$due_date) {
|
||||||
throw new Exception("Due date is required before issuing this document.");
|
throw new Exception("Due date is required before issuing this document.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$due_date = $this->normaliseDate((string)($due_date ?? ''), 'Due date');
|
||||||
|
if ($due_date !== null && $due_date < $issued_date) {
|
||||||
|
throw new Exception("The due date cannot be earlier than the issue date.");
|
||||||
|
}
|
||||||
$this->assertPostingWindow($issued_date, ucfirst(str_replace('_', ' ', $row['doc_type'])));
|
$this->assertPostingWindow($issued_date, ucfirst(str_replace('_', ' ', $row['doc_type'])));
|
||||||
|
|
||||||
$log = json_decode($row['log'] ?? '[]', true) ?: [];
|
$log = json_decode($row['log'] ?? '[]', true) ?: [];
|
||||||
@@ -852,18 +905,46 @@ class InvoiceManager {
|
|||||||
|
|
||||||
$log = [array_merge($logging, ['action' => 'create_credit_note'])];
|
$log = [array_merge($logging, ['action' => 'create_credit_note'])];
|
||||||
|
|
||||||
|
// Split the credited amount into net and VAT from the lines being
|
||||||
|
// credited. This used to store the whole VAT-inclusive amount as the
|
||||||
|
// subtotal with tax = 0, so the header disagreed with its own lines: the
|
||||||
|
// VAT report missed the output-tax reversal, and a GL formula posting
|
||||||
|
// from the header reversed revenue by the gross figure.
|
||||||
|
//
|
||||||
|
// The VAT is taken as "amount minus net" so the grand total still
|
||||||
|
// equals the caller's amount exactly, including any rounding adjustment
|
||||||
|
// the return carried; that adjustment is recorded as tax_adjustment.
|
||||||
|
// With no priced lines to split by, the amount is kept whole as before.
|
||||||
|
$net = round(array_reduce($items, fn($c, $i) => $c + (float)($i['total_price'] ?? 0), 0.0), 4);
|
||||||
|
$line_tax = round(array_reduce($items, fn($c, $i) => $c + (float)($i['tax_amount'] ?? 0), 0.0), 2);
|
||||||
|
|
||||||
|
if ($net > 0 && $net <= abs($amount) + 0.005) {
|
||||||
|
$subtotal = $net;
|
||||||
|
$tax = round(abs($amount) - $net, 4);
|
||||||
|
$tax_adj = round($tax - $line_tax, 2);
|
||||||
|
} else {
|
||||||
|
$subtotal = abs($amount);
|
||||||
|
$tax = 0.0;
|
||||||
|
$tax_adj = 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
$this->pdo->prepare(
|
$this->pdo->prepare(
|
||||||
"INSERT INTO td_invoice
|
"INSERT INTO td_invoice
|
||||||
(company_id, uuid, source_id, `source`, doc_type, invoice_number, ref_invoice_id,
|
(company_id, uuid, source_id, `source`, doc_type, invoice_number, ref_invoice_id,
|
||||||
order_id, contact_id, issued_date, due_date,
|
order_id, contact_id, department_id, issued_date, due_date,
|
||||||
subtotal, discount, tax, shipping_fee, grand_total,
|
subtotal, discount, tax, tax_adjustment, shipping_fee, grand_total,
|
||||||
status, notes, `log`)
|
status, notes, `log`)
|
||||||
VALUES
|
VALUES
|
||||||
(:company_id, :uuid, :source_id, :source, 'credit_note', :invoice_number, :ref_invoice_id,
|
(:company_id, :uuid, :source_id, :source, 'credit_note', :invoice_number, :ref_invoice_id,
|
||||||
:order_id, :contact_id, :issued_date, NULL,
|
:order_id, :contact_id, :department_id, :issued_date, NULL,
|
||||||
:amount, 0, 0, 0, :grand_total,
|
:amount, 0, :tax, :tax_adjustment, 0, :grand_total,
|
||||||
1, '', :log)"
|
1, '', :log)"
|
||||||
)->execute([
|
)->execute([
|
||||||
|
// The credit note belongs to the same department as the invoice it
|
||||||
|
// corrects; it was left at 0 before.
|
||||||
|
':department_id' => (int)($parent['department_id'] ?? 0),
|
||||||
|
':tax' => $tax,
|
||||||
|
':tax_adjustment' => $tax_adj,
|
||||||
':company_id' => $this->company_id,
|
':company_id' => $this->company_id,
|
||||||
':uuid' => bin2hex(random_bytes(16)),
|
':uuid' => bin2hex(random_bytes(16)),
|
||||||
':source_id' => $source_id,
|
':source_id' => $source_id,
|
||||||
@@ -873,7 +954,7 @@ class InvoiceManager {
|
|||||||
':order_id' => (int)$parent['order_id'],
|
':order_id' => (int)$parent['order_id'],
|
||||||
':contact_id' => (int)$parent['contact_id'],
|
':contact_id' => (int)$parent['contact_id'],
|
||||||
':issued_date' => $issued_date,
|
':issued_date' => $issued_date,
|
||||||
':amount' => $amount,
|
':amount' => $subtotal,
|
||||||
':grand_total' => -abs($amount), // negative for net-balance queries
|
':grand_total' => -abs($amount), // negative for net-balance queries
|
||||||
':log' => json_encode($log),
|
':log' => json_encode($log),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
require_once __DIR__ . '/DocumentNumberManager.php';
|
require_once __DIR__ . '/DocumentNumberManager.php';
|
||||||
|
require_once __DIR__ . '/DocumentValidator.php';
|
||||||
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
|
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -260,13 +261,16 @@ class OrderManager {
|
|||||||
{
|
{
|
||||||
$sth = $this->pdo->prepare(
|
$sth = $this->pdo->prepare(
|
||||||
"SELECT o.*,
|
"SELECT o.*,
|
||||||
COALESCE(c.contact_name, '') AS contact_name
|
COALESCE(c.contact_name, '') AS contact_name,
|
||||||
|
(SELECT COUNT(*) FROM td_order_item i
|
||||||
|
WHERE i.company_id = o.company_id
|
||||||
|
AND i.order_id = o.id) AS item_count
|
||||||
FROM td_order o
|
FROM td_order o
|
||||||
LEFT JOIN md_contact c
|
LEFT JOIN md_contact c
|
||||||
ON c.company_id = o.company_id
|
ON c.company_id = o.company_id
|
||||||
AND c.id = o.contact_id
|
AND c.id = o.contact_id
|
||||||
WHERE o.company_id = :company_id
|
WHERE o.company_id = :company_id
|
||||||
ORDER BY o.created_at DESC"
|
ORDER BY o.order_date DESC, o.id DESC"
|
||||||
);
|
);
|
||||||
$sth->execute([':company_id' => $this->company_id]);
|
$sth->execute([':company_id' => $this->company_id]);
|
||||||
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
|
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||||
@@ -403,12 +407,23 @@ class OrderManager {
|
|||||||
return $returnable;
|
return $returnable;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark an accepted quotation as converted once an order has been created
|
||||||
|
* from it.
|
||||||
|
*
|
||||||
|
* The link itself lives on the order (td_order.source = 'quotation',
|
||||||
|
* source_id = quotation id), which saveOrder() has already written and
|
||||||
|
* QuotationManager::getById() joins on. This used to also write
|
||||||
|
* td_quotation.order_id — a column that has never existed — so saving an
|
||||||
|
* order with source=quotation failed with "Unknown column" and rolled the
|
||||||
|
* new order back with it.
|
||||||
|
*/
|
||||||
public function linkQuotationToOrder(int $quotation_id, int $order_id): void
|
public function linkQuotationToOrder(int $quotation_id, int $order_id): void
|
||||||
{
|
{
|
||||||
$this->pdo->prepare(
|
$this->pdo->prepare(
|
||||||
"UPDATE td_quotation SET order_id = :order_id, status = 5
|
"UPDATE td_quotation SET status = 5
|
||||||
WHERE id = :id AND company_id = :cid AND status = 2"
|
WHERE id = :id AND company_id = :cid AND status = 2"
|
||||||
)->execute([':order_id' => $order_id, ':id' => $quotation_id, ':cid' => $this->company_id]);
|
)->execute([':id' => $quotation_id, ':cid' => $this->company_id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function assertRevenueOrderEditable(int $order_id): void
|
public function assertRevenueOrderEditable(int $order_id): void
|
||||||
@@ -471,13 +486,18 @@ class OrderManager {
|
|||||||
public function saveOrder(array $data, array $logging): int
|
public function saveOrder(array $data, array $logging): int
|
||||||
{
|
{
|
||||||
$id = (int)($data['id'] ?? 0);
|
$id = (int)($data['id'] ?? 0);
|
||||||
$items = $data['items'] ?? [];
|
$items = DocumentValidator::normaliseLines($data['items'] ?? [], 'Order');
|
||||||
|
$data['items'] = $items;
|
||||||
|
DocumentValidator::requireId($data['contact_id'] ?? 0, 'Contact is required.');
|
||||||
|
if ($id === 0 || array_key_exists('department_id', $data)) {
|
||||||
|
$data['department_id'] = DocumentValidator::requireDepartment($this->pdo, $this->company_id, $data['department_id'] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate totals from items
|
// Calculate totals from items
|
||||||
$subtotal = array_reduce($items, fn($carry, $item) =>
|
$subtotal = array_reduce($items, fn($carry, $item) =>
|
||||||
$carry + (float)($item['total_price'] ?? 0), 0.0
|
$carry + (float)($item['total_price'] ?? 0), 0.0
|
||||||
);
|
);
|
||||||
$discount = (float)($data['discount'] ?? 0);
|
$discount = DocumentValidator::discount($data['discount'] ?? 0, $subtotal);
|
||||||
$tax_adjustment = (float)($data['tax_adjustment'] ?? 0);
|
$tax_adjustment = (float)($data['tax_adjustment'] ?? 0);
|
||||||
if (abs($tax_adjustment) > 0.30) {
|
if (abs($tax_adjustment) > 0.30) {
|
||||||
throw new Exception("Tax adjustment cannot exceed ±0.30.");
|
throw new Exception("Tax adjustment cannot exceed ±0.30.");
|
||||||
@@ -485,7 +505,7 @@ class OrderManager {
|
|||||||
$tax = round(array_reduce($items, fn($carry, $item) =>
|
$tax = round(array_reduce($items, fn($carry, $item) =>
|
||||||
$carry + (float)($item['tax_amount'] ?? 0), 0.0
|
$carry + (float)($item['tax_amount'] ?? 0), 0.0
|
||||||
), 2) + $tax_adjustment;
|
), 2) + $tax_adjustment;
|
||||||
$shipping_fee = (float)($data['shipping_fee'] ?? 0);
|
$shipping_fee = DocumentValidator::amount($data['shipping_fee'] ?? 0, 'Shipping fee');
|
||||||
$tracking_no = trim((string)($data['shipping_tracking_number'] ?? ''));
|
$tracking_no = trim((string)($data['shipping_tracking_number'] ?? ''));
|
||||||
$grand_total = $subtotal - $discount + $tax + $shipping_fee;
|
$grand_total = $subtotal - $discount + $tax + $shipping_fee;
|
||||||
|
|
||||||
@@ -493,7 +513,7 @@ class OrderManager {
|
|||||||
|
|
||||||
// Fetch existing row to check status and load log
|
// Fetch existing row to check status and load log
|
||||||
$sth = $this->pdo->prepare(
|
$sth = $this->pdo->prepare(
|
||||||
"SELECT status, `log` FROM td_order
|
"SELECT status, department_id, `log` FROM td_order
|
||||||
WHERE company_id = :company_id AND id = :id"
|
WHERE company_id = :company_id AND id = :id"
|
||||||
);
|
);
|
||||||
$sth->execute([':company_id' => $this->company_id, ':id' => $id]);
|
$sth->execute([':company_id' => $this->company_id, ':id' => $id]);
|
||||||
@@ -502,6 +522,11 @@ class OrderManager {
|
|||||||
if (!$row) {
|
if (!$row) {
|
||||||
throw new Exception("Order not found.");
|
throw new Exception("Order not found.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pages without a department field (Revenue SO) must not wipe the stored one
|
||||||
|
$department_id = array_key_exists('department_id', $data)
|
||||||
|
? (int)$data['department_id']
|
||||||
|
: (int)$row['department_id'];
|
||||||
$cur_status = (int)$row['status'];
|
$cur_status = (int)$row['status'];
|
||||||
if ($cur_status !== 0 && $cur_status !== -2) {
|
if ($cur_status !== 0 && $cur_status !== -2) {
|
||||||
throw new Exception("Only draft or pending orders can be edited.");
|
throw new Exception("Only draft or pending orders can be edited.");
|
||||||
@@ -541,7 +566,7 @@ class OrderManager {
|
|||||||
WHERE id = :id AND company_id = :company_id"
|
WHERE id = :id AND company_id = :company_id"
|
||||||
)->execute([
|
)->execute([
|
||||||
':contact_id' => (int)($data['contact_id'] ?? 0),
|
':contact_id' => (int)($data['contact_id'] ?? 0),
|
||||||
':department_id' => (int)($data['department_id'] ?? 0),
|
':department_id' => $department_id,
|
||||||
':order_date' => $data['order_date'] ?? date('Y-m-d'),
|
':order_date' => $data['order_date'] ?? date('Y-m-d'),
|
||||||
':subtotal' => $subtotal,
|
':subtotal' => $subtotal,
|
||||||
':discount' => $discount,
|
':discount' => $discount,
|
||||||
|
|||||||
@@ -14,20 +14,25 @@
|
|||||||
*
|
*
|
||||||
* The OTP is a 6-digit TOTP derived from the user's current password hash via HMAC-SHA1,
|
* The OTP is a 6-digit TOTP derived from the user's current password hash via HMAC-SHA1,
|
||||||
* scoped to a 3-minute time step. It cannot be replayed after the window expires.
|
* scoped to a 3-minute time step. It cannot be replayed after the window expires.
|
||||||
* A human-readable reference number (6 uppercase letters) is also generated and emailed
|
* A random reference number (6 uppercase letters) is also generated and emailed so the
|
||||||
* so the user can confirm they received the correct OTP request.
|
* user can confirm they received the correct OTP request. It is not derived from the
|
||||||
|
* OTP: a derived reference let anyone who saw it recover the OTP offline.
|
||||||
*
|
*
|
||||||
* HTTP handler methods for thin AJAX endpoint wrappers:
|
* HTTP handler methods for thin AJAX endpoint wrappers:
|
||||||
* handleRequestOtp($user_id, $company_id)
|
* handleRequestOtp($user_id, $company_id) — signed-in profile page
|
||||||
|
* handleRequestOtpPublic($user_id, $company_id) — login page; same answer whether
|
||||||
|
* or not the account exists
|
||||||
* handleConfirmReset($user_id, $data)
|
* handleConfirmReset($user_id, $data)
|
||||||
*
|
*
|
||||||
* Session keys used (prefixed with 'reset_' to avoid collision with login OTP):
|
* Session keys used (prefixed with 'reset_' to avoid collision with login OTP):
|
||||||
* reset_otp, reset_otp_time, reset_reference, reset_user_id
|
* reset_otp, reset_otp_time, reset_reference, reset_user_id, reset_attempts
|
||||||
*
|
*
|
||||||
* Security:
|
* Security:
|
||||||
* - OTP is HMAC-derived from the current password hash — it changes when the password changes.
|
* - OTP is HMAC-derived from the current password hash — it changes when the password changes.
|
||||||
* - OTP is valid for OTP_EXPIRY_MINUTES (5) only; older OTPs are rejected with clearSession().
|
* - OTP is valid for OTP_EXPIRY_MINUTES (5) only; older OTPs are rejected with clearSession().
|
||||||
* - reset_user_id in session is verified against $user_id to prevent cross-user OTP reuse.
|
* - reset_user_id in session is verified against $user_id to prevent cross-user OTP reuse.
|
||||||
|
* - At most OTP_MAX_ATTEMPTS wrong entries per issued OTP, then it is discarded.
|
||||||
|
* - OTPs are compared with hash_equals().
|
||||||
* - Session is fully destroyed on successful reset, forcing re-authentication.
|
* - Session is fully destroyed on successful reset, forcing re-authentication.
|
||||||
* - All DB queries use PDO prepared statements with bound parameters.
|
* - All DB queries use PDO prepared statements with bound parameters.
|
||||||
* - AJAX handler methods output JSON via json_encode (XSS-safe).
|
* - AJAX handler methods output JSON via json_encode (XSS-safe).
|
||||||
@@ -43,6 +48,12 @@ class PasswordResetManager {
|
|||||||
/** OTP validity window in minutes — matches the login OTP window. */
|
/** OTP validity window in minutes — matches the login OTP window. */
|
||||||
const OTP_EXPIRY_MINUTES = 5;
|
const OTP_EXPIRY_MINUTES = 5;
|
||||||
|
|
||||||
|
/** Wrong OTP entries allowed per issued OTP before it is discarded. */
|
||||||
|
const OTP_MAX_ATTEMPTS = 5;
|
||||||
|
|
||||||
|
/** Answer shown on the login page whether or not the account exists. */
|
||||||
|
const PUBLIC_REQUEST_MESSAGE = "If an account matches, we've sent an OTP to its email.";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param PDO $pdo1 PDO connection to the wms database (user table).
|
* @param PDO $pdo1 PDO connection to the wms database (user table).
|
||||||
* @param PDO $pdo2 PDO connection to the company database (smtp_setting table).
|
* @param PDO $pdo2 PDO connection to the company database (smtp_setting table).
|
||||||
@@ -94,10 +105,10 @@ class PasswordResetManager {
|
|||||||
throw new \RuntimeException('No email address found for this account.');
|
throw new \RuntimeException('No email address found for this account.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate 6-digit TOTP and a human-readable 6-letter reference number
|
// Generate 6-digit TOTP and a random 6-letter reference number
|
||||||
$otp_time = time();
|
$otp_time = time();
|
||||||
$otp = $this->generateOTP($user['password'], $otp_time);
|
$otp = $this->generateOTP($user['password'], $otp_time);
|
||||||
$reference_number = $this->numberToLetters((int) $this->generateOTP($otp, $otp_time));
|
$reference_number = $this->randomReference();
|
||||||
|
|
||||||
// Send via the mailer module (uses company SMTP or falls back to system default)
|
// Send via the mailer module (uses company SMTP or falls back to system default)
|
||||||
require_once $this->include_url . '/assets/utils/module/mailer.php';
|
require_once $this->include_url . '/assets/utils/module/mailer.php';
|
||||||
@@ -124,6 +135,7 @@ class PasswordResetManager {
|
|||||||
$_SESSION['reset_otp_time'] = $otp_time;
|
$_SESSION['reset_otp_time'] = $otp_time;
|
||||||
$_SESSION['reset_reference'] = $reference_number;
|
$_SESSION['reset_reference'] = $reference_number;
|
||||||
$_SESSION['reset_user_id'] = $user_id;
|
$_SESSION['reset_user_id'] = $user_id;
|
||||||
|
$_SESSION['reset_attempts'] = 0;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'masked_email' => $this->maskEmail($user['email']),
|
'masked_email' => $this->maskEmail($user['email']),
|
||||||
@@ -131,6 +143,24 @@ class PasswordResetManager {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a reset that can never succeed, for a login-page request whose
|
||||||
|
* username/email matches no account. The session then looks exactly like a
|
||||||
|
* real request (random unguessable OTP, reset_user_id 0), so the confirm step
|
||||||
|
* answers "Incorrect OTP" instead of revealing that the account is missing.
|
||||||
|
*
|
||||||
|
* @return string Random 6-letter reference, same shape as a real one.
|
||||||
|
*/
|
||||||
|
public function startDecoy(): string {
|
||||||
|
$reference = $this->randomReference();
|
||||||
|
$_SESSION['reset_otp'] = bin2hex(random_bytes(16));
|
||||||
|
$_SESSION['reset_otp_time'] = time();
|
||||||
|
$_SESSION['reset_reference'] = $reference;
|
||||||
|
$_SESSION['reset_user_id'] = 0;
|
||||||
|
$_SESSION['reset_attempts'] = 0;
|
||||||
|
return $reference;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verify the OTP and force-set a new password via PasswordManager.
|
* Verify the OTP and force-set a new password via PasswordManager.
|
||||||
*
|
*
|
||||||
@@ -170,8 +200,14 @@ class PasswordResetManager {
|
|||||||
throw new \InvalidArgumentException('OTP has expired. Please request a new one.');
|
throw new \InvalidArgumentException('OTP has expired. Please request a new one.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify OTP value
|
// Verify OTP value — at most OTP_MAX_ATTEMPTS wrong entries per issued OTP,
|
||||||
if (trim($otp_input) !== $_SESSION['reset_otp']) {
|
// so the 6-digit code cannot be brute-forced inside its 5-minute window.
|
||||||
|
if (!hash_equals((string)$_SESSION['reset_otp'], trim($otp_input)) || $user_id <= 0) {
|
||||||
|
$_SESSION['reset_attempts'] = (int)($_SESSION['reset_attempts'] ?? 0) + 1;
|
||||||
|
if ($_SESSION['reset_attempts'] >= self::OTP_MAX_ATTEMPTS) {
|
||||||
|
$this->clearSession();
|
||||||
|
throw new \InvalidArgumentException('Too many incorrect OTP attempts. Please request a new OTP.');
|
||||||
|
}
|
||||||
throw new \InvalidArgumentException('Incorrect OTP. Please try again.');
|
throw new \InvalidArgumentException('Incorrect OTP. Please try again.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,6 +270,39 @@ class PasswordResetManager {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle the login-page request-OTP call. The answer is the same whether or
|
||||||
|
* not the username/email matches an account (no account enumeration): no
|
||||||
|
* masked email, a generic message and a reference number. Mail failures are
|
||||||
|
* logged, not reported, for the same reason.
|
||||||
|
*
|
||||||
|
* On success (always): { success: 1, message: PUBLIC_REQUEST_MESSAGE, reference: "ABCDEF" }
|
||||||
|
*
|
||||||
|
* @param int|null $user_id Resolved account, or null when nothing matched.
|
||||||
|
* @param int $company_id Company SMTP scope (0 = use system default).
|
||||||
|
*/
|
||||||
|
public function handleRequestOtpPublic(?int $user_id, int $company_id = 0): void {
|
||||||
|
|
||||||
|
$reference = null;
|
||||||
|
if ($user_id) {
|
||||||
|
try {
|
||||||
|
$reference = $this->requestOtp($user_id, $company_id)['reference'];
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
error_log('[PasswordResetManager::handleRequestOtpPublic] ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($reference === null) {
|
||||||
|
$reference = $this->startDecoy();
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => 1,
|
||||||
|
'message' => self::PUBLIC_REQUEST_MESSAGE,
|
||||||
|
'reference' => $reference,
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle an AJAX confirm-reset call and echo a JSON response.
|
* Handle an AJAX confirm-reset call and echo a JSON response.
|
||||||
*
|
*
|
||||||
@@ -312,23 +381,17 @@ class PasswordResetManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a positive integer into a base-26 uppercase letter string.
|
* Random 6-letter uppercase reference code (e.g. "BCDFHJ") for the reset email
|
||||||
|
* and the confirmation screen. Carries no information about the OTP.
|
||||||
*
|
*
|
||||||
* Used to turn the numeric reference OTP into a human-friendly 6-letter
|
* @return string 6-character uppercase string.
|
||||||
* reference code (e.g. 123456 → "BCDFHJ") for inclusion in the reset email.
|
|
||||||
* The result is left-padded with 'A' to always return a 6-character string.
|
|
||||||
*
|
|
||||||
* @param int $num Positive integer to convert.
|
|
||||||
* @return string 6-character uppercase string (e.g. "AAAABC").
|
|
||||||
*/
|
*/
|
||||||
private function numberToLetters(int $num): string {
|
private function randomReference(): string {
|
||||||
$result = '';
|
$result = '';
|
||||||
while ($num > 0) {
|
for ($i = 0; $i < 6; $i++) {
|
||||||
$mod = ($num - 1) % 26;
|
$result .= chr(65 + random_int(0, 25));
|
||||||
$result = chr(65 + $mod) . $result;
|
|
||||||
$num = intval(($num - $mod) / 26);
|
|
||||||
}
|
}
|
||||||
return str_pad($result, 6, 'A', STR_PAD_LEFT);
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -356,14 +419,15 @@ class PasswordResetManager {
|
|||||||
*
|
*
|
||||||
* Called on OTP expiry (to invalidate the request) and on successful
|
* Called on OTP expiry (to invalidate the request) and on successful
|
||||||
* reset (before session_destroy). Does not destroy the full session —
|
* reset (before session_destroy). Does not destroy the full session —
|
||||||
* only the 4 reset-specific keys are unset.
|
* only the reset-specific keys are unset.
|
||||||
*/
|
*/
|
||||||
private function clearSession(): void {
|
private function clearSession(): void {
|
||||||
unset(
|
unset(
|
||||||
$_SESSION['reset_otp'],
|
$_SESSION['reset_otp'],
|
||||||
$_SESSION['reset_otp_time'],
|
$_SESSION['reset_otp_time'],
|
||||||
$_SESSION['reset_reference'],
|
$_SESSION['reset_reference'],
|
||||||
$_SESSION['reset_user_id']
|
$_SESSION['reset_user_id'],
|
||||||
|
$_SESSION['reset_attempts']
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -569,9 +569,9 @@ class ProductManager {
|
|||||||
ON p.company_id = r.company_id
|
ON p.company_id = r.company_id
|
||||||
AND p.sku = r.product_sku
|
AND p.sku = r.product_sku
|
||||||
WHERE r.company_id = :company_id
|
WHERE r.company_id = :company_id
|
||||||
ORDER BY mw.warehouse_name, r.zone,
|
ORDER BY mw.warehouse_name, REGEXP_REPLACE(r.zone, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(r.zone, '[0-9]+$') AS UNSIGNED), r.zone,
|
||||||
CAST(r.aisle AS UNSIGNED), r.aisle,
|
REGEXP_REPLACE(r.aisle, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(r.aisle, '[0-9]+$') AS UNSIGNED), r.aisle,
|
||||||
CAST(r.bin AS UNSIGNED), r.bin"
|
REGEXP_REPLACE(r.bin, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(r.bin, '[0-9]+$') AS UNSIGNED), r.bin"
|
||||||
);
|
);
|
||||||
$sth->execute([':company_id' => $this->company_id]);
|
$sth->execute([':company_id' => $this->company_id]);
|
||||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<?php
|
<?php
|
||||||
require_once __DIR__ . '/DocumentNumberManager.php';
|
require_once __DIR__ . '/DocumentNumberManager.php';
|
||||||
|
require_once __DIR__ . '/DocumentValidator.php';
|
||||||
require_once __DIR__ . '/WarehouseManager.php';
|
require_once __DIR__ . '/WarehouseManager.php';
|
||||||
|
require_once __DIR__ . '/StockManager.php';
|
||||||
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
|
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -194,13 +196,16 @@ class PurchaseOrderManager {
|
|||||||
{
|
{
|
||||||
$sth = $this->pdo->prepare(
|
$sth = $this->pdo->prepare(
|
||||||
"SELECT p.*,
|
"SELECT p.*,
|
||||||
COALESCE(c.contact_name, '') AS contact_name
|
COALESCE(c.contact_name, '') AS contact_name,
|
||||||
|
(SELECT COUNT(*) FROM td_purchase_order_item i
|
||||||
|
WHERE i.company_id = p.company_id
|
||||||
|
AND i.order_id = p.id) AS item_count
|
||||||
FROM td_purchase_order p
|
FROM td_purchase_order p
|
||||||
LEFT JOIN md_contact c
|
LEFT JOIN md_contact c
|
||||||
ON c.company_id = p.company_id
|
ON c.company_id = p.company_id
|
||||||
AND c.id = p.contact_id
|
AND c.id = p.contact_id
|
||||||
WHERE p.company_id = :company_id
|
WHERE p.company_id = :company_id
|
||||||
ORDER BY p.created_at DESC"
|
ORDER BY p.po_date DESC, p.id DESC"
|
||||||
);
|
);
|
||||||
$sth->execute([':company_id' => $this->company_id]);
|
$sth->execute([':company_id' => $this->company_id]);
|
||||||
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
|
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||||
@@ -321,7 +326,12 @@ class PurchaseOrderManager {
|
|||||||
public function savePo(array $data, array $logging): int
|
public function savePo(array $data, array $logging): int
|
||||||
{
|
{
|
||||||
$id = (int)($data['id'] ?? 0);
|
$id = (int)($data['id'] ?? 0);
|
||||||
$items = $data['items'] ?? [];
|
$items = DocumentValidator::normaliseLines($data['items'] ?? [], 'Purchase order');
|
||||||
|
$data['items'] = $items;
|
||||||
|
DocumentValidator::requireId($data['contact_id'] ?? 0, 'Supplier is required.');
|
||||||
|
if ($id === 0 || array_key_exists('department_id', $data)) {
|
||||||
|
$data['department_id'] = DocumentValidator::requireDepartment($this->pdo, $this->company_id, $data['department_id'] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
$skus = array_filter(array_column($items, 'product_sku'));
|
$skus = array_filter(array_column($items, 'product_sku'));
|
||||||
if (count($skus) !== count(array_unique($skus))) {
|
if (count($skus) !== count(array_unique($skus))) {
|
||||||
@@ -331,7 +341,7 @@ class PurchaseOrderManager {
|
|||||||
$subtotal = array_reduce($items, fn($carry, $item) =>
|
$subtotal = array_reduce($items, fn($carry, $item) =>
|
||||||
$carry + (float)($item['total_price'] ?? 0), 0.0
|
$carry + (float)($item['total_price'] ?? 0), 0.0
|
||||||
);
|
);
|
||||||
$discount = (float)($data['discount'] ?? 0);
|
$discount = DocumentValidator::discount($data['discount'] ?? 0, $subtotal);
|
||||||
$tax_adjustment = (float)($data['tax_adjustment'] ?? 0);
|
$tax_adjustment = (float)($data['tax_adjustment'] ?? 0);
|
||||||
if (abs($tax_adjustment) > 0.30) {
|
if (abs($tax_adjustment) > 0.30) {
|
||||||
throw new Exception("Tax adjustment cannot exceed ±0.30.");
|
throw new Exception("Tax adjustment cannot exceed ±0.30.");
|
||||||
@@ -339,7 +349,7 @@ class PurchaseOrderManager {
|
|||||||
$tax = round(array_reduce($items, fn($carry, $item) =>
|
$tax = round(array_reduce($items, fn($carry, $item) =>
|
||||||
$carry + (float)($item['tax_amount'] ?? 0), 0.0
|
$carry + (float)($item['tax_amount'] ?? 0), 0.0
|
||||||
), 2) + $tax_adjustment;
|
), 2) + $tax_adjustment;
|
||||||
$shipping_fee = (float)($data['shipping_fee'] ?? 0);
|
$shipping_fee = DocumentValidator::amount($data['shipping_fee'] ?? 0, 'Shipping fee');
|
||||||
$grand_total = $subtotal - $discount + $tax + $shipping_fee;
|
$grand_total = $subtotal - $discount + $tax + $shipping_fee;
|
||||||
|
|
||||||
if ($id > 0) {
|
if ($id > 0) {
|
||||||
@@ -572,7 +582,16 @@ class PurchaseOrderManager {
|
|||||||
$warehouse_id = (int)($recv['warehouse_id'] ?? $po['warehouse_id']);
|
$warehouse_id = (int)($recv['warehouse_id'] ?? $po['warehouse_id']);
|
||||||
$quantity = (float)($recv['quantity'] ?? 0);
|
$quantity = (float)($recv['quantity'] ?? 0);
|
||||||
|
|
||||||
if ($quantity <= 0) continue;
|
// A blank line is a line the user chose not to receive — skip it.
|
||||||
|
if ($quantity == 0) continue;
|
||||||
|
|
||||||
|
// Anything positive has to survive the decimal(18,4) columns it is
|
||||||
|
// about to be written to. Without this, 0.0000001 was accepted, was
|
||||||
|
// stored as 0.0000, produced a stock movement of nothing, and still
|
||||||
|
// advanced received_qty enough to leave the PO stuck on "Partial".
|
||||||
|
$name = $po_items[$po_items_by_id[$item_id] ?? -1]['product_name'] ?? $product_sku;
|
||||||
|
$quantity = StockManager::normaliseQuantity($quantity, "Receiving quantity for \"{$name}\"");
|
||||||
|
|
||||||
if (!$product_sku) throw new Exception("Item #{$j}: missing product_sku.");
|
if (!$product_sku) throw new Exception("Item #{$j}: missing product_sku.");
|
||||||
if (!$warehouse_id) throw new Exception("Item #{$j}: missing warehouse_id.");
|
if (!$warehouse_id) throw new Exception("Item #{$j}: missing warehouse_id.");
|
||||||
|
|
||||||
@@ -597,6 +616,16 @@ class PurchaseOrderManager {
|
|||||||
$zone = $recv['zone'] ?? '';
|
$zone = $recv['zone'] ?? '';
|
||||||
$aisle = $recv['aisle'] ?? '';
|
$aisle = $recv['aisle'] ?? '';
|
||||||
|
|
||||||
|
// Expiry dates live on md_lot, keyed by lot number, so an expiry
|
||||||
|
// entered without one is silently dropped and the received stock
|
||||||
|
// shows no expiry at all. Say so instead of discarding it.
|
||||||
|
if (trim((string)($recv['expiry_date'] ?? '')) !== ''
|
||||||
|
&& trim((string)($recv['lot_number'] ?? '')) === '') {
|
||||||
|
throw new Exception(
|
||||||
|
"Enter a lot number for \"{$name}\" — an expiry date is recorded against its lot."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Simple location mode: zone and aisle must mirror the bin value
|
// Simple location mode: zone and aisle must mirror the bin value
|
||||||
// (same convention as manage_stock_in.php).
|
// (same convention as manage_stock_in.php).
|
||||||
// occupyBin() looks up md_bin WHERE zone=:zone AND aisle=:aisle AND bin=:bin,
|
// occupyBin() looks up md_bin WHERE zone=:zone AND aisle=:aisle AND bin=:bin,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
require_once __DIR__ . '/DocumentNumberManager.php';
|
require_once __DIR__ . '/DocumentNumberManager.php';
|
||||||
|
require_once __DIR__ . '/DocumentValidator.php';
|
||||||
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
|
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -94,7 +95,10 @@ class PurchaseRequestManager
|
|||||||
WHERE p.company_id = r.company_id
|
WHERE p.company_id = r.company_id
|
||||||
AND p.source = 'purchase_request'
|
AND p.source = 'purchase_request'
|
||||||
AND p.source_id = r.id
|
AND p.source_id = r.id
|
||||||
AND p.status != -1) AS linked_po_count
|
AND p.status != -1) AS linked_po_count,
|
||||||
|
(SELECT COUNT(*) FROM td_purchase_request_item i
|
||||||
|
WHERE i.company_id = r.company_id
|
||||||
|
AND i.request_id = r.id) AS item_count
|
||||||
FROM td_purchase_request r
|
FROM td_purchase_request r
|
||||||
LEFT JOIN md_contact c
|
LEFT JOIN md_contact c
|
||||||
ON c.company_id = r.company_id AND c.id = r.contact_id
|
ON c.company_id = r.company_id AND c.id = r.contact_id
|
||||||
@@ -160,12 +164,22 @@ class PurchaseRequestManager
|
|||||||
public function save(array $data, array $logging): int
|
public function save(array $data, array $logging): int
|
||||||
{
|
{
|
||||||
$id = (int)($data['id'] ?? 0);
|
$id = (int)($data['id'] ?? 0);
|
||||||
$items = $data['items'] ?? [];
|
$items = DocumentValidator::normaliseLines($data['items'] ?? [], 'Purchase request');
|
||||||
$discount = (float)($data['discount'] ?? 0);
|
$data['items'] = $items;
|
||||||
$shipping_fee = (float)($data['shipping_fee'] ?? 0);
|
$discount = DocumentValidator::discount($data['discount'] ?? 0, array_sum(array_column($items, 'total_price')));
|
||||||
|
$shipping_fee = DocumentValidator::amount($data['shipping_fee'] ?? 0, 'Shipping fee');
|
||||||
|
if ($id === 0 || array_key_exists('department_id', $data)) {
|
||||||
|
$data['department_id'] = DocumentValidator::requireDepartment($this->pdo, $this->company_id, $data['department_id'] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
if (empty($items)) throw new Exception('At least one item is required.');
|
if (empty($items)) throw new Exception('At least one item is required.');
|
||||||
|
|
||||||
|
$request_date = (string)($data['request_date'] ?? '');
|
||||||
|
$required_date = (string)($data['required_date'] ?? '');
|
||||||
|
if ($request_date !== '' && $required_date !== '' && $required_date < $request_date) {
|
||||||
|
throw new Exception('Required date cannot be earlier than the request date.');
|
||||||
|
}
|
||||||
|
|
||||||
[$subtotal, $tax, $grand] = $this->computeTotals($items, $discount, $shipping_fee);
|
[$subtotal, $tax, $grand] = $this->computeTotals($items, $discount, $shipping_fee);
|
||||||
|
|
||||||
if ($id === 0) {
|
if ($id === 0) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
require_once __DIR__ . '/DocumentNumberManager.php';
|
require_once __DIR__ . '/DocumentNumberManager.php';
|
||||||
|
require_once __DIR__ . '/DocumentValidator.php';
|
||||||
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
|
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -89,7 +90,10 @@ class QuotationManager
|
|||||||
public function getList(): array
|
public function getList(): array
|
||||||
{
|
{
|
||||||
$sth = $this->pdo->prepare(
|
$sth = $this->pdo->prepare(
|
||||||
"SELECT q.*, COALESCE(c.contact_name, '') AS contact_name
|
"SELECT q.*, COALESCE(c.contact_name, '') AS contact_name,
|
||||||
|
(SELECT COUNT(*) FROM td_quotation_item i
|
||||||
|
WHERE i.company_id = q.company_id
|
||||||
|
AND i.quotation_id = q.id) AS item_count
|
||||||
FROM td_quotation q
|
FROM td_quotation q
|
||||||
LEFT JOIN md_contact c
|
LEFT JOIN md_contact c
|
||||||
ON c.id = q.contact_id AND c.company_id = q.company_id
|
ON c.id = q.contact_id AND c.company_id = q.company_id
|
||||||
@@ -170,8 +174,27 @@ class QuotationManager
|
|||||||
public function save(array $data, array $logging): int
|
public function save(array $data, array $logging): int
|
||||||
{
|
{
|
||||||
$id = (int)($data['id'] ?? 0);
|
$id = (int)($data['id'] ?? 0);
|
||||||
$items = $data['items'] ?? [];
|
$items = DocumentValidator::normaliseLines($data['items'] ?? [], 'Quotation');
|
||||||
$discount = (float)($data['discount'] ?? 0);
|
$data['items'] = $items;
|
||||||
|
$discount = DocumentValidator::discount($data['discount'] ?? 0, array_sum(array_column($items, 'total_price')));
|
||||||
|
|
||||||
|
$quotation_date = (string)($data['quotation_date'] ?? '');
|
||||||
|
$valid_until = (string)($data['valid_until'] ?? '');
|
||||||
|
if ((int)($data['contact_id'] ?? 0) <= 0) {
|
||||||
|
throw new Exception('Contact is required.');
|
||||||
|
}
|
||||||
|
if ($quotation_date === '') {
|
||||||
|
throw new Exception('Quotation date is required.');
|
||||||
|
}
|
||||||
|
if ($valid_until !== '' && $valid_until < $quotation_date) {
|
||||||
|
throw new Exception('Valid until cannot be earlier than the quotation date.');
|
||||||
|
}
|
||||||
|
if ((int)($data['department_id'] ?? 0) <= 0) {
|
||||||
|
throw new Exception('Department is required.');
|
||||||
|
}
|
||||||
|
if (empty($items)) {
|
||||||
|
throw new Exception('At least one line item is required.');
|
||||||
|
}
|
||||||
|
|
||||||
[$subtotal, $tax, $grand] = $this->computeTotals($items, $discount);
|
[$subtotal, $tax, $grand] = $this->computeTotals($items, $discount);
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ class ReportManager
|
|||||||
// Private helpers
|
// Private helpers
|
||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private ?array $transfer_totals = null;
|
||||||
|
|
||||||
private function stockTableNameFromWarehouseId(int $warehouse_id): string
|
private function stockTableNameFromWarehouseId(int $warehouse_id): string
|
||||||
{
|
{
|
||||||
if ($warehouse_id <= 0) {
|
if ($warehouse_id <= 0) {
|
||||||
@@ -45,6 +47,61 @@ class ReportManager
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Approved warehouse-to-warehouse transfer quantities, per month and SKU.
|
||||||
|
*
|
||||||
|
* A transfer is stored as an `out` row in the source warehouse and an `in`
|
||||||
|
* row in the destination, and both reach etl_stock_summary, which is right
|
||||||
|
* for each warehouse's balance. Company-wide "Stock In / Stock Out" figures
|
||||||
|
* must leave them out: the goods were already counted when first received,
|
||||||
|
* and moving them between warehouses is neither a receipt nor an issue.
|
||||||
|
*
|
||||||
|
* @return array [month => [sku => ['in' => float, 'out' => float]]]
|
||||||
|
*/
|
||||||
|
private function transferTotals(): array
|
||||||
|
{
|
||||||
|
if ($this->transfer_totals !== null) return $this->transfer_totals;
|
||||||
|
|
||||||
|
$totals = [];
|
||||||
|
$sth = $this->pdo->prepare("SELECT id FROM md_warehouse WHERE company_id = :company_id");
|
||||||
|
$sth->execute([':company_id' => $this->company_id]);
|
||||||
|
foreach ($sth->fetchAll(PDO::FETCH_COLUMN) as $wh_id) {
|
||||||
|
$table = $this->stockTableNameFromWarehouseId((int)$wh_id);
|
||||||
|
try {
|
||||||
|
$rows = $this->fetchAll(
|
||||||
|
"SELECT DATE_FORMAT(`date`, '%Y-%m') AS month, product_sku,
|
||||||
|
SUM(`in`) AS qty_in, SUM(`out`) AS qty_out
|
||||||
|
FROM `{$table}`
|
||||||
|
WHERE company_id = :company_id AND status = 1 AND type = 'transfer'
|
||||||
|
GROUP BY month, product_sku"
|
||||||
|
);
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
continue; // warehouse without a stock table yet
|
||||||
|
}
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$slot = &$totals[$r['month']][$r['product_sku']];
|
||||||
|
$slot['in'] = ($slot['in'] ?? 0) + (float)$r['qty_in'];
|
||||||
|
$slot['out'] = ($slot['out'] ?? 0) + (float)$r['qty_out'];
|
||||||
|
unset($slot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $this->transfer_totals = $totals;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Transfer in/out summed over the given month (null = all months). */
|
||||||
|
private function transferSum(?string $month = null, ?string $sku = null): array
|
||||||
|
{
|
||||||
|
$in = 0.0; $out = 0.0;
|
||||||
|
foreach ($this->transferTotals() as $m => $by_sku) {
|
||||||
|
if ($month !== null && $m !== $month) continue;
|
||||||
|
foreach ($by_sku as $k => $t) {
|
||||||
|
if ($sku !== null && (string)$k !== $sku) continue;
|
||||||
|
$in += $t['in']; $out += $t['out'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ['in' => $in, 'out' => $out];
|
||||||
|
}
|
||||||
|
|
||||||
private function resolveWarehouseTable(int $warehouse_id): ?string
|
private function resolveWarehouseTable(int $warehouse_id): ?string
|
||||||
{
|
{
|
||||||
$sth = $this->pdo->prepare(
|
$sth = $this->pdo->prepare(
|
||||||
@@ -297,15 +354,7 @@ class ReportManager
|
|||||||
*/
|
*/
|
||||||
public function getLowStockCount(): int
|
public function getLowStockCount(): int
|
||||||
{
|
{
|
||||||
$products = $this->getStockBalance();
|
return count($this->getLowStockItems());
|
||||||
$count = 0;
|
|
||||||
foreach ($products as $product) {
|
|
||||||
$balance = (float) $product["total_in"] - (float) $product["total_out"];
|
|
||||||
if ($balance < (float) $product["min_stock"]) {
|
|
||||||
$count++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return $count;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getDashboardStockTotals(): array
|
public function getDashboardStockTotals(): array
|
||||||
@@ -318,13 +367,20 @@ class ReportManager
|
|||||||
WHERE company_id = :company_id"
|
WHERE company_id = :company_id"
|
||||||
);
|
);
|
||||||
$sth->execute([':company_id' => $this->company_id]);
|
$sth->execute([':company_id' => $this->company_id]);
|
||||||
return $sth->fetch(PDO::FETCH_ASSOC) ?: ['total_in' => 0, 'total_out' => 0];
|
$row = $sth->fetch(PDO::FETCH_ASSOC) ?: ['total_in' => 0, 'total_out' => 0];
|
||||||
|
$transfers = $this->transferSum();
|
||||||
|
return [
|
||||||
|
'total_in' => round(max(0, (float)$row['total_in'] - $transfers['in']), 2),
|
||||||
|
'total_out' => round(max(0, (float)$row['total_out'] - $transfers['out']), 2),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getDashboardOrderStats(): array
|
public function getDashboardOrderStats(): array
|
||||||
{
|
{
|
||||||
$sth = $this->pdo->prepare(
|
$sth = $this->pdo->prepare(
|
||||||
"SELECT COUNT(*), COALESCE(SUM(subtotal), 0)
|
// Orders are counted unless cancelled; revenue only once confirmed —
|
||||||
|
// a draft or pending order is not a sale yet.
|
||||||
|
"SELECT COUNT(*), COALESCE(SUM(CASE WHEN status >= 1 THEN subtotal ELSE 0 END), 0)
|
||||||
FROM td_order
|
FROM td_order
|
||||||
WHERE company_id = :company_id
|
WHERE company_id = :company_id
|
||||||
AND status != -1"
|
AND status != -1"
|
||||||
@@ -400,6 +456,13 @@ class ReportManager
|
|||||||
*
|
*
|
||||||
* @return array Low/critical stock items with warehouse_name, product_name, balance, status.
|
* @return array Low/critical stock items with warehouse_name, product_name, balance, status.
|
||||||
*/
|
*/
|
||||||
|
/*
|
||||||
|
* The one definition of "low stock", shared by the dashboard tile, the Low
|
||||||
|
* Stock page, the warehouse overview tile and the daily alert: an active
|
||||||
|
* product in an active warehouse whose balance there is at or below its
|
||||||
|
* reorder point (or minimum stock, whichever is higher). Each screen used
|
||||||
|
* to apply its own threshold and grouping, so the counts never matched.
|
||||||
|
*/
|
||||||
public function getLowStockItems(): array
|
public function getLowStockItems(): array
|
||||||
{
|
{
|
||||||
$sql = "SELECT
|
$sql = "SELECT
|
||||||
@@ -420,11 +483,13 @@ class ReportManager
|
|||||||
ON wb.company_id = mw.company_id
|
ON wb.company_id = mw.company_id
|
||||||
AND wb.warehouse_id = mw.id
|
AND wb.warehouse_id = mw.id
|
||||||
WHERE wb.company_id = :company_id
|
WHERE wb.company_id = :company_id
|
||||||
AND mp.reorder_point > 0
|
AND mp.status > 0
|
||||||
|
AND mw.status = 1
|
||||||
|
AND GREATEST(mp.reorder_point, mp.min_stock) > 0
|
||||||
GROUP BY
|
GROUP BY
|
||||||
wb.warehouse_id, wb.product_sku, mw.warehouse_name,
|
wb.warehouse_id, wb.product_sku, mw.warehouse_name,
|
||||||
mp.product_name, mp.min_stock, mp.reorder_point, mp.product_image, mp.cost_price
|
mp.product_name, mp.min_stock, mp.reorder_point, mp.product_image, mp.cost_price
|
||||||
HAVING balance <= mp.reorder_point
|
HAVING balance <= GREATEST(mp.reorder_point, mp.min_stock)
|
||||||
ORDER BY mp.product_name ASC, mw.warehouse_name ASC";
|
ORDER BY mp.product_name ASC, mw.warehouse_name ASC";
|
||||||
|
|
||||||
$rows = $this->fetchAll($sql);
|
$rows = $this->fetchAll($sql);
|
||||||
@@ -498,9 +563,13 @@ class ReportManager
|
|||||||
AND month = :month"
|
AND month = :month"
|
||||||
);
|
);
|
||||||
$sth->execute([':company_id' => $this->company_id, ':month' => $month]);
|
$sth->execute([':company_id' => $this->company_id, ':month' => $month]);
|
||||||
return $sth->fetch(PDO::FETCH_ASSOC) ?: [
|
$row = $sth->fetch(PDO::FETCH_ASSOC) ?: [
|
||||||
'total_in' => 0, 'total_out' => 0, 'active_products' => 0
|
'total_in' => 0, 'total_out' => 0, 'active_products' => 0
|
||||||
];
|
];
|
||||||
|
$transfers = $this->transferSum($month);
|
||||||
|
$row['total_in'] = round(max(0, (float)$row['total_in'] - $transfers['in']), 2);
|
||||||
|
$row['total_out'] = round(max(0, (float)$row['total_out'] - $transfers['out']), 2);
|
||||||
|
return $row;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -568,6 +637,9 @@ class ReportManager
|
|||||||
|
|
||||||
$dataByMonth = [];
|
$dataByMonth = [];
|
||||||
foreach ($rows as $row) {
|
foreach ($rows as $row) {
|
||||||
|
$transfers = $this->transferSum($row['month']);
|
||||||
|
$row['stock_in'] = round(max(0, (float)$row['stock_in'] - $transfers['in']), 2);
|
||||||
|
$row['stock_out'] = round(max(0, (float)$row['stock_out'] - $transfers['out']), 2);
|
||||||
$dataByMonth[$row['month']] = $row;
|
$dataByMonth[$row['month']] = $row;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -611,15 +683,22 @@ class ReportManager
|
|||||||
AND pc.id = p.category
|
AND pc.id = p.category
|
||||||
WHERE wb.company_id = :company_id
|
WHERE wb.company_id = :company_id
|
||||||
AND wb.month = :month
|
AND wb.month = :month
|
||||||
GROUP BY wb.product_sku, p.product_name, pc.category
|
GROUP BY wb.product_sku, p.product_name, pc.category"
|
||||||
ORDER BY total_out DESC
|
|
||||||
LIMIT {$limit}"
|
|
||||||
);
|
);
|
||||||
$sth->execute([
|
$sth->execute([
|
||||||
':company_id' => $this->company_id,
|
':company_id' => $this->company_id,
|
||||||
':month' => $month,
|
':month' => $month,
|
||||||
]);
|
]);
|
||||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
foreach ($rows as &$row) {
|
||||||
|
$transfers = $this->transferSum($month, (string)$row['product_sku']);
|
||||||
|
$row['total_in'] = round(max(0, (float)$row['total_in'] - $transfers['in']), 2);
|
||||||
|
$row['total_out'] = round(max(0, (float)$row['total_out'] - $transfers['out']), 2);
|
||||||
|
}
|
||||||
|
unset($row);
|
||||||
|
$rows = array_values(array_filter($rows, fn($r) => $r['total_in'] > 0 || $r['total_out'] > 0));
|
||||||
|
usort($rows, fn($a, $b) => $b['total_out'] <=> $a['total_out'] ?: $b['total_in'] <=> $a['total_in']);
|
||||||
|
return array_slice($rows, 0, $limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -668,8 +747,8 @@ class ReportManager
|
|||||||
$cid = (int) $this->company_id;
|
$cid = (int) $this->company_id;
|
||||||
$warehouse_name = $this->pdo->quote($wh['warehouse_name']);
|
$warehouse_name = $this->pdo->quote($wh['warehouse_name']);
|
||||||
return "SELECT s.date, s.product_sku, s.type,
|
return "SELECT s.date, s.product_sku, s.type,
|
||||||
ROUND(COALESCE(s.`in`, 0), 2) AS stock_in,
|
COALESCE(s.`in`, 0) AS stock_in,
|
||||||
ROUND(COALESCE(s.`out`, 0), 2) AS stock_out,
|
COALESCE(s.`out`, 0) AS stock_out,
|
||||||
p.product_name,
|
p.product_name,
|
||||||
{$warehouse_name} AS warehouse_name
|
{$warehouse_name} AS warehouse_name
|
||||||
FROM `{$table}` s
|
FROM `{$table}` s
|
||||||
@@ -677,7 +756,8 @@ class ReportManager
|
|||||||
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";
|
AND s.status = 1
|
||||||
|
AND (s.`in` > 0 OR s.`out` > 0)";
|
||||||
},
|
},
|
||||||
$warehouses
|
$warehouses
|
||||||
));
|
));
|
||||||
@@ -691,6 +771,8 @@ class ReportManager
|
|||||||
|
|
||||||
$items = [];
|
$items = [];
|
||||||
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||||
|
// Decided on the unrounded quantity: a receipt of 0.004 used to round
|
||||||
|
// to 0.00, fall through to "out" and show as -0.
|
||||||
$is_in = (float)$row['stock_in'] > 0;
|
$is_in = (float)$row['stock_in'] > 0;
|
||||||
$items[] = [
|
$items[] = [
|
||||||
'product_name' => $row['product_name'] ?: $row['product_sku'],
|
'product_name' => $row['product_name'] ?: $row['product_sku'],
|
||||||
@@ -771,25 +853,10 @@ class ReportManager
|
|||||||
*/
|
*/
|
||||||
public function getWarehouseLowStockCount(int $warehouse_id): int
|
public function getWarehouseLowStockCount(int $warehouse_id): int
|
||||||
{
|
{
|
||||||
$sth = $this->pdo->prepare(
|
return count(array_filter(
|
||||||
"SELECT wb.product_sku,
|
$this->getLowStockItems(),
|
||||||
ROUND(SUM(wb.total_in) - SUM(wb.total_out), 2) AS balance,
|
fn($item) => $item['warehouse_id'] === $warehouse_id
|
||||||
mp.min_stock
|
));
|
||||||
FROM etl_stock_summary wb
|
|
||||||
INNER JOIN md_product mp
|
|
||||||
ON mp.company_id = wb.company_id
|
|
||||||
AND mp.sku = wb.product_sku
|
|
||||||
WHERE wb.company_id = :company_id
|
|
||||||
AND wb.warehouse_id = :warehouse_id
|
|
||||||
GROUP BY wb.product_sku, mp.min_stock"
|
|
||||||
);
|
|
||||||
$sth->execute([':company_id' => $this->company_id, ':warehouse_id' => $warehouse_id]);
|
|
||||||
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
|
|
||||||
$count = 0;
|
|
||||||
foreach ($rows as $row) {
|
|
||||||
if ((float)$row['balance'] < (float)$row['min_stock']) $count++;
|
|
||||||
}
|
|
||||||
return $count;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1181,16 +1248,19 @@ class ReportManager
|
|||||||
$table = $this->stockTableNameFromWarehouseId((int)$wh['id']);
|
$table = $this->stockTableNameFromWarehouseId((int)$wh['id']);
|
||||||
|
|
||||||
$sth = $this->pdo->query(
|
$sth = $this->pdo->query(
|
||||||
"SELECT lot_number,
|
// Every row of the lot makes it listable; only approved rows
|
||||||
ROUND(SUM(COALESCE(`in`, 0)) - SUM(COALESCE(`out`, 0)), 2) AS lot_balance
|
// count towards the balance. A lot received but not yet
|
||||||
|
// approved used to vanish here while the lot master showed it.
|
||||||
|
// Keyed by SKU as well: two products may share a lot number.
|
||||||
|
"SELECT product_sku, lot_number,
|
||||||
|
ROUND(SUM(CASE WHEN status = 1 THEN COALESCE(`in`, 0) - COALESCE(`out`, 0) ELSE 0 END), 4) AS lot_balance
|
||||||
FROM `{$table}`
|
FROM `{$table}`
|
||||||
WHERE company_id = {$cid}
|
WHERE company_id = {$cid}
|
||||||
AND status = 1
|
AND lot_number <> ''
|
||||||
AND lot_number IS NOT NULL
|
GROUP BY product_sku, lot_number"
|
||||||
GROUP BY lot_number"
|
|
||||||
);
|
);
|
||||||
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $lb) {
|
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $lb) {
|
||||||
$key = $lb['lot_number'];
|
$key = $lb['product_sku'] . "\0" . $lb['lot_number'];
|
||||||
$lot_balance[$key] = ($lot_balance[$key] ?? 0) + (float)$lb['lot_balance'];
|
$lot_balance[$key] = ($lot_balance[$key] ?? 0) + (float)$lb['lot_balance'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1217,16 +1287,22 @@ 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)
|
// 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)));
|
$lot_key = fn($r) => $r['product_sku'] . "\0" . $r['lot_number'];
|
||||||
|
$rows = array_values(array_filter($rows, fn($r) => array_key_exists($lot_key($r), $lot_balance)));
|
||||||
|
|
||||||
foreach ($rows as &$row) {
|
foreach ($rows as &$row) {
|
||||||
$days = (int)$row['days_remaining'];
|
$days = (int)$row['days_remaining'];
|
||||||
$balance = round($lot_balance[$row['lot_number']] ?? 0, 2);
|
$balance = round($lot_balance[$lot_key($row)] ?? 0, 4);
|
||||||
|
|
||||||
$row['balance'] = $balance;
|
$row['balance'] = $balance;
|
||||||
$row['is_active'] = $balance > 0 ? 1 : 0;
|
$row['is_active'] = $balance > 0 ? 1 : 0;
|
||||||
|
|
||||||
if ($balance > 0) $active++;
|
if ($balance > 0) $active++;
|
||||||
|
if ($row['expiry_date'] === null || $row['expiry_date'] === '') {
|
||||||
|
// No expiry recorded: not "expiring today"
|
||||||
|
$row['status'] = 'ok';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if ($days < 0) $expired++;
|
if ($days < 0) $expired++;
|
||||||
if ($days >= 0 && $days <= 30) $near++;
|
if ($days >= 0 && $days <= 30) $near++;
|
||||||
|
|
||||||
@@ -1324,9 +1400,9 @@ class ReportManager
|
|||||||
ON p.company_id = r.company_id
|
ON p.company_id = r.company_id
|
||||||
AND p.sku = r.product_sku
|
AND p.sku = r.product_sku
|
||||||
WHERE r.company_id = :company_id
|
WHERE r.company_id = :company_id
|
||||||
ORDER BY mw.warehouse_name, r.zone,
|
ORDER BY mw.warehouse_name, REGEXP_REPLACE(r.zone, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(r.zone, '[0-9]+$') AS UNSIGNED), r.zone,
|
||||||
CAST(r.aisle AS UNSIGNED), r.aisle,
|
REGEXP_REPLACE(r.aisle, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(r.aisle, '[0-9]+$') AS UNSIGNED), r.aisle,
|
||||||
CAST(r.bin AS UNSIGNED), r.bin"
|
REGEXP_REPLACE(r.bin, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(r.bin, '[0-9]+$') AS UNSIGNED), r.bin"
|
||||||
);
|
);
|
||||||
$sth->execute([':company_id' => $this->company_id]);
|
$sth->execute([':company_id' => $this->company_id]);
|
||||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|||||||
@@ -122,13 +122,28 @@ class ReturnManager {
|
|||||||
$sth = $this->pdo->prepare(
|
$sth = $this->pdo->prepare(
|
||||||
"INSERT INTO td_return_item
|
"INSERT INTO td_return_item
|
||||||
(company_id, return_id, item_id, product_sku, product_name,
|
(company_id, return_id, item_id, product_sku, product_name,
|
||||||
quantity, unit_price, total_price, tax_amount, tax_rate, warehouse_id, stock_out_id, stock_out_warehouse_id)
|
quantity, unit_price, total_price, tax_amount, tax_rate, warehouse_id, stock_out_id, stock_out_warehouse_id,
|
||||||
|
zone, aisle, bin)
|
||||||
VALUES
|
VALUES
|
||||||
(:company_id, :return_id, :item_id, :product_sku, :product_name,
|
(:company_id, :return_id, :item_id, :product_sku, :product_name,
|
||||||
:quantity, :unit_price, :total_price, :tax_amount, :tax_rate, :warehouse_id, :stock_out_id, :stock_out_warehouse_id)"
|
:quantity, :unit_price, :total_price, :tax_amount, :tax_rate, :warehouse_id, :stock_out_id, :stock_out_warehouse_id,
|
||||||
|
:zone, :aisle, :bin)"
|
||||||
);
|
);
|
||||||
foreach ($items as $pos => $item) {
|
foreach ($items as $pos => $item) {
|
||||||
|
// Put-away location chosen on the form. In simple location mode
|
||||||
|
// the page sends only the bin; zone and aisle mirror it, the same
|
||||||
|
// convention stock-in and goods receipt use, because md_bin is
|
||||||
|
// looked up on all three.
|
||||||
|
$bin = trim((string)($item['bin'] ?? ''));
|
||||||
|
$zone = trim((string)($item['zone'] ?? ''));
|
||||||
|
$aisle = trim((string)($item['aisle'] ?? ''));
|
||||||
|
if ($zone === '' && $bin !== '') $zone = $bin;
|
||||||
|
if ($aisle === '' && $bin !== '') $aisle = $bin;
|
||||||
|
|
||||||
$sth->execute([
|
$sth->execute([
|
||||||
|
':zone' => $zone,
|
||||||
|
':aisle' => $aisle,
|
||||||
|
':bin' => $bin,
|
||||||
':company_id' => $this->company_id,
|
':company_id' => $this->company_id,
|
||||||
':return_id' => $return_id,
|
':return_id' => $return_id,
|
||||||
':item_id' => $pos + 1,
|
':item_id' => $pos + 1,
|
||||||
@@ -169,7 +184,10 @@ class ReturnManager {
|
|||||||
$sth = $this->pdo->prepare(
|
$sth = $this->pdo->prepare(
|
||||||
"SELECT r.*,
|
"SELECT r.*,
|
||||||
COALESCE(c.contact_name, '') AS contact_name,
|
COALESCE(c.contact_name, '') AS contact_name,
|
||||||
o.order_number
|
o.order_number,
|
||||||
|
(SELECT COUNT(*) FROM td_return_item i
|
||||||
|
WHERE i.company_id = r.company_id
|
||||||
|
AND i.return_id = r.id) AS item_count
|
||||||
FROM td_return r
|
FROM td_return r
|
||||||
LEFT JOIN md_contact c
|
LEFT JOIN md_contact c
|
||||||
ON c.company_id = r.company_id
|
ON c.company_id = r.company_id
|
||||||
@@ -471,6 +489,16 @@ class ReturnManager {
|
|||||||
throw new Exception("Item #{$i}: missing linked stock-out record.");
|
throw new Exception("Item #{$i}: missing linked stock-out record.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Returned goods are put back into a specific bin. A return saved
|
||||||
|
// before the location columns existed has none recorded; name the
|
||||||
|
// fix rather than letting occupyBin() fail on an empty location.
|
||||||
|
if (trim((string)($item['bin'] ?? '')) === '') {
|
||||||
|
$name = $item['product_name'] ?: $product_sku;
|
||||||
|
throw new Exception(
|
||||||
|
"\"{$name}\" has no return location. Open the return, choose where it goes back to, save, then confirm."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
$stock_out_table = $this->stockTableNameFromWarehouseId($stock_out_wh);
|
$stock_out_table = $this->stockTableNameFromWarehouseId($stock_out_wh);
|
||||||
$stock_out_sth = $this->pdo->prepare(
|
$stock_out_sth = $this->pdo->prepare(
|
||||||
"SELECT status, lot_number, serial_number, price
|
"SELECT status, lot_number, serial_number, price
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
require_once __DIR__ . '/../module/mailer.php';
|
require_once __DIR__ . '/../module/mailer.php';
|
||||||
|
require_once __DIR__ . '/../secret_box.php';
|
||||||
|
|
||||||
class SmtpManager
|
class SmtpManager
|
||||||
{
|
{
|
||||||
@@ -173,7 +174,7 @@ class SmtpManager
|
|||||||
|
|
||||||
private function encryptPassword(string $plain): string
|
private function encryptPassword(string $plain): string
|
||||||
{
|
{
|
||||||
return openssl_encrypt($plain, $this->method, $this->pinkey, 0, $this->iv);
|
return secret_encrypt($plain, $this->pinkey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
|
|||||||
@@ -26,6 +26,18 @@ require_once __DIR__ . '/../notify_node.php';
|
|||||||
*/
|
*/
|
||||||
class StockManager {
|
class StockManager {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scale of every stock quantity column (`in`, `out`, td_*_item.quantity are
|
||||||
|
* all decimal(18,4)). Anything finer than this cannot be stored: MySQL
|
||||||
|
* rounds it on insert, so a quantity of 0.0000001 silently became 0.0000
|
||||||
|
* and produced a movement of nothing that still left the source document
|
||||||
|
* "partially received".
|
||||||
|
*/
|
||||||
|
public const QTY_SCALE = 4;
|
||||||
|
|
||||||
|
/** Smallest quantity the schema can represent — 0.0001. */
|
||||||
|
public const QTY_MIN = 0.0001;
|
||||||
|
|
||||||
private PDO $pdo;
|
private PDO $pdo;
|
||||||
private int $company_id;
|
private int $company_id;
|
||||||
|
|
||||||
@@ -56,6 +68,39 @@ class StockManager {
|
|||||||
return 'td_stock_' . $warehouse_id;
|
return 'td_stock_' . $warehouse_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Round a quantity to the stored scale and reject values that cannot be
|
||||||
|
* represented.
|
||||||
|
*
|
||||||
|
* A positive input that rounds to zero is a mistake worth naming — the
|
||||||
|
* caller asked to move some stock and would otherwise get a zero-quantity
|
||||||
|
* movement that looks successful and reports as "0.00" everywhere.
|
||||||
|
*
|
||||||
|
* @param mixed $value Raw client input.
|
||||||
|
* @param string $label Field name used in the error message.
|
||||||
|
* @return float Quantity rounded to QTY_SCALE.
|
||||||
|
* @throws Exception When the value is not a usable quantity.
|
||||||
|
*/
|
||||||
|
public static function normaliseQuantity($value, string $label = 'Quantity'): float
|
||||||
|
{
|
||||||
|
$raw = (float)$value;
|
||||||
|
|
||||||
|
if ($raw <= 0) {
|
||||||
|
throw new Exception("{$label} must be greater than zero.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$rounded = round($raw, self::QTY_SCALE);
|
||||||
|
|
||||||
|
if ($rounded < self::QTY_MIN) {
|
||||||
|
throw new Exception(
|
||||||
|
"{$label} of {$raw} is smaller than the minimum the system records (" .
|
||||||
|
rtrim(rtrim(number_format(self::QTY_MIN, self::QTY_SCALE), '0'), '.') . ")."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rounded;
|
||||||
|
}
|
||||||
|
|
||||||
private function stockReferenceSql(): string
|
private function stockReferenceSql(): string
|
||||||
{
|
{
|
||||||
return "CONCAT(DATE_FORMAT(COALESCE(a.`date`, a.updated_at), '%Y%m%d%H%i%s'), '-', LPAD(a.id, 11, '0'))";
|
return "CONCAT(DATE_FORMAT(COALESCE(a.`date`, a.updated_at), '%Y%m%d%H%i%s'), '-', LPAD(a.id, 11, '0'))";
|
||||||
@@ -72,35 +117,93 @@ class StockManager {
|
|||||||
* The 'quantity' alias resolves to the correct column (in or out) depending
|
* The 'quantity' alias resolves to the correct column (in or out) depending
|
||||||
* on the type. For transfers, only the outbound row is listed (out > 0).
|
* on the type. For transfers, only the outbound row is listed (out > 0).
|
||||||
*
|
*
|
||||||
* @param int $warehouse_id The md_warehouse.id to query.
|
* @param int $warehouse_id The md_warehouse.id to query, or 0 for every
|
||||||
|
* warehouse of this company.
|
||||||
* @param string $type Movement type: 'in' | 'out' | 'transfer'.
|
* @param string $type Movement type: 'in' | 'out' | 'transfer'.
|
||||||
* @return array Stock rows ordered by date DESC, each with 'quantity' and 'product_name'.
|
* @return array Stock rows ordered by date DESC, each with 'quantity',
|
||||||
|
* 'product_name', 'warehouse_id' and 'warehouse_name'.
|
||||||
*/
|
*/
|
||||||
public function getStockList(int $warehouse_id, string $type): array
|
public function getStockList(int $warehouse_id, string $type): array
|
||||||
{
|
{
|
||||||
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
|
// warehouse_id 0 = every warehouse. Stock lives in one table per
|
||||||
$column = $type === 'out' ? 'ROUND(a.out, 2)' : 'ROUND(a.in, 2)';
|
// warehouse, so a single-warehouse list hides the rest of a receipt
|
||||||
|
// that was split across warehouses — a 4-line PO received into two of
|
||||||
|
// them looked like only 3 lines had been received.
|
||||||
|
$warehouses = $warehouse_id > 0
|
||||||
|
? [$warehouse_id]
|
||||||
|
: $this->warehouseIdsWithStockTable();
|
||||||
|
|
||||||
|
// The transfer list shows the outbound row, whose quantity is in `out` (its `in` is always 0).
|
||||||
|
// Quantity is NOT rounded for display here: rounding to 2 dp reports a
|
||||||
|
// small-but-real quantity as "0.00", which reads as missing data.
|
||||||
|
$column = in_array($type, ['out', 'transfer'], true) ? 'a.out' : 'a.in';
|
||||||
$stock_ref = $this->stockReferenceSql();
|
$stock_ref = $this->stockReferenceSql();
|
||||||
|
|
||||||
// Transfer list: show only the outbound side (out > 0) to avoid duplicate display
|
// Transfer list: show only the outbound side (out > 0) to avoid duplicate display
|
||||||
$extra_cond = ($type === 'transfer') ? 'AND a.out > 0' : '';
|
$extra_cond = ($type === 'transfer') ? 'AND a.out > 0' : '';
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
|
||||||
|
foreach ($warehouses as $wh_id) {
|
||||||
|
$table = $this->stockTableNameFromWarehouseId($wh_id);
|
||||||
|
|
||||||
|
$sth = $this->pdo->prepare(
|
||||||
|
"SELECT a.*, {$stock_ref} AS stock_reference, {$column} AS quantity,
|
||||||
|
b.product_name, b.uom,
|
||||||
|
w.warehouse_name
|
||||||
|
FROM `{$table}` a
|
||||||
|
LEFT JOIN md_product b
|
||||||
|
ON a.company_id = b.company_id
|
||||||
|
AND a.product_sku = b.sku
|
||||||
|
LEFT JOIN md_warehouse w
|
||||||
|
ON w.company_id = a.company_id
|
||||||
|
AND w.id = :warehouse_id
|
||||||
|
WHERE a.company_id = :company_id
|
||||||
|
AND a.type = :type
|
||||||
|
{$extra_cond}
|
||||||
|
ORDER BY a.date DESC"
|
||||||
|
);
|
||||||
|
$sth->execute([
|
||||||
|
':company_id' => $this->company_id,
|
||||||
|
':warehouse_id' => $wh_id,
|
||||||
|
':type' => $type,
|
||||||
|
]);
|
||||||
|
|
||||||
|
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||||
|
// The row's own warehouse, so the list can link each Action
|
||||||
|
// back to the right td_stock_<id> table when showing them all.
|
||||||
|
$row['warehouse_id'] = $wh_id;
|
||||||
|
$rows[] = $row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-sort across warehouses — each table was only ordered internally.
|
||||||
|
usort($rows, fn($x, $y) => strcmp((string)($y['date'] ?? ''), (string)($x['date'] ?? '')));
|
||||||
|
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Warehouse ids of this company that actually have a stock table.
|
||||||
|
*
|
||||||
|
* td_stock_<id> tables are created lazily on first use, so a warehouse with
|
||||||
|
* no movements yet has none and must be skipped rather than queried.
|
||||||
|
*
|
||||||
|
* @return int[]
|
||||||
|
*/
|
||||||
|
private function warehouseIdsWithStockTable(): array
|
||||||
|
{
|
||||||
$sth = $this->pdo->prepare(
|
$sth = $this->pdo->prepare(
|
||||||
"SELECT a.*, {$stock_ref} AS stock_reference, {$column} AS quantity, b.product_name, b.uom
|
"SELECT w.id
|
||||||
FROM `{$table}` a
|
FROM md_warehouse w
|
||||||
LEFT JOIN md_product b
|
JOIN information_schema.tables t
|
||||||
ON a.company_id = b.company_id
|
ON t.table_schema = DATABASE()
|
||||||
AND a.product_sku = b.sku
|
AND t.table_name = CONCAT('td_stock_', w.id)
|
||||||
WHERE a.company_id = :company_id
|
WHERE w.company_id = :company_id
|
||||||
AND a.type = :type
|
ORDER BY w.id"
|
||||||
{$extra_cond}
|
|
||||||
ORDER BY a.date DESC"
|
|
||||||
);
|
);
|
||||||
$sth->execute([
|
$sth->execute([':company_id' => $this->company_id]);
|
||||||
':company_id' => $this->company_id,
|
return array_map('intval', $sth->fetchAll(PDO::FETCH_COLUMN));
|
||||||
':type' => $type,
|
|
||||||
]);
|
|
||||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -204,7 +307,10 @@ class StockManager {
|
|||||||
$sth->execute([':company_id' => $this->company_id, ':id' => $id]);
|
$sth->execute([':company_id' => $this->company_id, ':id' => $id]);
|
||||||
$output = $sth->fetch(PDO::FETCH_ASSOC);
|
$output = $sth->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
if (!$output) return false;
|
// Only a transfer's outbound row names a destination. Any other row
|
||||||
|
// (a stock-in or stock-out reached through a stale or edited link) has
|
||||||
|
// no ref_warehouse, and resolving it threw "Invalid warehouse id."
|
||||||
|
if (!$output || $output['type'] !== 'transfer' || (int)$output['ref_warehouse'] <= 0) return false;
|
||||||
|
|
||||||
// Resolve the inbound (to) row via ref_warehouse + uuid
|
// Resolve the inbound (to) row via ref_warehouse + uuid
|
||||||
$to_warehouse_id = (int)$output['ref_warehouse'];
|
$to_warehouse_id = (int)$output['ref_warehouse'];
|
||||||
@@ -257,8 +363,8 @@ class StockManager {
|
|||||||
$warehouse_id = (int)($data["warehouse"] ?? 0);
|
$warehouse_id = (int)($data["warehouse"] ?? 0);
|
||||||
$quantity = (float)($data['quantity'] ?? 0);
|
$quantity = (float)($data['quantity'] ?? 0);
|
||||||
|
|
||||||
if ($id === 0 && $quantity <= 0) {
|
if ($id === 0) {
|
||||||
throw new Exception("Quantity must be greater than zero.");
|
$quantity = self::normaliseQuantity($quantity);
|
||||||
}
|
}
|
||||||
$whMgmt = new WarehouseManager($this->pdo, $this->company_id);
|
$whMgmt = new WarehouseManager($this->pdo, $this->company_id);
|
||||||
|
|
||||||
@@ -439,8 +545,9 @@ class StockManager {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Quantity and identifiers come from the existing stock_in row (immutable)
|
// Quantity and identifiers come from the existing stock_in row (immutable).
|
||||||
$quantity = (int)$source_stock['in'];
|
// `in` is decimal(18,4) — an int cast would drop fractional quantities.
|
||||||
|
$quantity = (float)$source_stock['in'];
|
||||||
$ref_id = (int)$source_stock['id'];
|
$ref_id = (int)$source_stock['id'];
|
||||||
$lot_number = $source_stock['lot_number'] ?? null;
|
$lot_number = $source_stock['lot_number'] ?? null;
|
||||||
$serial_number = $source_stock['serial_number'] ?? null;
|
$serial_number = $source_stock['serial_number'] ?? null;
|
||||||
@@ -611,8 +718,9 @@ class StockManager {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Quantity and identifiers come from the source stock_in row (immutable)
|
// Quantity and identifiers come from the source stock_in row (immutable).
|
||||||
$quantity = (int)$source_stock['in'];
|
// `in` is decimal(18,4) — an int cast would drop fractional quantities.
|
||||||
|
$quantity = (float)$source_stock['in'];
|
||||||
$lot_number = $source_stock['lot_number'] ?? null;
|
$lot_number = $source_stock['lot_number'] ?? null;
|
||||||
$serial_number = $source_stock['serial_number'] ?? null;
|
$serial_number = $source_stock['serial_number'] ?? null;
|
||||||
|
|
||||||
|
|||||||
@@ -154,7 +154,10 @@ class SupplierReturnManager {
|
|||||||
$sth = $this->pdo->prepare(
|
$sth = $this->pdo->prepare(
|
||||||
"SELECT r.*,
|
"SELECT r.*,
|
||||||
COALESCE(c.contact_name, '') AS contact_name,
|
COALESCE(c.contact_name, '') AS contact_name,
|
||||||
p.po_number
|
p.po_number,
|
||||||
|
(SELECT COUNT(*) FROM td_supplier_return_item i
|
||||||
|
WHERE i.company_id = r.company_id
|
||||||
|
AND i.return_id = r.id) AS item_count
|
||||||
FROM td_supplier_return r
|
FROM td_supplier_return r
|
||||||
LEFT JOIN md_contact c
|
LEFT JOIN md_contact c
|
||||||
ON c.company_id = r.company_id
|
ON c.company_id = r.company_id
|
||||||
|
|||||||
@@ -1035,7 +1035,7 @@ class WarehouseManager {
|
|||||||
$sth = $this->pdo->prepare(
|
$sth = $this->pdo->prepare(
|
||||||
"SELECT DISTINCT zone FROM md_bin
|
"SELECT DISTINCT zone FROM md_bin
|
||||||
WHERE company_id = :company_id AND warehouse = :warehouse
|
WHERE company_id = :company_id AND warehouse = :warehouse
|
||||||
ORDER BY CAST(zone AS UNSIGNED), zone"
|
ORDER BY REGEXP_REPLACE(zone, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(zone, '[0-9]+$') AS UNSIGNED), zone"
|
||||||
);
|
);
|
||||||
$sth->execute([':company_id' => $this->company_id, ':warehouse' => $warehouse_id]);
|
$sth->execute([':company_id' => $this->company_id, ':warehouse' => $warehouse_id]);
|
||||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||||
@@ -1055,7 +1055,7 @@ class WarehouseManager {
|
|||||||
$sth = $this->pdo->prepare(
|
$sth = $this->pdo->prepare(
|
||||||
"SELECT DISTINCT aisle FROM md_bin
|
"SELECT DISTINCT aisle FROM md_bin
|
||||||
WHERE company_id = :company_id AND warehouse = :warehouse AND zone = :zone
|
WHERE company_id = :company_id AND warehouse = :warehouse AND zone = :zone
|
||||||
ORDER BY CAST(aisle AS UNSIGNED), aisle"
|
ORDER BY REGEXP_REPLACE(aisle, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(aisle, '[0-9]+$') AS UNSIGNED), aisle"
|
||||||
);
|
);
|
||||||
$sth->execute([':company_id' => $this->company_id, ':warehouse' => $warehouse_id, ':zone' => $zone]);
|
$sth->execute([':company_id' => $this->company_id, ':warehouse' => $warehouse_id, ':zone' => $zone]);
|
||||||
return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'aisle');
|
return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'aisle');
|
||||||
@@ -1077,7 +1077,7 @@ class WarehouseManager {
|
|||||||
$sth = $this->pdo->prepare(
|
$sth = $this->pdo->prepare(
|
||||||
"SELECT DISTINCT bin FROM md_bin
|
"SELECT DISTINCT bin FROM md_bin
|
||||||
WHERE company_id = :company_id AND warehouse = :warehouse
|
WHERE company_id = :company_id AND warehouse = :warehouse
|
||||||
ORDER BY CAST(bin AS UNSIGNED), bin"
|
ORDER BY REGEXP_REPLACE(bin, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(bin, '[0-9]+$') AS UNSIGNED), bin"
|
||||||
);
|
);
|
||||||
$sth->execute([':company_id' => $this->company_id, ':warehouse' => $warehouse_id]);
|
$sth->execute([':company_id' => $this->company_id, ':warehouse' => $warehouse_id]);
|
||||||
return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'bin');
|
return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'bin');
|
||||||
@@ -1086,7 +1086,7 @@ class WarehouseManager {
|
|||||||
$sth = $this->pdo->prepare(
|
$sth = $this->pdo->prepare(
|
||||||
"SELECT DISTINCT bin FROM md_bin
|
"SELECT DISTINCT bin FROM md_bin
|
||||||
WHERE company_id = :company_id AND warehouse = :warehouse AND zone = :zone AND aisle = :aisle
|
WHERE company_id = :company_id AND warehouse = :warehouse AND zone = :zone AND aisle = :aisle
|
||||||
ORDER BY CAST(bin AS UNSIGNED), bin"
|
ORDER BY REGEXP_REPLACE(bin, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(bin, '[0-9]+$') AS UNSIGNED), bin"
|
||||||
);
|
);
|
||||||
$sth->execute([':company_id' => $this->company_id, ':warehouse' => $warehouse_id, ':zone' => $zone, ':aisle' => $aisle]);
|
$sth->execute([':company_id' => $this->company_id, ':warehouse' => $warehouse_id, ':zone' => $zone, ':aisle' => $aisle]);
|
||||||
return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'bin');
|
return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'bin');
|
||||||
@@ -1915,7 +1915,7 @@ class WarehouseManager {
|
|||||||
WHERE company_id = :company_id
|
WHERE company_id = :company_id
|
||||||
AND warehouse = :warehouse
|
AND warehouse = :warehouse
|
||||||
AND product_sku IS NULL
|
AND product_sku IS NULL
|
||||||
ORDER BY CAST(zone AS UNSIGNED), zone"
|
ORDER BY REGEXP_REPLACE(zone, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(zone, '[0-9]+$') AS UNSIGNED), zone"
|
||||||
);
|
);
|
||||||
$sth->execute([':company_id' => $this->company_id, ':warehouse' => $warehouse_id]);
|
$sth->execute([':company_id' => $this->company_id, ':warehouse' => $warehouse_id]);
|
||||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||||
@@ -1961,7 +1961,7 @@ class WarehouseManager {
|
|||||||
{$lot_cond}
|
{$lot_cond}
|
||||||
{$serial_cond}
|
{$serial_cond}
|
||||||
)
|
)
|
||||||
ORDER BY CAST(r.zone AS UNSIGNED), r.zone"
|
ORDER BY REGEXP_REPLACE(r.zone, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(r.zone, '[0-9]+$') AS UNSIGNED), r.zone"
|
||||||
);
|
);
|
||||||
$sth->execute($params);
|
$sth->execute($params);
|
||||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||||
@@ -1982,7 +1982,7 @@ class WarehouseManager {
|
|||||||
AND warehouse = :warehouse
|
AND warehouse = :warehouse
|
||||||
AND zone = :zone
|
AND zone = :zone
|
||||||
AND product_sku IS NULL
|
AND product_sku IS NULL
|
||||||
ORDER BY CAST(aisle AS UNSIGNED), aisle"
|
ORDER BY REGEXP_REPLACE(aisle, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(aisle, '[0-9]+$') AS UNSIGNED), aisle"
|
||||||
);
|
);
|
||||||
$sth->execute([
|
$sth->execute([
|
||||||
':company_id' => $this->company_id,
|
':company_id' => $this->company_id,
|
||||||
@@ -2033,7 +2033,7 @@ class WarehouseManager {
|
|||||||
{$lot_cond}
|
{$lot_cond}
|
||||||
{$serial_cond}
|
{$serial_cond}
|
||||||
)
|
)
|
||||||
ORDER BY CAST(r.aisle AS UNSIGNED), r.aisle"
|
ORDER BY REGEXP_REPLACE(r.aisle, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(r.aisle, '[0-9]+$') AS UNSIGNED), r.aisle"
|
||||||
);
|
);
|
||||||
$sth->execute($params);
|
$sth->execute($params);
|
||||||
return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'aisle');
|
return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'aisle');
|
||||||
@@ -2055,7 +2055,7 @@ class WarehouseManager {
|
|||||||
WHERE company_id = :company_id
|
WHERE company_id = :company_id
|
||||||
AND warehouse = :warehouse
|
AND warehouse = :warehouse
|
||||||
AND product_sku IS NULL
|
AND product_sku IS NULL
|
||||||
ORDER BY CAST(bin AS UNSIGNED), bin"
|
ORDER BY REGEXP_REPLACE(bin, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(bin, '[0-9]+$') AS UNSIGNED), bin"
|
||||||
);
|
);
|
||||||
$sth->execute([
|
$sth->execute([
|
||||||
':company_id' => $this->company_id,
|
':company_id' => $this->company_id,
|
||||||
@@ -2071,7 +2071,7 @@ class WarehouseManager {
|
|||||||
AND zone = :zone
|
AND zone = :zone
|
||||||
AND aisle = :aisle
|
AND aisle = :aisle
|
||||||
AND product_sku IS NULL
|
AND product_sku IS NULL
|
||||||
ORDER BY CAST(bin AS UNSIGNED), bin"
|
ORDER BY REGEXP_REPLACE(bin, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(bin, '[0-9]+$') AS UNSIGNED), bin"
|
||||||
);
|
);
|
||||||
$sth->execute([
|
$sth->execute([
|
||||||
':company_id' => $this->company_id,
|
':company_id' => $this->company_id,
|
||||||
@@ -2130,7 +2130,7 @@ class WarehouseManager {
|
|||||||
{$lot_cond}
|
{$lot_cond}
|
||||||
{$serial_cond}
|
{$serial_cond}
|
||||||
)
|
)
|
||||||
ORDER BY CAST(r.bin AS UNSIGNED), r.bin"
|
ORDER BY REGEXP_REPLACE(r.bin, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(r.bin, '[0-9]+$') AS UNSIGNED), r.bin"
|
||||||
);
|
);
|
||||||
$sth->execute($params);
|
$sth->execute($params);
|
||||||
return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'bin');
|
return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'bin');
|
||||||
|
|||||||
@@ -697,7 +697,7 @@ class FinancialReports
|
|||||||
COALESCE(d.dept_code, '') AS dept_code,
|
COALESCE(d.dept_code, '') AS dept_code,
|
||||||
COALESCE(d.dept_name, '') AS dept_name,
|
COALESCE(d.dept_name, '') AS dept_name,
|
||||||
COALESCE(g.journal_date, DATE(g.created_at)) AS entry_date,
|
COALESCE(g.journal_date, DATE(g.created_at)) AS entry_date,
|
||||||
DATE_FORMAT(g.created_at, '%d/%m/%Y %H:%i') AS posted_at,
|
DATE_FORMAT(g.created_at, '%Y-%m-%d %H:%i:%s') AS posted_at,
|
||||||
i.debit,
|
i.debit,
|
||||||
i.credit,
|
i.credit,
|
||||||
COALESCE(i.description, '') AS line_description
|
COALESCE(i.description, '') AS line_description
|
||||||
|
|||||||
@@ -81,7 +81,11 @@ class GlManager
|
|||||||
$now = date('Y-m-d H:i:s');
|
$now = date('Y-m-d H:i:s');
|
||||||
|
|
||||||
$sth = $this->pdo->prepare(
|
$sth = $this->pdo->prepare(
|
||||||
"SELECT id, current_version, formula_id, history
|
// `period` is read below as $old_period to reverse the old ETL
|
||||||
|
// totals. It was missing from this list, so it was always null and
|
||||||
|
// upsertEtl(string $period) threw a TypeError — every edit of a
|
||||||
|
// manual journal ended in HTTP 500.
|
||||||
|
"SELECT id, current_version, formula_id, history, period
|
||||||
FROM td_gl
|
FROM td_gl
|
||||||
WHERE company_id = :cid AND id = :gl_id AND source_type = 'manual'
|
WHERE company_id = :cid AND id = :gl_id AND source_type = 'manual'
|
||||||
FOR UPDATE"
|
FOR UPDATE"
|
||||||
|
|||||||
@@ -75,8 +75,8 @@ class GlQueryManager
|
|||||||
g.current_version,
|
g.current_version,
|
||||||
g.formula_id,
|
g.formula_id,
|
||||||
COALESCE(f.formula_name, '') AS formula_name,
|
COALESCE(f.formula_name, '') AS formula_name,
|
||||||
DATE_FORMAT(g.created_at, '%d/%m/%Y %H:%i') AS posted_at,
|
DATE_FORMAT(g.created_at, '%Y-%m-%d %H:%i:%s') AS posted_at,
|
||||||
DATE_FORMAT(g.updated_at, '%d/%m/%Y %H:%i') AS updated_at,
|
DATE_FORMAT(g.updated_at, '%Y-%m-%d %H:%i:%s') AS updated_at,
|
||||||
CASE g.source_type
|
CASE g.source_type
|
||||||
WHEN 'receipt' THEN r.receipt_number
|
WHEN 'receipt' THEN r.receipt_number
|
||||||
WHEN 'payment' THEN p.payment_number
|
WHEN 'payment' THEN p.payment_number
|
||||||
@@ -142,8 +142,8 @@ class GlQueryManager
|
|||||||
|
|
||||||
$sth = $this->pdo->prepare(
|
$sth = $this->pdo->prepare(
|
||||||
"SELECT g.*,
|
"SELECT g.*,
|
||||||
DATE_FORMAT(g.created_at, '%d/%m/%Y %H:%i') AS posted_at,
|
DATE_FORMAT(g.created_at, '%Y-%m-%d %H:%i:%s') AS posted_at,
|
||||||
DATE_FORMAT(g.updated_at, '%d/%m/%Y %H:%i') AS updated_at_fmt,
|
DATE_FORMAT(g.updated_at, '%Y-%m-%d %H:%i:%s') AS updated_at_fmt,
|
||||||
DATE_FORMAT(g.journal_date, '%d/%m/%Y') AS journal_date_fmt,
|
DATE_FORMAT(g.journal_date, '%d/%m/%Y') AS journal_date_fmt,
|
||||||
COALESCE(f.formula_name, '') AS formula_name
|
COALESCE(f.formula_name, '') AS formula_name
|
||||||
FROM td_gl g
|
FROM td_gl g
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ header('Content-Type: application/json; charset=utf-8');
|
|||||||
require_once __DIR__ . '/../../config.php';
|
require_once __DIR__ . '/../../config.php';
|
||||||
require_once __DIR__ . '/../../dbconn.php';
|
require_once __DIR__ . '/../../dbconn.php';
|
||||||
|
|
||||||
$secret = $_SERVER['HTTP_X_CRON_SECRET'] ?? '';
|
// An empty configured secret must never match an empty header.
|
||||||
if (!defined('NODE_EMIT_SECRET') || $secret !== NODE_EMIT_SECRET) {
|
$secret = (string)($_SERVER['HTTP_X_CRON_SECRET'] ?? '');
|
||||||
|
if (!defined('NODE_EMIT_SECRET') || NODE_EMIT_SECRET === '' || !hash_equals((string)NODE_EMIT_SECRET, $secret)) {
|
||||||
http_response_code(403);
|
http_response_code(403);
|
||||||
exit(json_encode(['success' => 0, 'message' => 'Forbidden']));
|
exit(json_encode(['success' => 0, 'message' => 'Forbidden']));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,38 @@ ini_set('display_errors', 0);
|
|||||||
ini_set('log_errors', 1);
|
ini_set('log_errors', 1);
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
// Last-resort handler for exceptions an engine does not catch itself. Many
|
||||||
|
// engines call a manager with no try/catch, so any exception — including the
|
||||||
|
// managers' own deliberate validation messages — used to end as a PHP fatal
|
||||||
|
// with an empty 500 body, which the browser could only report as "Server
|
||||||
|
// error occurred." This mirrors the convention the catching engines already
|
||||||
|
// use: a manager's Exception carries a user-facing message (400); a database
|
||||||
|
// or engine fault stays generic (500) and goes to the server log.
|
||||||
|
set_exception_handler(function (Throwable $e) {
|
||||||
|
while (ob_get_level() > 0) ob_end_clean();
|
||||||
|
if (!headers_sent()) header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
if ($e instanceof PDOException) {
|
||||||
|
error_log('Uncaught PDOException: ' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine());
|
||||||
|
http_response_code(500);
|
||||||
|
$message = 'Database error, please try again.';
|
||||||
|
} elseif ($e instanceof Exception) {
|
||||||
|
http_response_code(400);
|
||||||
|
$message = $e->getMessage();
|
||||||
|
} else {
|
||||||
|
// Error / TypeError: a programming fault, not something to show users.
|
||||||
|
error_log('Uncaught ' . get_class($e) . ': ' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine());
|
||||||
|
http_response_code(500);
|
||||||
|
$message = 'Server error occurred.';
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode(['success' => 0, 'message' => $message]);
|
||||||
|
});
|
||||||
|
|
||||||
require_once __DIR__."/../../config.php";
|
require_once __DIR__."/../../config.php";
|
||||||
require_once __DIR__."/../../dbconn.php";
|
require_once __DIR__."/../../dbconn.php";
|
||||||
require_once __DIR__."/db_helpers.php";
|
require_once __DIR__."/db_helpers.php";
|
||||||
|
require_once __DIR__."/app_access.php";
|
||||||
|
|
||||||
if (!function_exists('require_role')) {
|
if (!function_exists('require_role')) {
|
||||||
function require_role(string $user_role, array $allowed): void {
|
function require_role(string $user_role, array $allowed): void {
|
||||||
@@ -25,14 +54,14 @@ if(!empty($_SESSION["login_company_id"])){
|
|||||||
// CSRF Validation — add right at the top of the logged-in block
|
// CSRF Validation — add right at the top of the logged-in block
|
||||||
if($_SERVER['REQUEST_METHOD'] === 'POST'){
|
if($_SERVER['REQUEST_METHOD'] === 'POST'){
|
||||||
$csrf_token = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
$csrf_token = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||||
if(empty($csrf_token) || $csrf_token !== $_SESSION['csrf_token']){
|
if(empty($csrf_token) || !hash_equals((string)($_SESSION['csrf_token'] ?? ''), $csrf_token)){
|
||||||
http_response_code(403);
|
http_response_code(403);
|
||||||
exit(json_encode(["message" => "Invalid request"]));
|
exit(json_encode(["message" => "Invalid request"]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// validate otp
|
// validate otp
|
||||||
$sql = "SELECT `password`
|
$sql = "SELECT `password`, license, app_access
|
||||||
FROM user
|
FROM user
|
||||||
WHERE user_id = :company_id";
|
WHERE user_id = :company_id";
|
||||||
$sth = $pdo1->prepare($sql);
|
$sth = $pdo1->prepare($sql);
|
||||||
@@ -40,7 +69,8 @@ if(!empty($_SESSION["login_company_id"])){
|
|||||||
":company_id" => $_SESSION["login_user_id"]
|
":company_id" => $_SESSION["login_user_id"]
|
||||||
]);
|
]);
|
||||||
db_check($sth, $answer);
|
db_check($sth, $answer);
|
||||||
$password = $sth->fetchColumn();
|
$user_row = $sth->fetch(PDO::FETCH_ASSOC) ?: [];
|
||||||
|
$password = $user_row['password'] ?? '';
|
||||||
/** Generate OTP */
|
/** Generate OTP */
|
||||||
function generateOTP($sercet_key, $time_step = 180, $length = 6){
|
function generateOTP($sercet_key, $time_step = 180, $length = 6){
|
||||||
$counter = floor($_SESSION["otpTime"] / $time_step);
|
$counter = floor($_SESSION["otpTime"] / $time_step);
|
||||||
@@ -55,7 +85,9 @@ if(!empty($_SESSION["login_company_id"])){
|
|||||||
$otp = generateOTP($password);
|
$otp = generateOTP($password);
|
||||||
|
|
||||||
if( $_SESSION["otp"]!=$otp ){
|
if( $_SESSION["otp"]!=$otp ){
|
||||||
|
http_response_code(401);
|
||||||
$answer["message"] = "Your password has been reset, Please logout and login again.";
|
$answer["message"] = "Your password has been reset, Please logout and login again.";
|
||||||
|
$answer["code"] = "password_changed";
|
||||||
exit(json_encode($answer));
|
exit(json_encode($answer));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,13 +102,29 @@ if(!empty($_SESSION["login_company_id"])){
|
|||||||
$map = $sth->fetchAll(PDO::FETCH_ASSOC);
|
$map = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
if( count($map)==0 ){
|
if( count($map)==0 ){
|
||||||
|
http_response_code(403);
|
||||||
$answer["message"] = "Your accessibility to this company has been removed.";
|
$answer["message"] = "Your accessibility to this company has been removed.";
|
||||||
|
$answer["code"] = "access_removed";
|
||||||
exit(json_encode($answer));
|
exit(json_encode($answer));
|
||||||
}
|
}
|
||||||
|
|
||||||
$user_role = $map[0]['role'] ?? 'viewer';
|
$user_role = $map[0]['role'] ?? 'viewer';
|
||||||
$_SESSION['login_role'] = $user_role;
|
$_SESSION['login_role'] = $user_role;
|
||||||
|
|
||||||
|
// App access (WMS / Accounting), re-read on every request so a change made in
|
||||||
|
// Setting → Users applies at once. Owners hold it on their own user row;
|
||||||
|
// invited users per company (same rule as login_confirm.php).
|
||||||
|
$app_access = (($user_row['license'] ?? 'owner') === 'owner')
|
||||||
|
? ($user_row['app_access'] ?? 'wms')
|
||||||
|
: ($map[0]['app_access'] ?? 'wms');
|
||||||
|
$_SESSION['login_app_access'] = $app_access;
|
||||||
|
|
||||||
|
$required_app = app_access_app_for($_SERVER['SCRIPT_NAME'] ?? '');
|
||||||
|
if ($required_app !== null && !app_access_allows($app_access, $required_app)) {
|
||||||
|
http_response_code(403);
|
||||||
|
exit(json_encode(['success' => 0, 'message' => 'Your account does not have access to this module.']));
|
||||||
|
}
|
||||||
|
|
||||||
// Single-session enforcement: if a session_token was issued at login, verify
|
// Single-session enforcement: if a session_token was issued at login, verify
|
||||||
// it still matches the DB. A mismatch means a newer login has taken over.
|
// it still matches the DB. A mismatch means a newer login has taken over.
|
||||||
if (!empty($_SESSION['session_token'])) {
|
if (!empty($_SESSION['session_token'])) {
|
||||||
@@ -104,7 +152,12 @@ if(!empty($_SESSION["login_company_id"])){
|
|||||||
// unless the engine explicitly declared itself a pre-auth route.
|
// unless the engine explicitly declared itself a pre-auth route.
|
||||||
if (empty($_SESSION['login_company_id']) && !defined('UNAUTHENTICATED_ROUTE')) {
|
if (empty($_SESSION['login_company_id']) && !defined('UNAUTHENTICATED_ROUTE')) {
|
||||||
http_response_code(401);
|
http_response_code(401);
|
||||||
exit(json_encode(['success' => 0, 'message' => 'Authentication required.']));
|
$expired = !empty($_SESSION['_idle_expired']);
|
||||||
|
exit(json_encode([
|
||||||
|
'success' => 0,
|
||||||
|
'message' => $expired ? 'Your session has expired. Please sign in again.' : 'Authentication required.',
|
||||||
|
'code' => $expired ? 'session_expired' : 'auth_required',
|
||||||
|
]));
|
||||||
}
|
}
|
||||||
|
|
||||||
// set up ANSWER
|
// set up ANSWER
|
||||||
@@ -113,12 +166,19 @@ $answer = array("success"=>0, "message"=>"");
|
|||||||
if (isset($_POST['json'])) {
|
if (isset($_POST['json'])) {
|
||||||
// Old Method: Data is wrapped in a JSON string
|
// Old Method: Data is wrapped in a JSON string
|
||||||
$data = json_decode($_POST['json'], true);
|
$data = json_decode($_POST['json'], true);
|
||||||
|
if (!is_array($data)) {
|
||||||
|
// An undecodable payload used to carry on as an empty request.
|
||||||
|
http_response_code(400);
|
||||||
|
$answer["message"] = "The request could not be read. Please reload the page and try again.";
|
||||||
|
exit(json_encode($answer));
|
||||||
|
}
|
||||||
} else if (isset($_POST['otp'])) {
|
} else if (isset($_POST['otp'])) {
|
||||||
// New Method: Data is sent directly (FormData)
|
// New Method: Data is sent directly (FormData)
|
||||||
// We check for 'otp' because every request should have one
|
// We check for 'otp' because every request should have one
|
||||||
$data = $_POST;
|
$data = $_POST;
|
||||||
} else {
|
} else {
|
||||||
// Truly no data received
|
// Truly no data received
|
||||||
|
http_response_code(400);
|
||||||
$answer["message"] = "Request denied: No valid JSON payload or Form Data detected.";
|
$answer["message"] = "Request denied: No valid JSON payload or Form Data detected.";
|
||||||
exit(json_encode($answer));
|
exit(json_encode($answer));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,7 +72,8 @@ class mailer{
|
|||||||
"input" => $input
|
"input" => $input
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return openssl_decrypt(trim($input["data"]), "AES-256-CBC", $input["key"], 0, "1234567890123456" );
|
require_once __DIR__ . '/../secret_box.php';
|
||||||
|
return secret_decrypt((string)$input["data"], (string)$input["key"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* page_headers.php — security headers for HTML pages (app pages, sign-in pages).
|
||||||
|
*
|
||||||
|
* Call send_page_security_headers() before any output. The Content-Security-Policy
|
||||||
|
* lists what the pages actually load:
|
||||||
|
* - scripts, styles, fonts and data files are all self-hosted under assets/vendor/
|
||||||
|
* (versions in assets/vendor/VERSIONS.json), so no CDN host is allowed;
|
||||||
|
* - the Node.js real-time server (NODE_PUBLIC_URL) serves socket.io.js and the
|
||||||
|
* WebSocket connection;
|
||||||
|
* - 'unsafe-inline' because pages use inline <script> blocks and onclick=
|
||||||
|
* handlers; 'unsafe-eval' because alasql compiles its queries with new Function.
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (!function_exists('send_page_security_headers')) {
|
||||||
|
function send_page_security_headers(): void {
|
||||||
|
if (headers_sent()) return;
|
||||||
|
|
||||||
|
$script = ["'self'", "'unsafe-inline'", "'unsafe-eval'"];
|
||||||
|
$connect = ["'self'"];
|
||||||
|
|
||||||
|
if (defined('NODE_PUBLIC_URL')) {
|
||||||
|
$node = parse_url(NODE_PUBLIC_URL);
|
||||||
|
if (!empty($node['scheme']) && !empty($node['host'])) {
|
||||||
|
$origin = $node['host'] . (isset($node['port']) ? ':' . $node['port'] : '');
|
||||||
|
$secure = strtolower($node['scheme']) === 'https';
|
||||||
|
$script[] = ($secure ? 'https://' : 'http://') . $origin;
|
||||||
|
$connect[] = ($secure ? 'https://' : 'http://') . $origin;
|
||||||
|
$connect[] = ($secure ? 'wss://' : 'ws://') . $origin;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$csp = implode('; ', [
|
||||||
|
"default-src 'self'",
|
||||||
|
'script-src ' . implode(' ', $script),
|
||||||
|
"style-src 'self' 'unsafe-inline'",
|
||||||
|
"font-src 'self' data:",
|
||||||
|
"img-src 'self' data: blob:",
|
||||||
|
"media-src 'self' blob:",
|
||||||
|
'connect-src ' . implode(' ', $connect),
|
||||||
|
"worker-src 'self' blob:",
|
||||||
|
"frame-src 'self' blob:",
|
||||||
|
"object-src 'none'",
|
||||||
|
"base-uri 'self'",
|
||||||
|
"form-action 'self'",
|
||||||
|
"frame-ancestors 'self'",
|
||||||
|
]);
|
||||||
|
|
||||||
|
header('Content-Security-Policy: ' . $csp, true);
|
||||||
|
header('X-Content-Type-Options: nosniff', true);
|
||||||
|
header('X-Frame-Options: SAMEORIGIN', true);
|
||||||
|
header('Referrer-Policy: strict-origin-when-cross-origin', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* rate_limit.php — DB-backed request throttle for the unauthenticated login,
|
||||||
|
* OTP and registration endpoints.
|
||||||
|
*
|
||||||
|
* Counters live in wms.auth_throttle (created by setup.php), not in the PHP
|
||||||
|
* session: an attacker simply drops the session cookie to reset a session
|
||||||
|
* counter. Each (bucket, key) pair counts hits in a fixed window; keys are
|
||||||
|
* stored as SHA-256 hashes so the table never holds raw IPs or emails.
|
||||||
|
*
|
||||||
|
* The helper fails open: if the table is missing or the query fails, the error
|
||||||
|
* is logged and the request is allowed, so a schema problem can never lock
|
||||||
|
* every user out of the login page.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* require_once '../../../assets/utils/rate_limit.php';
|
||||||
|
* rate_limit_guard($pdo1, [
|
||||||
|
* ['login_ip', rate_limit_client_ip(), 20, 900],
|
||||||
|
* ['login_user', $username, 10, 900],
|
||||||
|
* ]);
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (!function_exists('rate_limit_client_ip')) {
|
||||||
|
/**
|
||||||
|
* The client address as Apache sees it. X-Forwarded-For is deliberately not
|
||||||
|
* trusted here: any client can send it, which would let them pick a fresh
|
||||||
|
* key for every request.
|
||||||
|
*/
|
||||||
|
function rate_limit_client_ip(): string {
|
||||||
|
return (string)($_SERVER['REMOTE_ADDR'] ?? '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('rate_limit_hit')) {
|
||||||
|
/**
|
||||||
|
* Count one hit for ($bucket, $key) and report whether the limit is exceeded.
|
||||||
|
*
|
||||||
|
* @param PDO $pdo Connection to the wms (auth) database.
|
||||||
|
* @param string $bucket Endpoint/purpose name, e.g. 'login_ip'.
|
||||||
|
* @param string $key Raw key (IP, normalised username/email, user id).
|
||||||
|
* @param int $max Hits allowed per window.
|
||||||
|
* @param int $window_seconds Window length in seconds.
|
||||||
|
* @return bool true when this hit is over the limit.
|
||||||
|
*/
|
||||||
|
function rate_limit_hit(PDO $pdo, string $bucket, string $key, int $max, int $window_seconds): bool {
|
||||||
|
if ($key === '') return false;
|
||||||
|
$key_hash = hash('sha256', $bucket . '|' . $key);
|
||||||
|
try {
|
||||||
|
// One statement per hit: a new row starts at 1; an existing row either
|
||||||
|
// restarts its window (expired) or counts up. MySQL applies the SET
|
||||||
|
// list left to right, so `hits` still sees the old window_start.
|
||||||
|
$pdo->prepare(
|
||||||
|
"INSERT INTO auth_throttle (bucket, key_hash, window_start, hits)
|
||||||
|
VALUES (:b, :k, NOW(), 1)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
hits = IF(window_start < NOW() - INTERVAL :w1 SECOND, 1, hits + 1),
|
||||||
|
window_start = IF(window_start < NOW() - INTERVAL :w2 SECOND, NOW(), window_start)"
|
||||||
|
)->execute([':b' => $bucket, ':k' => $key_hash, ':w1' => $window_seconds, ':w2' => $window_seconds]);
|
||||||
|
|
||||||
|
$sth = $pdo->prepare("SELECT hits FROM auth_throttle WHERE bucket = :b AND key_hash = :k");
|
||||||
|
$sth->execute([':b' => $bucket, ':k' => $key_hash]);
|
||||||
|
return (int)$sth->fetchColumn() > $max;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
error_log('[rate_limit] throttle check skipped (' . $bucket . '): ' . $e->getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('rate_limit_guard')) {
|
||||||
|
/**
|
||||||
|
* Count every check and stop the request with HTTP 429 if any is over its
|
||||||
|
* limit. Each check is [bucket, key, max, window_seconds].
|
||||||
|
*/
|
||||||
|
function rate_limit_guard(PDO $pdo, array $checks): void {
|
||||||
|
$limited = false;
|
||||||
|
foreach ($checks as [$bucket, $key, $max, $window]) {
|
||||||
|
if (rate_limit_hit($pdo, $bucket, (string)$key, (int)$max, (int)$window)) {
|
||||||
|
$limited = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($limited) {
|
||||||
|
rate_limit_reject();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('rate_limit_reject')) {
|
||||||
|
/** Answer 429 with the same generic message everywhere and stop. */
|
||||||
|
function rate_limit_reject(): void {
|
||||||
|
http_response_code(429);
|
||||||
|
header('Retry-After: 300');
|
||||||
|
exit(json_encode([
|
||||||
|
'success' => 0,
|
||||||
|
'message' => 'Too many requests. Please wait a few minutes and try again.',
|
||||||
|
'code' => 'rate_limited',
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* secret_box.php — reversible encryption for stored credentials (SMTP passwords).
|
||||||
|
*
|
||||||
|
* Format "v2:<base64(iv . ciphertext)>": AES-256-CBC with a random IV per value and
|
||||||
|
* a key derived from APP_SECRET_KEY (config.php, from the deployment environment).
|
||||||
|
*
|
||||||
|
* Older values were encrypted with the fixed key $pinkey ("wms") and a constant IV,
|
||||||
|
* which anyone reading the source can undo. secret_decrypt() still reads that legacy
|
||||||
|
* format so existing rows keep working; setup.php re-encrypts them to v2 and every
|
||||||
|
* save writes v2. Without APP_SECRET_KEY the legacy format is written (and logged)
|
||||||
|
* so a deployment that has not set the key yet keeps sending mail.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SECRET_BOX_LEGACY_IV = '1234567890123456';
|
||||||
|
|
||||||
|
function secret_box_key(): ?string {
|
||||||
|
if (!defined('APP_SECRET_KEY') || APP_SECRET_KEY === '') return null;
|
||||||
|
return hash('sha256', APP_SECRET_KEY, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function secret_encrypt(string $plain, string $legacy_key = 'wms'): string {
|
||||||
|
$key = secret_box_key();
|
||||||
|
if ($key === null) {
|
||||||
|
error_log('[secret_box] APP_SECRET_KEY is not set; storing a credential in the legacy format.');
|
||||||
|
return openssl_encrypt($plain, 'AES-256-CBC', $legacy_key, 0, SECRET_BOX_LEGACY_IV);
|
||||||
|
}
|
||||||
|
$iv = random_bytes(16);
|
||||||
|
$ct = openssl_encrypt($plain, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv);
|
||||||
|
return 'v2:' . base64_encode($iv . $ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the plain text, or false when the value cannot be decrypted. */
|
||||||
|
function secret_decrypt(string $stored, string $legacy_key = 'wms') {
|
||||||
|
$stored = trim($stored);
|
||||||
|
if (strncmp($stored, 'v2:', 3) === 0) {
|
||||||
|
$key = secret_box_key();
|
||||||
|
$raw = base64_decode(substr($stored, 3), true);
|
||||||
|
if ($key === null || $raw === false || strlen($raw) <= 16) return false;
|
||||||
|
return openssl_decrypt(substr($raw, 16), 'AES-256-CBC', $key, OPENSSL_RAW_DATA, substr($raw, 0, 16));
|
||||||
|
}
|
||||||
|
return openssl_decrypt($stored, 'AES-256-CBC', $legacy_key, 0, SECRET_BOX_LEGACY_IV);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a stored value still uses the legacy fixed-key format. */
|
||||||
|
function secret_is_legacy(string $stored): bool {
|
||||||
|
return $stored !== '' && strncmp(trim($stored), 'v2:', 3) !== 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
// Applies the configured application timezone to PHP, and exposes the matching
|
||||||
|
// UTC offset so the database session can be pinned to the same zone.
|
||||||
|
//
|
||||||
|
// config.php has always defined $time_zone ("Asia/Bangkok"), but nothing ever
|
||||||
|
// called date_default_timezone_set() with it. PHP therefore ran on its ini
|
||||||
|
// default (UTC on this stack) while MySQL NOW() ran on the database server's
|
||||||
|
// zone (Bangkok). Every timestamp written from PHP — stock movement `date`
|
||||||
|
// above all — was stored 7 hours behind the real wall clock, so a stock-in
|
||||||
|
// created at 14:02 was listed as 07:02.
|
||||||
|
//
|
||||||
|
// Loaded from dbconn.php (covers every API engine, which is where writes
|
||||||
|
// happen) and from include_header.php (covers the rendered pages).
|
||||||
|
|
||||||
|
if (!defined('APP_TIMEZONE')) {
|
||||||
|
|
||||||
|
$app_tz = $GLOBALS['time_zone'] ?? 'Asia/Bangkok';
|
||||||
|
|
||||||
|
// An unknown identifier would leave PHP on UTC and silently reintroduce the
|
||||||
|
// skew, so fall back to the documented project zone instead.
|
||||||
|
try {
|
||||||
|
$tz = new DateTimeZone($app_tz);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$app_tz = 'Asia/Bangkok';
|
||||||
|
$tz = new DateTimeZone($app_tz);
|
||||||
|
}
|
||||||
|
|
||||||
|
date_default_timezone_set($app_tz);
|
||||||
|
define('APP_TIMEZONE', $app_tz);
|
||||||
|
|
||||||
|
// "+07:00" — the form MySQL accepts without its named-timezone tables
|
||||||
|
// having been loaded, which is the usual case on a stock install.
|
||||||
|
$offset_seconds = $tz->getOffset(new DateTime('now', $tz));
|
||||||
|
define('APP_TIMEZONE_OFFSET', sprintf(
|
||||||
|
'%s%02d:%02d',
|
||||||
|
$offset_seconds < 0 ? '-' : '+',
|
||||||
|
intdiv(abs($offset_seconds), 3600),
|
||||||
|
intdiv(abs($offset_seconds) % 3600, 60)
|
||||||
|
));
|
||||||
|
}
|
||||||
Vendored
+35
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"resolved": {
|
||||||
|
"apexcharts": "7.5.1",
|
||||||
|
"flatpickr": "4.6.13",
|
||||||
|
"dropzone": "5.9.3",
|
||||||
|
"chart.js": "4.5.1",
|
||||||
|
"world_countries_lists": "3.3.0"
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"jquery/3.7.1/jquery.min.js": "https://code.jquery.com/jquery-3.7.1.min.js",
|
||||||
|
"popper/2.11.8/popper.min.js": "https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.8/dist/umd/popper.min.js",
|
||||||
|
"bootstrap/5.3.8/bootstrap.min.js": "https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.min.js",
|
||||||
|
"bootstrap/5.3.8/bootstrap.min.css": "https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css",
|
||||||
|
"bootbox/4.4.0/bootbox.min.js": "https://cdnjs.cloudflare.com/ajax/libs/bootbox.js/4.4.0/bootbox.min.js",
|
||||||
|
"loadingoverlay/2.1.7/loadingoverlay.min.js": "https://cdn.jsdelivr.net/npm/gasparesganga-jquery-loading-overlay@2.1.7/dist/loadingoverlay.min.js",
|
||||||
|
"flatpickr/4.6.13/flatpickr.min.js": "https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/flatpickr.min.js",
|
||||||
|
"flatpickr/4.6.13/flatpickr.min.css": "https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/flatpickr.min.css",
|
||||||
|
"flatpickr/4.6.13/plugins/monthSelect/index.js": "https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/plugins/monthSelect/index.js",
|
||||||
|
"flatpickr/4.6.13/plugins/monthSelect/style.css": "https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/plugins/monthSelect/style.css",
|
||||||
|
"jquery-ui/1.14.1/jquery-ui.min.js": "https://code.jquery.com/ui/1.14.1/jquery-ui.min.js",
|
||||||
|
"jquery-ui/1.14.1/jquery-ui.css": "https://code.jquery.com/ui/1.14.1/themes/base/jquery-ui.css",
|
||||||
|
"alasql/4.6.6/alasql.min.js": "https://cdnjs.cloudflare.com/ajax/libs/alasql/4.6.6/alasql.min.js",
|
||||||
|
"dropzone/5.9.3/dropzone.min.js": "https://cdn.jsdelivr.net/npm/dropzone@5.9.3/dist/min/dropzone.min.js",
|
||||||
|
"apexcharts/7.5.1/apexcharts.min.js": "https://cdn.jsdelivr.net/npm/apexcharts@7.5.1/dist/apexcharts.min.js",
|
||||||
|
"html5-qrcode/2.3.8/html5-qrcode.min.js": "https://cdnjs.cloudflare.com/ajax/libs/html5-qrcode/2.3.8/html5-qrcode.min.js",
|
||||||
|
"zxcvbn/4.4.2/zxcvbn.js": "https://cdn.jsdelivr.net/npm/zxcvbn@4.4.2/dist/zxcvbn.js",
|
||||||
|
"jsbarcode/3.11.6/JsBarcode.all.min.js": "https://cdn.jsdelivr.net/npm/jsbarcode@3.11.6/dist/JsBarcode.all.min.js",
|
||||||
|
"xlsx/0.18.5/xlsx.full.min.js": "https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js",
|
||||||
|
"jspdf/2.5.1/jspdf.umd.min.js": "https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js",
|
||||||
|
"jspdf-autotable/3.8.2/jspdf.plugin.autotable.min.js": "https://cdnjs.cloudflare.com/ajax/libs/jspdf-autotable/3.8.2/jspdf.plugin.autotable.min.js",
|
||||||
|
"chart.js/4.5.1/chart.umd.min.js": "https://cdn.jsdelivr.net/npm/chart.js@4.5.1/dist/chart.umd.min.js",
|
||||||
|
"tabler-icons/3.35.0/tabler-icons.min.css": "https://cdnjs.cloudflare.com/ajax/libs/tabler-icons/3.35.0/tabler-icons.min.css",
|
||||||
|
"world_countries_lists/3.3.0/countries.json": "https://cdn.jsdelivr.net/npm/world_countries_lists@3.3.0/data/countries/en/countries.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
+264
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,301 @@
|
|||||||
|
(function (global, factory) {
|
||||||
|
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
|
||||||
|
typeof define === 'function' && define.amd ? define(factory) :
|
||||||
|
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.monthSelectPlugin = factory());
|
||||||
|
}(this, (function () { 'use strict';
|
||||||
|
|
||||||
|
/*! *****************************************************************************
|
||||||
|
Copyright (c) Microsoft Corporation.
|
||||||
|
|
||||||
|
Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
purpose with or without fee is hereby granted.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
***************************************************************************** */
|
||||||
|
|
||||||
|
var __assign = function() {
|
||||||
|
__assign = Object.assign || function __assign(t) {
|
||||||
|
for (var s, i = 1, n = arguments.length; i < n; i++) {
|
||||||
|
s = arguments[i];
|
||||||
|
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) t[p] = s[p];
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
};
|
||||||
|
return __assign.apply(this, arguments);
|
||||||
|
};
|
||||||
|
|
||||||
|
var monthToStr = function (monthNumber, shorthand, locale) { return locale.months[shorthand ? "shorthand" : "longhand"][monthNumber]; };
|
||||||
|
|
||||||
|
function clearNode(node) {
|
||||||
|
while (node.firstChild)
|
||||||
|
node.removeChild(node.firstChild);
|
||||||
|
}
|
||||||
|
function getEventTarget(event) {
|
||||||
|
try {
|
||||||
|
if (typeof event.composedPath === "function") {
|
||||||
|
var path = event.composedPath();
|
||||||
|
return path[0];
|
||||||
|
}
|
||||||
|
return event.target;
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
return event.target;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var defaultConfig = {
|
||||||
|
shorthand: false,
|
||||||
|
dateFormat: "F Y",
|
||||||
|
altFormat: "F Y",
|
||||||
|
theme: "light",
|
||||||
|
};
|
||||||
|
function monthSelectPlugin(pluginConfig) {
|
||||||
|
var config = __assign(__assign({}, defaultConfig), pluginConfig);
|
||||||
|
return function (fp) {
|
||||||
|
fp.config.dateFormat = config.dateFormat;
|
||||||
|
fp.config.altFormat = config.altFormat;
|
||||||
|
var self = { monthsContainer: null };
|
||||||
|
function clearUnnecessaryDOMElements() {
|
||||||
|
if (!fp.rContainer)
|
||||||
|
return;
|
||||||
|
clearNode(fp.rContainer);
|
||||||
|
for (var index = 0; index < fp.monthElements.length; index++) {
|
||||||
|
var element = fp.monthElements[index];
|
||||||
|
if (!element.parentNode)
|
||||||
|
continue;
|
||||||
|
element.parentNode.removeChild(element);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function build() {
|
||||||
|
if (!fp.rContainer)
|
||||||
|
return;
|
||||||
|
self.monthsContainer = fp._createElement("div", "flatpickr-monthSelect-months");
|
||||||
|
self.monthsContainer.tabIndex = -1;
|
||||||
|
buildMonths();
|
||||||
|
fp.rContainer.appendChild(self.monthsContainer);
|
||||||
|
fp.calendarContainer.classList.add("flatpickr-monthSelect-theme-" + config.theme);
|
||||||
|
}
|
||||||
|
function buildMonths() {
|
||||||
|
if (!self.monthsContainer)
|
||||||
|
return;
|
||||||
|
clearNode(self.monthsContainer);
|
||||||
|
var frag = document.createDocumentFragment();
|
||||||
|
for (var i = 0; i < 12; i++) {
|
||||||
|
var month = fp.createDay("flatpickr-monthSelect-month", new Date(fp.currentYear, i), 0, i);
|
||||||
|
if (month.dateObj.getMonth() === new Date().getMonth() &&
|
||||||
|
month.dateObj.getFullYear() === new Date().getFullYear())
|
||||||
|
month.classList.add("today");
|
||||||
|
month.textContent = monthToStr(i, config.shorthand, fp.l10n);
|
||||||
|
month.addEventListener("click", selectMonth);
|
||||||
|
frag.appendChild(month);
|
||||||
|
}
|
||||||
|
self.monthsContainer.appendChild(frag);
|
||||||
|
if (fp.config.minDate &&
|
||||||
|
fp.currentYear === fp.config.minDate.getFullYear())
|
||||||
|
fp.prevMonthNav.classList.add("flatpickr-disabled");
|
||||||
|
else
|
||||||
|
fp.prevMonthNav.classList.remove("flatpickr-disabled");
|
||||||
|
if (fp.config.maxDate &&
|
||||||
|
fp.currentYear === fp.config.maxDate.getFullYear())
|
||||||
|
fp.nextMonthNav.classList.add("flatpickr-disabled");
|
||||||
|
else
|
||||||
|
fp.nextMonthNav.classList.remove("flatpickr-disabled");
|
||||||
|
}
|
||||||
|
function bindEvents() {
|
||||||
|
fp._bind(fp.prevMonthNav, "click", function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
fp.changeYear(fp.currentYear - 1);
|
||||||
|
selectYear();
|
||||||
|
buildMonths();
|
||||||
|
});
|
||||||
|
fp._bind(fp.nextMonthNav, "click", function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
fp.changeYear(fp.currentYear + 1);
|
||||||
|
selectYear();
|
||||||
|
buildMonths();
|
||||||
|
});
|
||||||
|
fp._bind(self.monthsContainer, "mouseover", function (e) {
|
||||||
|
if (fp.config.mode === "range")
|
||||||
|
fp.onMouseOver(getEventTarget(e), "flatpickr-monthSelect-month");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function setCurrentlySelected() {
|
||||||
|
if (!fp.rContainer)
|
||||||
|
return;
|
||||||
|
if (!fp.selectedDates.length)
|
||||||
|
return;
|
||||||
|
var currentlySelected = fp.rContainer.querySelectorAll(".flatpickr-monthSelect-month.selected");
|
||||||
|
for (var index = 0; index < currentlySelected.length; index++) {
|
||||||
|
currentlySelected[index].classList.remove("selected");
|
||||||
|
}
|
||||||
|
var targetMonth = fp.selectedDates[0].getMonth();
|
||||||
|
var month = fp.rContainer.querySelector(".flatpickr-monthSelect-month:nth-child(" + (targetMonth + 1) + ")");
|
||||||
|
if (month) {
|
||||||
|
month.classList.add("selected");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function selectYear() {
|
||||||
|
var selectedDate = fp.selectedDates[0];
|
||||||
|
if (selectedDate) {
|
||||||
|
selectedDate = new Date(selectedDate);
|
||||||
|
selectedDate.setFullYear(fp.currentYear);
|
||||||
|
if (fp.config.minDate && selectedDate < fp.config.minDate) {
|
||||||
|
selectedDate = fp.config.minDate;
|
||||||
|
}
|
||||||
|
if (fp.config.maxDate && selectedDate > fp.config.maxDate) {
|
||||||
|
selectedDate = fp.config.maxDate;
|
||||||
|
}
|
||||||
|
fp.currentYear = selectedDate.getFullYear();
|
||||||
|
}
|
||||||
|
fp.currentYearElement.value = String(fp.currentYear);
|
||||||
|
if (fp.rContainer) {
|
||||||
|
var months = fp.rContainer.querySelectorAll(".flatpickr-monthSelect-month");
|
||||||
|
months.forEach(function (month) {
|
||||||
|
month.dateObj.setFullYear(fp.currentYear);
|
||||||
|
if ((fp.config.minDate && month.dateObj < fp.config.minDate) ||
|
||||||
|
(fp.config.maxDate && month.dateObj > fp.config.maxDate)) {
|
||||||
|
month.classList.add("flatpickr-disabled");
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
month.classList.remove("flatpickr-disabled");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setCurrentlySelected();
|
||||||
|
}
|
||||||
|
function selectMonth(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
var eventTarget = getEventTarget(e);
|
||||||
|
if (!(eventTarget instanceof Element))
|
||||||
|
return;
|
||||||
|
if (eventTarget.classList.contains("flatpickr-disabled"))
|
||||||
|
return;
|
||||||
|
if (eventTarget.classList.contains("notAllowed"))
|
||||||
|
return; // necessary??
|
||||||
|
setMonth(eventTarget.dateObj);
|
||||||
|
if (fp.config.closeOnSelect) {
|
||||||
|
var single = fp.config.mode === "single";
|
||||||
|
var range = fp.config.mode === "range" && fp.selectedDates.length === 2;
|
||||||
|
if (single || range)
|
||||||
|
fp.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function setMonth(date) {
|
||||||
|
var selectedDate = new Date(fp.currentYear, date.getMonth(), date.getDate());
|
||||||
|
var selectedDates = [];
|
||||||
|
switch (fp.config.mode) {
|
||||||
|
case "single":
|
||||||
|
selectedDates = [selectedDate];
|
||||||
|
break;
|
||||||
|
case "multiple":
|
||||||
|
selectedDates.push(selectedDate);
|
||||||
|
break;
|
||||||
|
case "range":
|
||||||
|
if (fp.selectedDates.length === 2) {
|
||||||
|
selectedDates = [selectedDate];
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
selectedDates = fp.selectedDates.concat([selectedDate]);
|
||||||
|
selectedDates.sort(function (a, b) { return a.getTime() - b.getTime(); });
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
fp.setDate(selectedDates, true);
|
||||||
|
setCurrentlySelected();
|
||||||
|
}
|
||||||
|
var shifts = {
|
||||||
|
37: -1,
|
||||||
|
39: 1,
|
||||||
|
40: 3,
|
||||||
|
38: -3,
|
||||||
|
};
|
||||||
|
function onKeyDown(_, __, ___, e) {
|
||||||
|
var shouldMove = shifts[e.keyCode] !== undefined;
|
||||||
|
if (!shouldMove && e.keyCode !== 13) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!fp.rContainer || !self.monthsContainer)
|
||||||
|
return;
|
||||||
|
var currentlySelected = fp.rContainer.querySelector(".flatpickr-monthSelect-month.selected");
|
||||||
|
var index = Array.prototype.indexOf.call(self.monthsContainer.children, document.activeElement);
|
||||||
|
if (index === -1) {
|
||||||
|
var target = currentlySelected || self.monthsContainer.firstElementChild;
|
||||||
|
target.focus();
|
||||||
|
index = target.$i;
|
||||||
|
}
|
||||||
|
if (shouldMove) {
|
||||||
|
self.monthsContainer.children[(12 + index + shifts[e.keyCode]) % 12].focus();
|
||||||
|
}
|
||||||
|
else if (e.keyCode === 13 &&
|
||||||
|
self.monthsContainer.contains(document.activeElement)) {
|
||||||
|
setMonth(document.activeElement.dateObj);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function closeHook() {
|
||||||
|
var _a;
|
||||||
|
if (((_a = fp.config) === null || _a === void 0 ? void 0 : _a.mode) === "range" && fp.selectedDates.length === 1)
|
||||||
|
fp.clear(false);
|
||||||
|
if (!fp.selectedDates.length)
|
||||||
|
buildMonths();
|
||||||
|
}
|
||||||
|
// Help the prev/next year nav honor config.minDate (see 3fa5a69)
|
||||||
|
function stubCurrentMonth() {
|
||||||
|
config._stubbedCurrentMonth = fp._initialDate.getMonth();
|
||||||
|
fp._initialDate.setMonth(config._stubbedCurrentMonth);
|
||||||
|
fp.currentMonth = config._stubbedCurrentMonth;
|
||||||
|
}
|
||||||
|
function unstubCurrentMonth() {
|
||||||
|
if (!config._stubbedCurrentMonth)
|
||||||
|
return;
|
||||||
|
fp._initialDate.setMonth(config._stubbedCurrentMonth);
|
||||||
|
fp.currentMonth = config._stubbedCurrentMonth;
|
||||||
|
delete config._stubbedCurrentMonth;
|
||||||
|
}
|
||||||
|
function destroyPluginInstance() {
|
||||||
|
if (self.monthsContainer !== null) {
|
||||||
|
var months = self.monthsContainer.querySelectorAll(".flatpickr-monthSelect-month");
|
||||||
|
for (var index = 0; index < months.length; index++) {
|
||||||
|
months[index].removeEventListener("click", selectMonth);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
onParseConfig: function () {
|
||||||
|
fp.config.enableTime = false;
|
||||||
|
},
|
||||||
|
onValueUpdate: setCurrentlySelected,
|
||||||
|
onKeyDown: onKeyDown,
|
||||||
|
onReady: [
|
||||||
|
stubCurrentMonth,
|
||||||
|
clearUnnecessaryDOMElements,
|
||||||
|
build,
|
||||||
|
bindEvents,
|
||||||
|
setCurrentlySelected,
|
||||||
|
function () {
|
||||||
|
fp.config.onClose.push(closeHook);
|
||||||
|
fp.loadedPlugins.push("monthSelect");
|
||||||
|
},
|
||||||
|
],
|
||||||
|
onDestroy: [
|
||||||
|
unstubCurrentMonth,
|
||||||
|
destroyPluginInstance,
|
||||||
|
function () {
|
||||||
|
fp.config.onClose = fp.config.onClose.filter(function (hook) { return hook !== closeHook; });
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return monthSelectPlugin;
|
||||||
|
|
||||||
|
})));
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
.flatpickr-monthSelect-months {
|
||||||
|
margin: 10px 1px 3px 1px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flatpickr-monthSelect-month {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
-webkit-box-sizing: border-box;
|
||||||
|
box-sizing: border-box;
|
||||||
|
color: #393939;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-block;
|
||||||
|
font-weight: 400;
|
||||||
|
margin: 0.5px;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 10px;
|
||||||
|
position: relative;
|
||||||
|
-webkit-box-pack: center;
|
||||||
|
-webkit-justify-content: center;
|
||||||
|
-ms-flex-pack: center;
|
||||||
|
text-align: center;
|
||||||
|
width: 33%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flatpickr-monthSelect-month.flatpickr-disabled {
|
||||||
|
color: #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flatpickr-monthSelect-month.flatpickr-disabled:hover,
|
||||||
|
.flatpickr-monthSelect-month.flatpickr-disabled:focus {
|
||||||
|
cursor: not-allowed;
|
||||||
|
background: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flatpickr-monthSelect-theme-dark {
|
||||||
|
background: #3f4458;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flatpickr-monthSelect-theme-dark .flatpickr-current-month input.cur-year {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flatpickr-monthSelect-theme-dark .flatpickr-months .flatpickr-prev-month,
|
||||||
|
.flatpickr-monthSelect-theme-dark .flatpickr-months .flatpickr-next-month {
|
||||||
|
color: #fff;
|
||||||
|
fill: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flatpickr-monthSelect-theme-dark .flatpickr-monthSelect-month {
|
||||||
|
color: rgba(255, 255, 255, 0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flatpickr-monthSelect-month.today {
|
||||||
|
border-color: #959ea9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flatpickr-monthSelect-month.inRange,
|
||||||
|
.flatpickr-monthSelect-month.inRange.today,
|
||||||
|
.flatpickr-monthSelect-month:hover,
|
||||||
|
.flatpickr-monthSelect-month:focus {
|
||||||
|
background: #e6e6e6;
|
||||||
|
cursor: pointer;
|
||||||
|
outline: 0;
|
||||||
|
border-color: #e6e6e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flatpickr-monthSelect-theme-dark .flatpickr-monthSelect-month.inRange,
|
||||||
|
.flatpickr-monthSelect-theme-dark .flatpickr-monthSelect-month:hover,
|
||||||
|
.flatpickr-monthSelect-theme-dark .flatpickr-monthSelect-month:focus {
|
||||||
|
background: #646c8c;
|
||||||
|
border-color: #646c8c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flatpickr-monthSelect-month.today:hover,
|
||||||
|
.flatpickr-monthSelect-month.today:focus {
|
||||||
|
background: #959ea9;
|
||||||
|
border-color: #959ea9;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flatpickr-monthSelect-month.selected,
|
||||||
|
.flatpickr-monthSelect-month.startRange,
|
||||||
|
.flatpickr-monthSelect-month.endRange {
|
||||||
|
background-color: #569ff7;
|
||||||
|
box-shadow: none;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #569ff7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flatpickr-monthSelect-month.startRange {
|
||||||
|
border-radius: 50px 0 0 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flatpickr-monthSelect-month.endRange {
|
||||||
|
border-radius: 0 50px 50px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flatpickr-monthSelect-month.startRange.endRange {
|
||||||
|
border-radius: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flatpickr-monthSelect-month.inRange {
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: -5px 0 0 #e6e6e6, 5px 0 0 #e6e6e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flatpickr-monthSelect-theme-dark .flatpickr-monthSelect-month.selected,
|
||||||
|
.flatpickr-monthSelect-theme-dark .flatpickr-monthSelect-month.startRange,
|
||||||
|
.flatpickr-monthSelect-theme-dark .flatpickr-monthSelect-month.endRange {
|
||||||
|
background: #80cbc4;
|
||||||
|
-webkit-box-shadow: none;
|
||||||
|
box-shadow: none;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #80cbc4;
|
||||||
|
}
|
||||||
+324
@@ -0,0 +1,324 @@
|
|||||||
|
/* latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Poppins';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 100;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(pxiAyp8kv8JHgFVrJJLmE0tMMPKzSQ.woff2) format('woff2');
|
||||||
|
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
/* latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Poppins';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 100;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(pxiAyp8kv8JHgFVrJJLmE0tCMPI.woff2) format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
/* latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Poppins';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 200;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(pxiDyp8kv8JHgFVrJJLmv1pVGdeOcEg.woff2) format('woff2');
|
||||||
|
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
/* latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Poppins';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 200;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(pxiDyp8kv8JHgFVrJJLmv1pVF9eO.woff2) format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
/* latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Poppins';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 300;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(pxiDyp8kv8JHgFVrJJLm21lVGdeOcEg.woff2) format('woff2');
|
||||||
|
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
/* latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Poppins';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 300;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(pxiDyp8kv8JHgFVrJJLm21lVF9eO.woff2) format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
/* latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Poppins';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(pxiGyp8kv8JHgFVrJJLufntAKPY.woff2) format('woff2');
|
||||||
|
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
/* latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Poppins';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(pxiGyp8kv8JHgFVrJJLucHtA.woff2) format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
/* latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Poppins';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 500;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(pxiDyp8kv8JHgFVrJJLmg1hVGdeOcEg.woff2) format('woff2');
|
||||||
|
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
/* latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Poppins';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 500;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(pxiDyp8kv8JHgFVrJJLmg1hVF9eO.woff2) format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
/* latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Poppins';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(pxiDyp8kv8JHgFVrJJLmr19VGdeOcEg.woff2) format('woff2');
|
||||||
|
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
/* latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Poppins';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(pxiDyp8kv8JHgFVrJJLmr19VF9eO.woff2) format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
/* latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Poppins';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(pxiDyp8kv8JHgFVrJJLmy15VGdeOcEg.woff2) format('woff2');
|
||||||
|
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
/* latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Poppins';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(pxiDyp8kv8JHgFVrJJLmy15VF9eO.woff2) format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
/* latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Poppins';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 800;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(pxiDyp8kv8JHgFVrJJLm111VGdeOcEg.woff2) format('woff2');
|
||||||
|
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
/* latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Poppins';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 800;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(pxiDyp8kv8JHgFVrJJLm111VF9eO.woff2) format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
/* latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Poppins';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 900;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(pxiDyp8kv8JHgFVrJJLm81xVGdeOcEg.woff2) format('woff2');
|
||||||
|
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
/* latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Poppins';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 900;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(pxiDyp8kv8JHgFVrJJLm81xVF9eO.woff2) format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
/* latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Poppins';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 100;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(pxiGyp8kv8JHgFVrLPTufntAKPY.woff2) format('woff2');
|
||||||
|
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
/* latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Poppins';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 100;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(pxiGyp8kv8JHgFVrLPTucHtA.woff2) format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
/* latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Poppins';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 200;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(pxiByp8kv8JHgFVrLFj_Z1JlFc-K.woff2) format('woff2');
|
||||||
|
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
/* latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Poppins';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 200;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(pxiByp8kv8JHgFVrLFj_Z1xlFQ.woff2) format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
/* latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Poppins';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 300;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(pxiByp8kv8JHgFVrLDz8Z1JlFc-K.woff2) format('woff2');
|
||||||
|
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
/* latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Poppins';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 300;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(pxiByp8kv8JHgFVrLDz8Z1xlFQ.woff2) format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
/* latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Poppins';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(pxiEyp8kv8JHgFVrJJnecmNE.woff2) format('woff2');
|
||||||
|
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
/* latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Poppins';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(pxiEyp8kv8JHgFVrJJfecg.woff2) format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
/* latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Poppins';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(pxiByp8kv8JHgFVrLGT9Z1JlFc-K.woff2) format('woff2');
|
||||||
|
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
/* latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Poppins';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(pxiByp8kv8JHgFVrLGT9Z1xlFQ.woff2) format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
/* latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Poppins';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(pxiByp8kv8JHgFVrLEj6Z1JlFc-K.woff2) format('woff2');
|
||||||
|
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
/* latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Poppins';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(pxiByp8kv8JHgFVrLEj6Z1xlFQ.woff2) format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
/* latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Poppins';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(pxiByp8kv8JHgFVrLCz7Z1JlFc-K.woff2) format('woff2');
|
||||||
|
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
/* latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Poppins';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(pxiByp8kv8JHgFVrLCz7Z1xlFQ.woff2) format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
/* latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Poppins';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 800;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(pxiByp8kv8JHgFVrLDD4Z1JlFc-K.woff2) format('woff2');
|
||||||
|
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
/* latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Poppins';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 800;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(pxiByp8kv8JHgFVrLDD4Z1xlFQ.woff2) format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
/* latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Poppins';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 900;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(pxiByp8kv8JHgFVrLBT5Z1JlFc-K.woff2) format('woff2');
|
||||||
|
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
/* latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Poppins';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 900;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(pxiByp8kv8JHgFVrLBT5Z1xlFQ.woff2) format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user