diff --git a/app.zip b/app.zip
new file mode 100644
index 0000000..5cf4c6e
Binary files /dev/null and b/app.zip differ
diff --git a/app/assets/utils/classes/FileUploader.php b/app/assets/utils/classes/FileUploader.php
new file mode 100644
index 0000000..419f189
--- /dev/null
+++ b/app/assets/utils/classes/FileUploader.php
@@ -0,0 +1,162 @@
+target_dir = rtrim($target_dir, '/') . '/';
+ $this->ensureDirectory();
+ }
+
+ private function ensureDirectory() {
+ if (!is_dir($this->target_dir)) {
+ if (!mkdir($this->target_dir, 0755, true)) {
+ throw new Exception("Failed to create directory: " . $this->target_dir);
+ }
+ }
+ if (!is_writable($this->target_dir)) {
+ throw new Exception("Target directory is not writable: " . $this->target_dir);
+ }
+ }
+
+ /**
+ * Remove files that user deleted in the UI
+ */
+ public function cleanup($existing_csv, $keep_csv) {
+ if (empty($existing_csv)) return;
+
+ $existing = array_filter(array_map('trim', explode(',', $existing_csv)));
+ $keep = !empty($keep_csv) ? array_filter(array_map('trim', explode(',', $keep_csv))) : [];
+
+ foreach ($existing as $file) {
+ if (!in_array($file, $keep)) {
+ $path = $this->target_dir . $file;
+ if (file_exists($path)) {
+ unlink($path);
+ $this->deleted_files[] = $file;
+ }
+ }
+ }
+ }
+
+ /**
+ * Upload new files from $_FILES
+ * Returns array of errors (empty = success)
+ */
+ public function upload($field_name) {
+ if (!isset($_FILES[$field_name])) return [];
+
+ $errors = [];
+
+ // Normalize single file to array structure
+ $files = $_FILES[$field_name];
+ if (!is_array($files['name'])) {
+ $files['name'] = [$files['name']];
+ $files['tmp_name'] = [$files['tmp_name']];
+ $files['error'] = [$files['error']];
+ $files['size'] = [$files['size']];
+ }
+
+ foreach ($files['name'] as $key => $name) {
+ $error_code = $files['error'][$key];
+
+ if ($error_code !== UPLOAD_ERR_OK) {
+ $errors[] = "{$name}: " . $this->getUploadError($error_code);
+ continue;
+ }
+
+ $tmp_name = $files['tmp_name'][$key];
+ $file_size = $files['size'][$key];
+ $extension = strtolower(pathinfo($name, PATHINFO_EXTENSION));
+
+ if ($file_size > $this->max_size) {
+ $errors[] = "{$name}: exceeds " . ($this->max_size / 1024 / 1024) . "MB limit";
+ continue;
+ }
+
+ if (!in_array($extension, $this->allowed_extensions)) {
+ $errors[] = "{$name}: .{$extension} is not allowed";
+ continue;
+ }
+
+ $finfo = finfo_open(FILEINFO_MIME_TYPE);
+ $mime = finfo_file($finfo, $tmp_name);
+ finfo_close($finfo);
+
+ if (!in_array($mime, $this->allowed_mimes)) {
+ $errors[] = "{$name}: content type ({$mime}) is not allowed";
+ continue;
+ }
+
+ // FILE NAME SANITIZATION + UNIQUE ID
+ $original = pathinfo($name, PATHINFO_FILENAME);
+ $original = preg_replace('/[^a-zA-Z0-9_-]/', '_', $original); // sanitize
+ $file_id = $original . "_" . uniqid() . "." . $extension;
+
+ $destination = $this->target_dir . $file_id;
+
+ if (move_uploaded_file($tmp_name, $destination)) {
+ $this->uploaded_files[] = $file_id;
+ chmod($destination, 0644);
+ } else {
+ $errors[] = "{$name}: failed to save";
+ }
+ }
+
+ return $errors;
+ }
+
+ /**
+ * Build final CSV string for DB
+ */
+ public function buildFileString($keep_csv) {
+ $keep = !empty($keep_csv) ? array_filter(array_map('trim', explode(',', $keep_csv))) : [];
+ $final = array_merge($keep, $this->uploaded_files);
+ return implode(",", array_filter($final));
+ }
+
+ /**
+ * Build final CSV string for DB as single file
+ */
+ public function getUploadedFiles() {
+ return $this->uploaded_files;
+ }
+
+ /**
+ * Rollback uploaded files if DB fails
+ */
+ public function rollbackUploads() {
+ foreach ($this->uploaded_files as $file) {
+ $path = $this->target_dir . $file;
+ if (file_exists($path)) {
+ unlink($path);
+ }
+ }
+ $this->uploaded_files = [];
+ }
+
+ private function getUploadError($code) {
+ $messages = [
+ UPLOAD_ERR_INI_SIZE => "exceeds server max upload size (" . ini_get('upload_max_filesize') . ")",
+ UPLOAD_ERR_FORM_SIZE => "exceeds form max size",
+ UPLOAD_ERR_PARTIAL => "was only partially uploaded",
+ UPLOAD_ERR_NO_FILE => "no file was sent",
+ UPLOAD_ERR_NO_TMP_DIR => "server missing temp folder",
+ UPLOAD_ERR_CANT_WRITE => "server failed to write to disk",
+ UPLOAD_ERR_EXTENSION => "blocked by server extension",
+ ];
+ return $messages[$code] ?? "unknown error (code {$code})";
+ }
+}
+
+
+
+
+
+?>
\ No newline at end of file
diff --git a/app/assets/utils/classes/Reports.php b/app/assets/utils/classes/Reports.php
new file mode 100644
index 0000000..65dc777
--- /dev/null
+++ b/app/assets/utils/classes/Reports.php
@@ -0,0 +1,121 @@
+pdo = $pdo;
+ $this->companyId = $companyId;
+ }
+
+ public function getTotalCategory(): int
+ {
+ $sql = "SELECT COUNT(*)
+ FROM md_product_category
+ WHERE company_id = :company_id";
+ return (int) $this->fetchScalar($sql);
+ }
+
+ public function getActiveCategory(): int
+ {
+ $sql = "SELECT COUNT(*)
+ FROM md_product_category
+ WHERE company_id = :company_id
+ AND `status` = 1";
+ return (int) $this->fetchScalar($sql);
+ }
+
+ public function getInactiveCategory(): int
+ {
+ $sql = "SELECT COUNT(*)
+ FROM md_product_category
+ WHERE company_id = :company_id
+ AND `status` = 0";
+ return (int) $this->fetchScalar($sql);
+ }
+
+ public function getTotalWarehouse(): int
+ {
+ $sql = "SELECT COUNT(*)
+ FROM md_warehouse
+ WHERE company_id = :company_id";
+ return (int) $this->fetchScalar($sql);
+ }
+
+ public function getTotalLocation(): int
+ {
+ $sql = "SELECT COUNT(DISTINCT `location`)
+ FROM md_warehouse
+ WHERE company_id = :company_id";
+ return (int) $this->fetchScalar($sql);
+ }
+
+ public function getTotalCapacity(): float
+ {
+ $sql = "SELECT SUM(capacity)
+ FROM md_warehouse
+ WHERE company_id = :company_id";
+ return (float) $this->fetchScalar($sql);
+ }
+
+ public function getTotalProduct(): int
+ {
+ $sql = "SELECT COUNT(*)
+ FROM md_product
+ WHERE company_id = :company_id";
+ return (int) $this->fetchScalar($sql);
+ }
+
+ public function getLowStockCount(): int
+ {
+ $products = $this->getStockBalance();
+ $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 getStockBalance(): array
+ {
+ $sql = "SELECT
+ a.product_sku,
+ SUM(a.total_in) AS total_in,
+ SUM(a.total_out) AS total_out,
+ b.min_stock
+ FROM warehouse_balance a
+ LEFT JOIN md_product b
+ ON a.company_id = b.company_id
+ AND a.product_sku = b.sku
+ WHERE a.company_id = :company_id
+ GROUP BY a.product_sku, b.min_stock";
+ return $this->fetchAll($sql);
+ }
+
+ // FETCH HELPERS
+ // These helper methods abstract away the common pattern of preparing, executing, and fetching results from the database.
+ private function fetchScalar(string $sql)
+ {
+ $sth = $this->pdo->prepare($sql);
+ $sth->execute([":company_id" => $this->companyId]);
+ return $sth->fetchColumn();
+ }
+
+ private function fetchAll(string $sql): array
+ {
+ $sth = $this->pdo->prepare($sql);
+ $sth->execute([":company_id" => $this->companyId]);
+ return $sth->fetchAll(PDO::FETCH_ASSOC);
+ }
+}
+
+
+
+
+?>
\ No newline at end of file
diff --git a/app/assets/utils/classes/WarehouseManager.php b/app/assets/utils/classes/WarehouseManager.php
new file mode 100644
index 0000000..97604f5
--- /dev/null
+++ b/app/assets/utils/classes/WarehouseManager.php
@@ -0,0 +1,105 @@
+pdo = $pdo;
+ $this->company_id = $company_id;
+ }
+
+ public function getWarehouseName($warehouse_id) {
+ $sql = "SELECT warehouse_name FROM md_warehouse
+ WHERE company_id = :company_id AND id = :warehouse_id";
+
+ $sth = $this->pdo->prepare($sql);
+ $sth->execute([
+ ":company_id" => $this->company_id,
+ ":warehouse_id" => $warehouse_id
+ ]);
+ return $sth->fetchColumn();
+ }
+
+ public function getStockContext($warehouse_id, $id) {
+ $name = $this->getWarehouseName($warehouse_id);
+ $table = "td_stock_" . $name;
+
+ if (empty($id)) {
+ return [
+ 'table' => $table,
+ 'name' => $name,
+ 'row' => []
+ ];
+ }
+
+ $sql = "SELECT * FROM `$table`
+ WHERE company_id = :company_id AND id = :id";
+
+ $sth = $this->pdo->prepare($sql);
+ $sth->execute([
+ ":company_id" => $this->company_id,
+ ":id" => $id
+ ]);
+
+ return [
+ 'table' => $table,
+ 'name' => $name,
+ 'row' => $sth->fetch(PDO::FETCH_ASSOC) ?: []
+ ];
+ }
+
+ public function adjustBalance($type, $warehouse_id, $product_sku, $old_qty, $new_qty) {
+
+ // Lock the row — other transactions wait here
+ $sql = "SELECT id FROM warehouse_balance
+ WHERE company_id = :company_id
+ AND warehouse_id = :warehouse_id
+ AND product_sku = :product_sku
+ FOR UPDATE";
+
+ $sth = $this->pdo->prepare($sql);
+ $sth->execute([
+ ":company_id" => $this->company_id,
+ ":warehouse_id" => $warehouse_id,
+ ":product_sku" => $product_sku
+ ]);
+
+ if (!$sth->fetchColumn()) {
+ $sql = "INSERT INTO warehouse_balance
+ (company_id, warehouse_id, product_sku)
+ VALUES (:company_id, :warehouse_id, :product_sku)";
+
+ $sth = $this->pdo->prepare($sql);
+ $sth->execute([
+ ":company_id" => $this->company_id,
+ ":warehouse_id" => $warehouse_id,
+ ":product_sku" => $product_sku
+ ]);
+ }
+
+ $column = $type === 'in' ? 'total_in' : 'total_out';
+
+ // This is already atomic since it uses relative update
+ $sql = "UPDATE warehouse_balance
+ SET `$column` = `$column` - :old_qty + :new_qty
+ WHERE company_id = :company_id
+ AND warehouse_id = :warehouse_id
+ AND product_sku = :product_sku";
+
+ $sth = $this->pdo->prepare($sql);
+ $sth->execute([
+ ":company_id" => $this->company_id,
+ ":warehouse_id" => $warehouse_id,
+ ":product_sku" => $product_sku,
+ ":old_qty" => $old_qty,
+ ":new_qty" => $new_qty
+ ]);
+ }
+}
+
+
+
+
+?>
\ No newline at end of file
diff --git a/app/assets/utils/db_auth.php b/app/assets/utils/db_auth.php
index febe96b..f343f9b 100644
--- a/app/assets/utils/db_auth.php
+++ b/app/assets/utils/db_auth.php
@@ -2,78 +2,7 @@
require_once __DIR__."/../../config.php";
require_once __DIR__."/../../dbconn.php";
-
-
-/**
- * Check PDO statement for errors
- * Logs full error server-side, returns generic message to client
- *
- * @param PDOStatement $sth - The executed PDO statement
- * @param array &$answer - Response array passed by reference
- * @param array $options - Optional settings:
- * 'message' => Custom client-facing error message
- * 'log' => Custom server log label
- * 'code' => HTTP response code (default: 500)
- * 'before_exit' => Callback function before exit
- *
- * @example
- * // Simple — use all defaults
- * db_check($sth, $answer);
- *
- * @example
- * // Custom client message
- * db_check($sth, $answer, [
- * 'message' => "Contact not found."
- * ]);
- *
- * @example
- * // Custom log label + message
- * db_check($sth, $answer, [
- * 'log' => "Failed to fetch contact",
- * 'message' => "Could not load contact. Please try again."
- * ]);
- *
- * @example
- * // Custom HTTP status code
- * db_check($sth, $answer, [
- * 'code' => 403,
- * 'message' => "Access denied."
- * ]);
- *
- * @example
- * // Cleanup callback before exit
- * db_check($sth, $answer, [
- * 'message' => "Upload failed.",
- * 'before_exit' => function() use ($tmp_file) {
- * if(file_exists($tmp_file)) unlink($tmp_file);
- * }
- * ]);
- */
-function db_check($sth, &$answer, $options = []) {
- if ($sth->errorInfo()[0] != "00000" && !empty($sth->errorInfo()[0])) {
-
- $trace = debug_backtrace()[0];
-
- $log_message = $options['log'] ?? "DB Error";
- error_log($log_message . " in " . $trace['file'] . " line " . $trace['line'] . ": " . $sth->errorInfo()[2]);
-
- // production version
- $answer["message"] = $options['message'] ?? "A server error occurred. Please try again.";
-
- // development version
- $answer["message"] = $sth->errorInfo()[2];
-
- http_response_code($options['code'] ?? 500);
-
- if(isset($options['before_exit'])) {
- call_user_func($options['before_exit']);
- }
-
- exit(json_encode($answer));
- }
-}
-
-
+require_once __DIR__."/db_helpers.php";
if(!empty($_SESSION["login_company_id"])){
@@ -164,300 +93,4 @@ if(!empty($_SESSION["login_company_id"])){
}
-/**
- * HELPER CLASSES AND FUNCTIONS
- * These are designed to be reusable across different API endpoints for consistent data handling and error management.
- * They are not specific to any single operation and can be used wherever similar patterns arise.
- */
-
-/**
- * Helper class to manage data consistency
- */
-class WarehouseManager {
-
- private $pdo;
- private $company_id;
-
- public function __construct($pdo, $company_id) {
- $this->pdo = $pdo;
- $this->company_id = $company_id;
- }
-
- public function getWarehouseName($warehouse_id) {
- $sql = "SELECT warehouse_name FROM md_warehouse
- WHERE company_id = :company_id AND id = :warehouse_id";
-
- $sth = $this->pdo->prepare($sql);
- $sth->execute([
- ":company_id" => $this->company_id,
- ":warehouse_id" => $warehouse_id
- ]);
- return $sth->fetchColumn();
- }
-
- public function getStockContext($warehouse_id, $id) {
- $name = $this->getWarehouseName($warehouse_id);
- $table = "td_stock_" . $name;
-
- if (empty($id)) {
- return [
- 'table' => $table,
- 'name' => $name,
- 'row' => []
- ];
- }
-
- $sql = "SELECT * FROM `$table`
- WHERE company_id = :company_id AND id = :id";
-
- $sth = $this->pdo->prepare($sql);
- $sth->execute([
- ":company_id" => $this->company_id,
- ":id" => $id
- ]);
-
- return [
- 'table' => $table,
- 'name' => $name,
- 'row' => $sth->fetch(PDO::FETCH_ASSOC) ?: []
- ];
- }
-
- public function adjustBalance($type, $warehouse_id, $product_sku, $old_qty, $new_qty) {
-
- // Lock the row — other transactions wait here
- $sql = "SELECT id FROM warehouse_balance
- WHERE company_id = :company_id
- AND warehouse_id = :warehouse_id
- AND product_sku = :product_sku
- FOR UPDATE";
-
- $sth = $this->pdo->prepare($sql);
- $sth->execute([
- ":company_id" => $this->company_id,
- ":warehouse_id" => $warehouse_id,
- ":product_sku" => $product_sku
- ]);
-
- if (!$sth->fetchColumn()) {
- $sql = "INSERT INTO warehouse_balance
- (company_id, warehouse_id, product_sku)
- VALUES (:company_id, :warehouse_id, :product_sku)";
-
- $sth = $this->pdo->prepare($sql);
- $sth->execute([
- ":company_id" => $this->company_id,
- ":warehouse_id" => $warehouse_id,
- ":product_sku" => $product_sku
- ]);
- }
-
- $column = $type === 'in' ? 'total_in' : 'total_out';
-
- // This is already atomic since it uses relative update
- $sql = "UPDATE warehouse_balance
- SET `$column` = `$column` - :old_qty + :new_qty
- WHERE company_id = :company_id
- AND warehouse_id = :warehouse_id
- AND product_sku = :product_sku";
-
- $sth = $this->pdo->prepare($sql);
- $sth->execute([
- ":company_id" => $this->company_id,
- ":warehouse_id" => $warehouse_id,
- ":product_sku" => $product_sku,
- ":old_qty" => $old_qty,
- ":new_qty" => $new_qty
- ]);
- }
-}
-
-
-class FileUploader {
-
- private $target_dir;
- private $allowed_mimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf'];
- private $allowed_extensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'pdf'];
- private $max_size = 5 * 1024 * 1024; // 5MB
- private $uploaded_files = []; // newly uploaded filenames
- private $deleted_files = []; // files we deleted from disk
-
- public function __construct($target_dir) {
- $this->target_dir = rtrim($target_dir, '/') . '/';
- $this->ensureDirectory();
- }
-
- private function ensureDirectory() {
- if (!is_dir($this->target_dir)) {
- if (!mkdir($this->target_dir, 0755, true)) {
- throw new Exception("Failed to create directory: " . $this->target_dir);
- }
- }
- if (!is_writable($this->target_dir)) {
- throw new Exception("Target directory is not writable: " . $this->target_dir);
- }
- }
-
- /**
- * Remove files that user deleted in the UI
- */
- public function cleanup($existing_csv, $keep_csv) {
- if (empty($existing_csv)) return;
-
- $existing = array_filter(array_map('trim', explode(',', $existing_csv)));
- $keep = !empty($keep_csv) ? array_filter(array_map('trim', explode(',', $keep_csv))) : [];
-
- foreach ($existing as $file) {
- if (!in_array($file, $keep)) {
- $path = $this->target_dir . $file;
- if (file_exists($path)) {
- unlink($path);
- $this->deleted_files[] = $file;
- }
- }
- }
- }
-
- /**
- * Upload new files from $_FILES
- * Returns array of errors (empty = success)
- */
- public function upload($field_name) {
- if (!isset($_FILES[$field_name])) return [];
-
- $errors = [];
-
- // Normalize single file to array structure
- $files = $_FILES[$field_name];
- if (!is_array($files['name'])) {
- $files['name'] = [$files['name']];
- $files['tmp_name'] = [$files['tmp_name']];
- $files['error'] = [$files['error']];
- $files['size'] = [$files['size']];
- }
-
- foreach ($files['name'] as $key => $name) {
- $error_code = $files['error'][$key];
-
- if ($error_code !== UPLOAD_ERR_OK) {
- $errors[] = "{$name}: " . $this->getUploadError($error_code);
- continue;
- }
-
- $tmp_name = $files['tmp_name'][$key];
- $file_size = $files['size'][$key];
- $extension = strtolower(pathinfo($name, PATHINFO_EXTENSION));
-
- if ($file_size > $this->max_size) {
- $errors[] = "{$name}: exceeds " . ($this->max_size / 1024 / 1024) . "MB limit";
- continue;
- }
-
- if (!in_array($extension, $this->allowed_extensions)) {
- $errors[] = "{$name}: .{$extension} is not allowed";
- continue;
- }
-
- $finfo = finfo_open(FILEINFO_MIME_TYPE);
- $mime = finfo_file($finfo, $tmp_name);
- finfo_close($finfo);
-
- if (!in_array($mime, $this->allowed_mimes)) {
- $errors[] = "{$name}: content type ({$mime}) is not allowed";
- continue;
- }
-
- // FILE NAME SANITIZATION + UNIQUE ID
- $original = pathinfo($name, PATHINFO_FILENAME);
- $original = preg_replace('/[^a-zA-Z0-9_-]/', '_', $original); // sanitize
- $file_id = $original . "_" . uniqid() . "." . $extension;
-
- $destination = $this->target_dir . $file_id;
-
- if (move_uploaded_file($tmp_name, $destination)) {
- $this->uploaded_files[] = $file_id;
- chmod($destination, 0644);
- } else {
- $errors[] = "{$name}: failed to save";
- }
- }
-
- return $errors;
- }
-
- /**
- * Build final CSV string for DB
- */
- public function buildFileString($keep_csv) {
- $keep = !empty($keep_csv) ? array_filter(array_map('trim', explode(',', $keep_csv))) : [];
- $final = array_merge($keep, $this->uploaded_files);
- return implode(",", array_filter($final));
- }
-
- /**
- * Build final CSV string for DB as single file
- */
- public function getUploadedFiles() {
- return $this->uploaded_files;
- }
-
- /**
- * Rollback uploaded files if DB fails
- */
- public function rollbackUploads() {
- foreach ($this->uploaded_files as $file) {
- $path = $this->target_dir . $file;
- if (file_exists($path)) {
- unlink($path);
- }
- }
- $this->uploaded_files = [];
- }
-
- private function getUploadError($code) {
- $messages = [
- UPLOAD_ERR_INI_SIZE => "exceeds server max upload size (" . ini_get('upload_max_filesize') . ")",
- UPLOAD_ERR_FORM_SIZE => "exceeds form max size",
- UPLOAD_ERR_PARTIAL => "was only partially uploaded",
- UPLOAD_ERR_NO_FILE => "no file was sent",
- UPLOAD_ERR_NO_TMP_DIR => "server missing temp folder",
- UPLOAD_ERR_CANT_WRITE => "server failed to write to disk",
- UPLOAD_ERR_EXTENSION => "blocked by server extension",
- ];
- return $messages[$code] ?? "unknown error (code {$code})";
- }
-}
-
-
-/**
- * Helper function
- */
-
-// transaction wrapper with retry logic for deadlocks
-function dbTransaction($pdo, $callback, $maxRetries = 3) {
- for ($attempt = 0; $attempt < $maxRetries; $attempt++) {
- try {
- $pdo->beginTransaction();
- $result = $callback($pdo);
- $pdo->commit();
- return $result;
-
- } catch (PDOException $e) {
- $pdo->rollBack();
-
- if ($e->getCode() == '40001' && $attempt < $maxRetries - 1) {
- usleep(600000 * ($attempt + 1));
- continue;
- }
-
- error_log("[DB ERROR] " . $e->getMessage() . " | " . $e->getFile() . ":" . $e->getLine());
- throw $e;
-
- } catch (Exception $e) {
- $pdo->rollBack();
- error_log("[ERROR] " . $e->getMessage() . " | " . $e->getFile() . ":" . $e->getLine());
- throw $e;
- }
- }
-}
?>
\ No newline at end of file
diff --git a/app/assets/utils/db_helpers.php b/app/assets/utils/db_helpers.php
new file mode 100644
index 0000000..3a058b0
--- /dev/null
+++ b/app/assets/utils/db_helpers.php
@@ -0,0 +1,100 @@
+ Custom client-facing error message
+ * 'log' => Custom server log label
+ * 'code' => HTTP response code (default: 500)
+ * 'before_exit' => Callback function before exit
+ *
+ * @example
+ * // Simple — use all defaults
+ * db_check($sth, $answer);
+ *
+ * @example
+ * // Custom client message
+ * db_check($sth, $answer, [
+ * 'message' => "Contact not found."
+ * ]);
+ *
+ * @example
+ * // Custom log label + message
+ * db_check($sth, $answer, [
+ * 'log' => "Failed to fetch contact",
+ * 'message' => "Could not load contact. Please try again."
+ * ]);
+ *
+ * @example
+ * // Custom HTTP status code
+ * db_check($sth, $answer, [
+ * 'code' => 403,
+ * 'message' => "Access denied."
+ * ]);
+ *
+ * @example
+ * // Cleanup callback before exit
+ * db_check($sth, $answer, [
+ * 'message' => "Upload failed.",
+ * 'before_exit' => function() use ($tmp_file) {
+ * if(file_exists($tmp_file)) unlink($tmp_file);
+ * }
+ * ]);
+ */
+
+
+function db_check($sth, &$answer, $options = []) {
+ if ($sth->errorInfo()[0] != "00000" && !empty($sth->errorInfo()[0])) {
+
+ $trace = debug_backtrace()[0];
+
+ $log_message = $options['log'] ?? "DB Error";
+ error_log($log_message . " in " . $trace['file'] . " line " . $trace['line'] . ": " . $sth->errorInfo()[2]);
+
+ // production version
+ $answer["message"] = $options['message'] ?? "A server error occurred. Please try again.";
+
+ http_response_code($options['code'] ?? 500);
+
+ if(isset($options['before_exit'])) {
+ call_user_func($options['before_exit']);
+ }
+
+ exit(json_encode($answer));
+ }
+}
+
+
+
+// transaction wrapper with retry logic for deadlocks
+function dbTransaction($pdo, $callback, $maxRetries = 3) {
+ for ($attempt = 0; $attempt < $maxRetries; $attempt++) {
+ try {
+ $pdo->beginTransaction();
+ $result = $callback($pdo);
+ $pdo->commit();
+ return $result;
+
+ } catch (PDOException $e) {
+ $pdo->rollBack();
+
+ if ($e->getCode() == '40001' && $attempt < $maxRetries - 1) {
+ usleep(600000 * ($attempt + 1));
+ continue;
+ }
+
+ error_log("[DB ERROR] " . $e->getMessage() . " | " . $e->getFile() . ":" . $e->getLine());
+ throw $e;
+
+ } catch (Exception $e) {
+ $pdo->rollBack();
+ error_log("[ERROR] " . $e->getMessage() . " | " . $e->getFile() . ":" . $e->getLine());
+ throw $e;
+ }
+ }
+}
+
+?>
\ No newline at end of file
diff --git a/app/contact/api/engine/manage_contact.php b/app/contact/api/engine/manage_contact.php
index a74f540..17e112c 100644
--- a/app/contact/api/engine/manage_contact.php
+++ b/app/contact/api/engine/manage_contact.php
@@ -1,6 +1,7 @@
-
-
-
-
-
-
-
-
+
+
diff --git a/app/dashboard/reports.php b/app/dashboard/reports.php
new file mode 100644
index 0000000..d022cb9
--- /dev/null
+++ b/app/dashboard/reports.php
@@ -0,0 +1,186 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Reports
+
View your inventory analytics and reports
+
+
+
+
+
+
+
+
+
+
+
+
Total Revenue
+
$45,231
+
12% from last month
+
+
+
+
+
+
+
+
Products Sold
+
1,234
+
8% from last month
+
+
+
+
+
+
+
+
Low Stock Items
+
23
+
3% from last month
+
+
+
+
+
+
+
+
Out of Stock
+
5
+
2% from last month
+
+
+
+
+
+
+
+
+
+
+
+
+
Sales Overview
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

+
+
+
+
+
Gaming Joy Stick
+ 156 units sold
+
+
+ $3,120
+
+
+
+
+
+
+
+

+
+
+
+
+
Wireless Headphones
+ 134 units sold
+
+
+ $2,680
+
+
+
+
+
+
+
+

+
+
+
+
+
Smartwatch
+ 98 units sold
+
+
+ $1,960
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+