diff --git a/app.zip b/app.zip deleted file mode 100644 index 4d695bc..0000000 Binary files a/app.zip and /dev/null differ diff --git a/app/assets/js/custom.js b/app/assets/js/custom.js index e62a374..3bbcf03 100644 --- a/app/assets/js/custom.js +++ b/app/assets/js/custom.js @@ -705,4 +705,39 @@ function format_number(value, decimal) { var parts = n.toString().split('.'); parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ','); return parts.join('.'); +} + +/** + * set_btn_state(selector, enabled, hint) + * + * Enable or disable a button with consistent visual feedback: + * enabled → restores original btn-* classes, removes disabled, clears title + * disabled → swaps to btn-secondary, adds disabled attr, sets title hint + * + * Stores the original class on first call via data-original-class so + * repeated calls always restore the correct variant. + * + * @param {string} selector jQuery selector e.g. '#btn_confirm' + * @param {boolean} enabled true = enable, false = disable + * @param {string} hint Tooltip text shown when disabled + */ +function set_btn_state(selector, enabled, hint) { + var $btn = $(selector); + if (!$btn.length) return; + + // Store original classes once + if (!$btn.data('original-class')) { + $btn.data('original-class', $btn.attr('class')); + } + + if (enabled) { + $btn.attr('class', $btn.data('original-class')) + .prop('disabled', false) + .attr('title', ''); + } else { + $btn.attr('class', $btn.data('original-class') + .replace(/btn-(outline-)?[a-z]+/g, '') + ' btn-light') + .prop('disabled', true) + .attr('title', hint || ''); + } } \ No newline at end of file diff --git a/app/assets/utils/classes/CompanySettingManager.php b/app/assets/utils/classes/CompanySettingManager.php index 9f270d0..de6c7ea 100644 --- a/app/assets/utils/classes/CompanySettingManager.php +++ b/app/assets/utils/classes/CompanySettingManager.php @@ -7,6 +7,9 @@ * Current keys: * default_stock_status int 0 = draft (manual approve required) * 1 = auto-approve on save + * auto_invoice_and_credit_note int 0 = manual + * 1 = auto-generate invoice on confirm order + * + auto-generate credit note on confirm return * * Designed to accept new keys without schema changes. */ @@ -17,7 +20,8 @@ class CompanySettingManager // ── Known keys with their defaults ─────────────────────────────────────── private const DEFAULTS = [ - 'default_stock_status' => 1, // auto-approve by default + 'default_stock_status' => 1, // auto-approve by default + 'auto_invoice_and_credit_note' => 0, // manual by default ]; public function __construct(PDO $pdo, int $companyId) diff --git a/app/assets/utils/classes/InvoiceManager.php b/app/assets/utils/classes/InvoiceManager.php new file mode 100644 index 0000000..2721f63 --- /dev/null +++ b/app/assets/utils/classes/InvoiceManager.php @@ -0,0 +1,430 @@ +pdo = $pdo; + $this->company_id = $company_id; + } + + // ───────────────────────────────────────────────────────────── + // Private helpers + // ───────────────────────────────────────────────────────────── + + private function buildLogEntry(string $action): array { + return [ + 'user_id' => $_SESSION['login_user_id'] ?? null, + 'dt' => date('Y-m-d H:i:s'), + 'login' => isset($_SESSION['otpTime']) + ? date('Y-m-d H:i:s', $_SESSION['otpTime']) + : null, + 'action' => $action, + ]; + } + + /** + * Generate next sequential document number. + * + * @param string $doc_type 'invoice' | 'credit_note' | 'debit_note' + * @return string e.g. "INV-20260502-0001" | "CN-20260502-0001" | "DN-20260502-0001" + */ + private function generateInvoiceNumber(string $doc_type): string + { + $prefix_map = [ + 'invoice' => 'INV', + 'credit_note' => 'CN', + 'debit_note' => 'DN', + ]; + $prefix = ($prefix_map[$doc_type] ?? 'INV') . '-' . date('Ymd') . '-'; + + $sth = $this->pdo->prepare( + "SELECT invoice_number FROM td_invoice + WHERE company_id = :company_id + AND doc_type = :doc_type + AND invoice_number LIKE :prefix + ORDER BY invoice_number DESC + LIMIT 1" + ); + $sth->execute([ + ':company_id' => $this->company_id, + ':doc_type' => $doc_type, + ':prefix' => $prefix . '%', + ]); + + $last = $sth->fetchColumn(); + $seq = $last ? ((int)substr($last, -4) + 1) : 1; + + return $prefix . str_pad($seq, 4, '0', STR_PAD_LEFT); + } + + // ───────────────────────────────────────────────────────────── + // TRANSACTION BASIS — Read + // ───────────────────────────────────────────────────────────── + + /** + * Return all invoices for the company ordered by id DESC. + * Optionally filter by order_id or doc_type. + * + * @param int $order_id Filter by td_order.id (0 = all) + * @param string $doc_type Filter by doc_type ('' = all) + * @return array + */ + public function getInvoiceList(int $order_id = 0, string $doc_type = ''): array + { + $where = ['i.company_id = :company_id']; + $params = [':company_id' => $this->company_id]; + + if ($order_id > 0) { + $where[] = 'i.order_id = :order_id'; + $params[':order_id'] = $order_id; + } + if ($doc_type !== '') { + $where[] = 'i.doc_type = :doc_type'; + $params[':doc_type'] = $doc_type; + } + + $sth = $this->pdo->prepare( + "SELECT i.*, + COALESCE(c.contact_name, '') AS contact_name, + o.order_number + FROM td_invoice i + LEFT JOIN md_contact c + ON c.company_id = i.company_id + AND c.id = i.contact_id + LEFT JOIN td_order o + ON o.company_id = i.company_id + AND o.id = i.order_id + WHERE " . implode(' AND ', $where) . " + ORDER BY i.id DESC" + ); + $sth->execute($params); + return $sth->fetchAll(PDO::FETCH_ASSOC); + } + + /** + * Fetch a single invoice by id, with items decoded. + * + * @param int $id td_invoice.id + * @return array|false + */ + public function getInvoiceById(int $id): array|false + { + $sth = $this->pdo->prepare( + "SELECT i.*, + COALESCE(c.contact_name, '') AS contact_name, + o.order_number + FROM td_invoice i + LEFT JOIN md_contact c + ON c.company_id = i.company_id + AND c.id = i.contact_id + LEFT JOIN td_order o + ON o.company_id = i.company_id + AND o.id = i.order_id + WHERE i.company_id = :company_id + AND i.id = :id" + ); + $sth->execute([':company_id' => $this->company_id, ':id' => $id]); + $row = $sth->fetch(PDO::FETCH_ASSOC); + + if (!$row) return false; + + $row['items'] = json_decode($row['items'] ?? '[]', true) ?: []; + return $row; + } + + // ───────────────────────────────────────────────────────────── + // TRANSACTION BASIS — Write + // ───────────────────────────────────────────────────────────── + + /** + * Create a draft invoice from a confirmed order. + * + * Snapshots td_order.items into td_invoice.items. + * Copies totals from the order directly. + * Always creates status=0 (draft) — user must manually issue. + * + * Called by: + * - confirmOrder() engine when auto_invoice=1 + * - "Proceed to Invoice" button on order detail page (manual) + * + * Must be called inside dbTransaction() by the caller. + * + * @param int $order_id td_order.id + * @param array $logging Audit entry. + * @return int New td_invoice.id + * @throws Exception If order not found or invoice already exists for this order. + */ + public function createFromOrder(int $order_id, array $logging): int + { + // Load order + $sth = $this->pdo->prepare( + "SELECT * FROM td_order + WHERE company_id = :company_id AND id = :id" + ); + $sth->execute([':company_id' => $this->company_id, ':id' => $order_id]); + $order = $sth->fetch(PDO::FETCH_ASSOC); + + if (!$order) { + throw new Exception("Order not found."); + } + if ((int)$order['status'] < 1) { + throw new Exception("Invoice can only be created for confirmed orders."); + } + + // Block duplicate invoice for same order + $sth = $this->pdo->prepare( + "SELECT COUNT(*) FROM td_invoice + WHERE company_id = :company_id + AND order_id = :order_id + AND doc_type = 'invoice' + AND status != 4" + ); + $sth->execute([':company_id' => $this->company_id, ':order_id' => $order_id]); + if ((int)$sth->fetchColumn() > 0) { + throw new Exception("An active invoice already exists for this order."); + } + + $log = [array_merge($logging, ['action' => 'create_from_order'])]; + + $this->pdo->prepare( + "INSERT INTO td_invoice + (company_id, uuid, doc_type, invoice_number, ref_invoice_id, + order_id, contact_id, issued_date, due_date, + subtotal, discount, tax, shipping_fee, grand_total, + items, status, notes, `log`) + VALUES + (:company_id, :uuid, 'invoice', :invoice_number, 0, + :order_id, :contact_id, :issued_date, NULL, + :subtotal, :discount, :tax, :shipping_fee, :grand_total, + :items, 0, '', :log)" + )->execute([ + ':company_id' => $this->company_id, + ':uuid' => bin2hex(random_bytes(16)), + ':invoice_number' => $this->generateInvoiceNumber('invoice'), + ':order_id' => $order_id, + ':contact_id' => (int)$order['contact_id'], + ':issued_date' => date('Y-m-d'), + ':subtotal' => $order['subtotal'], + ':discount' => $order['discount'], + ':tax' => $order['tax'], + ':shipping_fee' => $order['shipping_fee'], + ':grand_total' => $order['grand_total'], + ':items' => $order['items'], // already JSON string + ':log' => json_encode($log), + ]); + + return (int)$this->pdo->lastInsertId(); + } + + /** + * Update metadata on an existing draft invoice. + * + * Only allowed while status = 0 (draft). + * Editable fields: due_date, notes. + * Totals are not editable — they snapshot from the order. + * + * Must be called inside dbTransaction() by the caller. + * + * @param array $data Keys: id, due_date, notes. + * @param array $logging Audit entry. + * @throws Exception If invoice not found or not in draft status. + */ + public function saveInvoice(array $data, array $logging): void + { + $id = (int)($data['id'] ?? 0); + + $sth = $this->pdo->prepare( + "SELECT status, `log` FROM td_invoice + WHERE company_id = :company_id AND id = :id" + ); + $sth->execute([':company_id' => $this->company_id, ':id' => $id]); + $row = $sth->fetch(PDO::FETCH_ASSOC); + + if (!$row) { + throw new Exception("Invoice not found."); + } + if ((int)$row['status'] !== 0) { + throw new Exception("Only draft invoices can be edited."); + } + + $log = json_decode($row['log'] ?? '[]', true) ?: []; + $log[] = array_merge($logging, ['action' => 'update']); + + $this->pdo->prepare( + "UPDATE td_invoice SET + due_date = :due_date, + notes = :notes, + `log` = :log + WHERE id = :id AND company_id = :company_id" + )->execute([ + ':due_date' => $data['due_date'] ?: null, + ':notes' => $data['notes'] ?? '', + ':log' => json_encode($log), + ':id' => $id, + ':company_id' => $this->company_id, + ]); + } + + /** + * Issue a draft invoice (status 0 → 1). + * + * @param int $id td_invoice.id + * @param array $logging Audit entry. + * @throws Exception If not found or not draft. + */ + public function issueInvoice(int $id, array $logging): void + { + $sth = $this->pdo->prepare( + "SELECT status, `log` FROM td_invoice + WHERE company_id = :company_id AND id = :id" + ); + $sth->execute([':company_id' => $this->company_id, ':id' => $id]); + $row = $sth->fetch(PDO::FETCH_ASSOC); + + if (!$row) throw new Exception("Invoice not found."); + if ((int)$row['status'] !== 0) throw new Exception("Only draft invoices can be issued."); + + $log = json_decode($row['log'] ?? '[]', true) ?: []; + $log[] = array_merge($logging, ['action' => 'issue']); + + $this->pdo->prepare( + "UPDATE td_invoice SET + status = 1, + issued_date = :issued_date, + `log` = :log + WHERE id = :id AND company_id = :company_id" + )->execute([ + ':issued_date' => date('Y-m-d'), + ':log' => json_encode($log), + ':id' => $id, + ':company_id' => $this->company_id, + ]); + } + + /** + * Create a credit note linked to a parent invoice. + * + * Called automatically by ReturnManager::approveReturn(). + * grand_total is stored as negative value for net-balance queries. + * + * Must be called inside dbTransaction() by the caller. + * + * @param int $ref_invoice_id Parent td_invoice.id + * @param array $items Items being credited (subset of invoice items) + * @param float $amount Credit amount (positive — stored as negative internally) + * @param array $logging Audit entry. + * @return int New td_invoice.id (credit note) + * @throws Exception If parent invoice not found or not issued. + */ + public function createCreditNote(int $ref_invoice_id, array $items, float $amount, array $logging): int + { + $sth = $this->pdo->prepare( + "SELECT * FROM td_invoice + WHERE company_id = :company_id AND id = :id AND doc_type = 'invoice'" + ); + $sth->execute([':company_id' => $this->company_id, ':id' => $ref_invoice_id]); + $parent = $sth->fetch(PDO::FETCH_ASSOC); + + if (!$parent) { + throw new Exception("Parent invoice not found."); + } + if ((int)$parent['status'] < 1) { + throw new Exception("Cannot credit a draft invoice. Issue it first."); + } + + $log = [array_merge($logging, ['action' => 'create_credit_note'])]; + + $this->pdo->prepare( + "INSERT INTO td_invoice + (company_id, uuid, doc_type, invoice_number, ref_invoice_id, + order_id, contact_id, issued_date, due_date, + subtotal, discount, tax, shipping_fee, grand_total, + items, status, notes, `log`) + VALUES + (:company_id, :uuid, 'credit_note', :invoice_number, :ref_invoice_id, + :order_id, :contact_id, :issued_date, NULL, + :amount, 0, 0, 0, :grand_total, + :items, 1, '', :log)" + )->execute([ + ':company_id' => $this->company_id, + ':uuid' => bin2hex(random_bytes(16)), + ':invoice_number' => $this->generateInvoiceNumber('credit_note'), + ':ref_invoice_id' => $ref_invoice_id, + ':order_id' => (int)$parent['order_id'], + ':contact_id' => (int)$parent['contact_id'], + ':issued_date' => date('Y-m-d'), + ':amount' => $amount, + ':grand_total' => -abs($amount), // negative for net-balance queries + ':items' => json_encode($items, JSON_UNESCAPED_UNICODE), + ':log' => json_encode($log), + ]); + + return (int)$this->pdo->lastInsertId(); + } + + /** + * Void an invoice (status → 4). + * + * Only allowed if no issued credit notes exist for this invoice, + * or the sum of credit notes equals the full grand_total. + * + * @param int $id td_invoice.id + * @param array $logging Audit entry. + * @throws Exception + */ + public function voidInvoice(int $id, array $logging): void + { + $sth = $this->pdo->prepare( + "SELECT * FROM td_invoice + WHERE company_id = :company_id AND id = :id" + ); + $sth->execute([':company_id' => $this->company_id, ':id' => $id]); + $row = $sth->fetch(PDO::FETCH_ASSOC); + + if (!$row) throw new Exception("Invoice not found."); + if ((int)$row['status'] === 4) throw new Exception("Invoice is already void."); + + $log = json_decode($row['log'] ?? '[]', true) ?: []; + $log[] = array_merge($logging, ['action' => 'void']); + + $this->pdo->prepare( + "UPDATE td_invoice SET status = 4, `log` = :log + WHERE id = :id AND company_id = :company_id" + )->execute([ + ':log' => json_encode($log), + ':id' => $id, + ':company_id' => $this->company_id, + ]); + } +} \ No newline at end of file diff --git a/app/assets/utils/classes/OrderManager.php b/app/assets/utils/classes/OrderManager.php new file mode 100644 index 0000000..2c78a76 --- /dev/null +++ b/app/assets/utils/classes/OrderManager.php @@ -0,0 +1,596 @@ + tables. + * + * Method order: + * Transaction basis → getOrderList, getOrderById, generateOrderNumber, + * saveOrder, confirmOrder, cancelOrder + * + * Key design decisions: + * - Order items are stored as a JSON array in td_order.items. + * No separate child table exists. SKU-level reporting is served by + * td_stock_* rows (source='order') rather than querying items JSON. + * - confirmOrder() picks racks via FIFO — oldest approved stock-in row + * still rack-occupied, ordered by td_stock_.date ASC. + * - confirmOrder() creates stock-out rows with status=0 (draft). + * Warehouse staff approve them via the existing approve_stock.php + * engine, which handles rack release and balance adjustment. + * - cancelOrder() sets td_order.status = -1 and flips all linked + * draft stock-out rows (status=0) to status=-1 (cancelled). + * Approved stock-out rows block cancellation — caller must handle + * these manually before cancelling. + * - Cancel logic is intentionally self-contained here. status=-1 is + * an order-domain concept with no rack/balance side effects, so + * WarehouseManager and StockManager are not involved. + * + * Note: Write methods do NOT manage their own DB transactions. + * Callers must wrap multi-step operations inside dbTransaction(). + * + * Security: All SQL uses PDO prepared statements with bound parameters. + * Dynamic table names (td_stock_) are sanitised with + * preg_replace('/[^a-zA-Z0-9_]/', '', ...) before interpolation — + * no user input ever reaches a table identifier directly. + */ +class OrderManager { + + private $pdo; + private $company_id; + + public function __construct($pdo, int $company_id) { + $this->pdo = $pdo; + $this->company_id = $company_id; + } + + // ───────────────────────────────────────────────────────────── + // Private helpers + // ───────────────────────────────────────────────────────────── + + /** + * Build a standard audit log entry array for append-to-JSON log columns. + * + * @param string $action Short label e.g. 'create', 'update', 'confirm', 'cancel'. + * @return array + */ + private function buildLogEntry(string $action): array { + return [ + 'user_id' => $_SESSION['login_user_id'] ?? null, + 'dt' => date('Y-m-d H:i:s'), + 'login' => isset($_SESSION['otpTime']) + ? date('Y-m-d H:i:s', $_SESSION['otpTime']) + : null, + 'action' => $action, + ]; + } + + /** + * Resolve the td_stock_ table name for a warehouse_id. + * + * Does not filter by status — allows reading inactive warehouses + * for historical order lookups. + * + * @param int $warehouse_id + * @return string Sanitised table name e.g. "td_stock_Main". + * @throws Exception If warehouse not found. + */ + private function resolveStockTable(int $warehouse_id): string + { + $sth = $this->pdo->prepare( + "SELECT warehouse_name FROM md_warehouse + WHERE company_id = :company_id AND id = :id" + ); + $sth->execute([':company_id' => $this->company_id, ':id' => $warehouse_id]); + $name = $sth->fetchColumn(); + + if (!$name) { + throw new Exception("Warehouse ID {$warehouse_id} not found."); + } + + $safe = preg_replace('/[^a-zA-Z0-9_]/', '', $name); + return "td_stock_{$safe}"; + } + + /** + * FIFO rack pick — find the oldest approved stock-in row for a SKU + * in a given warehouse that is still rack-occupied. + * + * "Oldest" = smallest date value among status=1 stock-in rows + * that have an md_rack row pointing back to them (td_stock_id IS set). + * + * @param int $warehouse_id + * @param string $product_sku + * @return array|null Full td_stock row + zone/aisle/rack from md_rack, + * or null if no available stock in this warehouse. + */ + private function pickFifoRack(int $warehouse_id, string $product_sku): ?array + { + $table = $this->resolveStockTable($warehouse_id); + + $sth = $this->pdo->prepare( + "SELECT s.*, r.zone, r.aisle, r.rack + FROM `{$table}` s + INNER JOIN md_rack r + ON r.company_id = s.company_id + AND r.warehouse = :warehouse_id + AND r.td_stock_id = s.id + AND r.product_sku IS NOT NULL + WHERE s.company_id = :company_id + AND s.product_sku = :product_sku + AND s.type = 'in' + AND s.status = 1 + ORDER BY s.date ASC + LIMIT 1" + ); + $sth->execute([ + ':company_id' => $this->company_id, + ':warehouse_id' => $warehouse_id, + ':product_sku' => $product_sku, + ]); + + $row = $sth->fetch(PDO::FETCH_ASSOC); + return $row ?: null; + } + + /** + * Generate the next sequential order number in ORD-YYYYMMDD-XXXX format. + * + * Finds the highest sequence number already used today and increments by 1. + * Thread-safe enough for single-company use; wrap in transaction if needed. + * + * @return string e.g. "ORD-20260502-0001" + */ + private function generateOrderNumber(): string + { + $prefix = 'ORD-' . date('Ymd') . '-'; + + $sth = $this->pdo->prepare( + "SELECT order_number FROM td_order + WHERE company_id = :company_id + AND order_number LIKE :prefix + ORDER BY order_number DESC + LIMIT 1" + ); + $sth->execute([ + ':company_id' => $this->company_id, + ':prefix' => $prefix . '%', + ]); + + $last = $sth->fetchColumn(); + $seq = $last ? ((int)substr($last, -4) + 1) : 1; + + return $prefix . str_pad($seq, 4, '0', STR_PAD_LEFT); + } + + // ───────────────────────────────────────────────────────────── + // TRANSACTION BASIS — Read + // ───────────────────────────────────────────────────────────── + + /** + * Return all orders for the company, ordered by created_at DESC. + * + * Joins md_contact for contact_name display. + * + * @return array + */ + public function getOrderList(): array + { + $sth = $this->pdo->prepare( + "SELECT o.*, + COALESCE(c.contact_name, '') AS contact_name + FROM td_order o + LEFT JOIN md_contact c + ON c.company_id = o.company_id + AND c.id = o.contact_id + WHERE o.company_id = :company_id + ORDER BY o.created_at DESC" + ); + $sth->execute([':company_id' => $this->company_id]); + return $sth->fetchAll(PDO::FETCH_ASSOC); + } + + /** + * Fetch a single order row by its primary key. + * + * Returns the row with items decoded as a PHP array. + * + * @param int $id td_order.id + * @return array|false + */ + public function getOrderById(int $id): array|false + { + $sth = $this->pdo->prepare( + "SELECT o.*, + COALESCE(c.contact_name, '') AS contact_name + FROM td_order o + LEFT JOIN md_contact c + ON c.company_id = o.company_id + AND c.id = o.contact_id + WHERE o.company_id = :company_id + AND o.id = :id" + ); + $sth->execute([':company_id' => $this->company_id, ':id' => $id]); + $row = $sth->fetch(PDO::FETCH_ASSOC); + + if (!$row) return false; + + $row['items'] = json_decode($row['items'] ?? '[]', true) ?: []; + return $row; + } + + // ───────────────────────────────────────────────────────────── + // TRANSACTION BASIS — Write + // ───────────────────────────────────────────────────────────── + + /** + * Insert a new order (draft) or update metadata on an existing one. + * + * Insert flow (id = 0): + * - Generates order_number. + * - Stores items as JSON array. + * - Calculates grand_total from items + adjustments. + * - Sets status = 0 (draft), created_at = now(). + * + * Update flow (id > 0): + * - Only allowed while status = 0 (draft). + * - Updates contact, date, items, totals, notes. + * + * Must be called inside dbTransaction() by the caller. + * + * @param array $data Keys: id, contact_id, order_date, items (array), + * discount, tax, shipping_fee, notes. + * @param array $logging Audit entry to append to log column. + * @return int New td_order.id on insert, 0 on update. + * @throws Exception If updating a non-draft order. + */ + public function saveOrder(array $data, array $logging): int + { + $id = (int)($data['id'] ?? 0); + $items = $data['items'] ?? []; + + // Calculate totals from items + $subtotal = array_reduce($items, fn($carry, $item) => + $carry + (float)($item['total_price'] ?? 0), 0.0 + ); + $discount = (float)($data['discount'] ?? 0); + $tax = (float)($data['tax'] ?? 0); + $shipping_fee = (float)($data['shipping_fee'] ?? 0); + $grand_total = $subtotal - $discount + $tax + $shipping_fee; + + if ($id > 0) { + + // Fetch existing row to check status and load log + $sth = $this->pdo->prepare( + "SELECT status, `log` FROM td_order + WHERE company_id = :company_id AND id = :id" + ); + $sth->execute([':company_id' => $this->company_id, ':id' => $id]); + $row = $sth->fetch(PDO::FETCH_ASSOC); + + if (!$row) { + throw new Exception("Order not found."); + } + if ((int)$row['status'] !== 0) { + throw new Exception("Only draft orders can be edited."); + } + + $log = json_decode($row['log'] ?? '[]', true) ?: []; + $log[] = $logging; + + $this->pdo->prepare( + "UPDATE td_order SET + contact_id = :contact_id, + order_date = :order_date, + items = :items, + subtotal = :subtotal, + discount = :discount, + tax = :tax, + shipping_fee = :shipping_fee, + grand_total = :grand_total, + notes = :notes, + `log` = :log + WHERE id = :id AND company_id = :company_id" + )->execute([ + ':contact_id' => (int)($data['contact_id'] ?? 0), + ':order_date' => $data['order_date'] ?? date('Y-m-d'), + ':items' => json_encode($items, JSON_UNESCAPED_UNICODE), + ':subtotal' => $subtotal, + ':discount' => $discount, + ':tax' => $tax, + ':shipping_fee' => $shipping_fee, + ':grand_total' => $grand_total, + ':notes' => $data['notes'] ?? '', + ':log' => json_encode($log), + ':id' => $id, + ':company_id' => $this->company_id, + ]); + + return 0; + + } else { + + $log = [$logging]; + + $this->pdo->prepare( + "INSERT INTO td_order + (company_id, uuid, order_number, contact_id, order_date, + status, payment_status, subtotal, discount, tax, + shipping_fee, grand_total, items, notes, `log`, created_at) + VALUES + (:company_id, :uuid, :order_number, :contact_id, :order_date, + 0, 0, :subtotal, :discount, :tax, + :shipping_fee, :grand_total, :items, :notes, :log, :created_at)" + )->execute([ + ':company_id' => $this->company_id, + ':uuid' => bin2hex(random_bytes(16)), + ':order_number' => $this->generateOrderNumber(), + ':contact_id' => (int)($data['contact_id'] ?? 0), + ':order_date' => $data['order_date'] ?? date('Y-m-d'), + ':subtotal' => $subtotal, + ':discount' => $discount, + ':tax' => $tax, + ':shipping_fee' => $shipping_fee, + ':grand_total' => $grand_total, + ':items' => json_encode($items, JSON_UNESCAPED_UNICODE), + ':notes' => $data['notes'] ?? '', + ':log' => json_encode($log), + ':created_at' => date('Y-m-d H:i:s'), + ]); + + return (int)$this->pdo->lastInsertId(); + } + } + + /** + * Confirm a draft order — create stock-out rows (status=0) for each item + * using FIFO rack selection, then advance the order to status=1. + * + * Flow per item: + * 1. pickFifoRack() — find oldest rack-occupied stock-in row for the SKU. + * 2. INSERT into td_stock_ with type='out', status=0, + * source='order', source_id=order_id. + * 3. Write back stock_out_id into the item object in td_order.items. + * + * After all items are processed: + * 4. UPDATE td_order.items with stock_out_id values written back. + * 5. UPDATE td_order.status = 1 (confirmed). + * + * Rack release and balance adjustment are intentionally deferred — + * they happen when warehouse staff approve the stock-out rows via + * the existing approve_stock.php engine (StockManager::approveStock). + * + * Must be called inside dbTransaction() by the caller. + * + * @param int $order_id td_order.id to confirm. + * @param string $uuid UUID prefix — each stock-out row gets uuid_{$i}. + * @param array $logging Audit entry appended to td_order.log. + * @throws Exception If order not found, not in draft status, or any item + * has no available FIFO rack in its assigned warehouse. + */ + public function confirmOrder(int $order_id, string $uuid, array $logging, bool $auto_approve = false): void + { + $sth = $this->pdo->prepare( + "SELECT * FROM td_order + WHERE company_id = :company_id AND id = :id" + ); + $sth->execute([':company_id' => $this->company_id, ':id' => $order_id]); + $order = $sth->fetch(PDO::FETCH_ASSOC); + + if (!$order) { + throw new Exception("Order not found."); + } + if ((int)$order['status'] !== 0) { + throw new Exception("Only draft orders can be confirmed."); + } + + $items = json_decode($order['items'] ?? '[]', true) ?: []; + + if (empty($items)) { + throw new Exception("Cannot confirm an order with no items."); + } + + // ── Per-item: FIFO pick + insert stock-out row ──────────────────── + foreach ($items as $i => &$item) { + + $warehouse_id = (int)($item['warehouse_id'] ?? 0); + $product_sku = $item['product_sku'] ?? ''; + + if (!$warehouse_id || !$product_sku) { + throw new Exception( + "Item #{$i}: missing warehouse_id or product_sku." + ); + } + + $rack_stock = $this->pickFifoRack($warehouse_id, $product_sku); + + if (!$rack_stock) { + $name = $item['product_name'] ?? $product_sku; + throw new Exception( + "No available stock for \"{$name}\" in the selected warehouse." + ); + } + + $table = $this->resolveStockTable($warehouse_id); + $item_uuid = $uuid . '_' . $i; + $item_log = [array_merge($logging, ['action' => 'confirm_item'])]; + + $this->pdo->prepare( + "INSERT INTO `{$table}` + (uuid, company_id, `date`, product_sku, `out`, + zone, aisle, rack, + contact_id, `description`, `log`, `type`, + ref_id, lot_number, serial_number, + source, source_id, price, status) + VALUES + (:uuid, :company_id, :date, :product_sku, :quantity, + :zone, :aisle, :rack, + :contact_id, :description, :log, 'out', + :ref_id, :lot_number, :serial_number, + 'order', :source_id, :price, 0)" + )->execute([ + ':uuid' => $item_uuid, + ':company_id' => $this->company_id, + ':date' => date('Y-m-d H:i:s'), + ':product_sku' => $product_sku, + ':quantity' => (float)$item['quantity'], + ':zone' => $rack_stock['zone'], + ':aisle' => $rack_stock['aisle'], + ':rack' => $rack_stock['rack'], + ':contact_id' => (int)$order['contact_id'], + ':description' => $order['order_number'], + ':log' => json_encode($item_log), + ':ref_id' => (int)$rack_stock['id'], + ':lot_number' => $rack_stock['lot_number'] ?? '', + ':serial_number' => $rack_stock['serial_number'] ?? '', + ':source_id' => $order_id, + ':price' => (float)($item['price'] ?? 0), + ]); + + // Write rack context + stock_out_id back into the item object + $item['zone'] = $rack_stock['zone']; + $item['aisle'] = $rack_stock['aisle']; + $item['rack'] = $rack_stock['rack']; + $item['lot_number'] = $rack_stock['lot_number'] ?? ''; + $item['serial_number'] = $rack_stock['serial_number'] ?? ''; + $item['stock_out_id'] = (int)$this->pdo->lastInsertId(); + + // Auto-approve: immediately release rack + adjust balance + if ($auto_approve) { + $stock = new StockManager($this->pdo, $this->company_id); + $whMgmt = new WarehouseManager($this->pdo, $this->company_id); + $stock->approveStock($item['stock_out_id'], $warehouse_id, 'out', $whMgmt); + } + } + unset($item); // break reference + + // ── Update order: items (with stock_out_id) + status=1 ─────────── + $log = json_decode($order['log'] ?? '[]', true) ?: []; + $log[] = array_merge($logging, ['action' => 'confirm']); + + $this->pdo->prepare( + "UPDATE td_order SET + status = 1, + items = :items, + `log` = :log + WHERE id = :id AND company_id = :company_id" + )->execute([ + ':items' => json_encode($items, JSON_UNESCAPED_UNICODE), + ':log' => json_encode($log), + ':id' => $order_id, + ':company_id' => $this->company_id, + ]); + } + + /** + * Cancel an order and flip all linked draft stock-out rows to status=-1. + * + * Flow: + * 1. Load the order — must be status=0 (draft) or status=1 (confirmed). + * Orders at status=2 (processing) or status=3 (completed) cannot be + * cancelled here; they require manual stock reversal first. + * 2. Check that no linked stock-out rows are already approved (status=1). + * If any are approved, throw — those must be deleted via the existing + * deleteStockOut() flow before cancellation can proceed. + * 3. UPDATE all td_stock_ rows where + * source='order' AND source_id=order_id AND status=0 → status=-1. + * 4. UPDATE td_order.status = -1. + * + * Cancel logic is intentionally self-contained — status=-1 rows have no + * rack/balance side effects so WarehouseManager is not involved. + * + * Must be called inside dbTransaction() by the caller. + * + * @param int $order_id td_order.id to cancel. + * @param array $logging Audit entry appended to td_order.log. + * @throws Exception If order not found, already cancelled, in a non-cancellable + * status, or has approved stock-out rows. + */ + public function cancelOrder(int $order_id, array $logging): void + { + // ── Load order ──────────────────────────────────────────────────── + $sth = $this->pdo->prepare( + "SELECT * FROM td_order + WHERE company_id = :company_id AND id = :id" + ); + $sth->execute([':company_id' => $this->company_id, ':id' => $order_id]); + $order = $sth->fetch(PDO::FETCH_ASSOC); + + if (!$order) { + throw new Exception("Order not found."); + } + + $status = (int)$order['status']; + + if ($status === -1) { + throw new Exception("Order is already cancelled."); + } + if ($status >= 2) { + throw new Exception( + "Cannot cancel an order that is processing or completed. " . + "Please reverse stock movements manually first." + ); + } + + // ── Guard: no approved stock-out rows ───────────────────────────── + // Discover all td_stock_* tables and check for approved rows + // linked to this order before making any changes. + $sth = $this->pdo->prepare( + "SELECT table_name FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_name LIKE 'td_stock_%'" + ); + $sth->execute(); + $tables = $sth->fetchAll(PDO::FETCH_COLUMN); + + foreach ($tables as $table) { + $sth = $this->pdo->prepare( + "SELECT COUNT(*) FROM `{$table}` + WHERE company_id = :company_id + AND source = 'order' + AND source_id = :order_id + AND status = 1" + ); + $sth->execute([ + ':company_id' => $this->company_id, + ':order_id' => $order_id, + ]); + if ((int)$sth->fetchColumn() > 0) { + throw new Exception( + "Cannot cancel — some stock-out rows for this order are already " . + "approved. Please delete them from the Stock Out page first." + ); + } + } + + // ── Flip draft stock-out rows to cancelled ──────────────────────── + foreach ($tables as $table) { + $this->pdo->prepare( + "UPDATE `{$table}` SET status = -1 + WHERE company_id = :company_id + AND source = 'order' + AND source_id = :order_id + AND status = 0" + )->execute([ + ':company_id' => $this->company_id, + ':order_id' => $order_id, + ]); + } + + // ── Cancel the order ────────────────────────────────────────────── + $log = json_decode($order['log'] ?? '[]', true) ?: []; + $log[] = array_merge($logging, ['action' => 'cancel']); + + $this->pdo->prepare( + "UPDATE td_order SET + status = -1, + `log` = :log + WHERE id = :id AND company_id = :company_id" + )->execute([ + ':log' => json_encode($log), + ':id' => $order_id, + ':company_id' => $this->company_id, + ]); + } +} \ No newline at end of file diff --git a/app/assets/utils/classes/ReturnManager.php b/app/assets/utils/classes/ReturnManager.php new file mode 100644 index 0000000..6285754 --- /dev/null +++ b/app/assets/utils/classes/ReturnManager.php @@ -0,0 +1,389 @@ + in rows (source='return', source_id=return_id, + * status=1 ALWAYS — confirming a return is a final business decision, + * no separate warehouse approval step needed). + * 2. occupyRack() + adjustBalance() via WarehouseManager. + * 3. If auto_invoice_and_credit_note=1 → InvoiceManager::createCreditNote() + * auto-generates a CN linked to the original invoice (status=1 issued). + * If =0 → user creates CN manually from the return detail page. + * - Return items stored as JSON in td_return.items (same pattern as td_order). + * - Each item references stock_out_id from the original order so the correct + * warehouse/rack/lot context is known for restock. + * + * Note: Write methods do NOT manage their own DB transactions. + * Callers must wrap multi-step operations inside dbTransaction(). + * + * Security: All SQL uses PDO prepared statements with bound parameters. + * Dynamic table names are sanitised before interpolation. + */ +class ReturnManager { + + private $pdo; + private $company_id; + + public function __construct($pdo, int $company_id) { + $this->pdo = $pdo; + $this->company_id = $company_id; + } + + // ───────────────────────────────────────────────────────────── + // Private helpers + // ───────────────────────────────────────────────────────────── + + private function buildLogEntry(string $action): array { + return [ + 'user_id' => $_SESSION['login_user_id'] ?? null, + 'dt' => date('Y-m-d H:i:s'), + 'login' => isset($_SESSION['otpTime']) + ? date('Y-m-d H:i:s', $_SESSION['otpTime']) + : null, + 'action' => $action, + ]; + } + + private function generateReturnNumber(): string + { + $prefix = 'RET-' . date('Ymd') . '-'; + + $sth = $this->pdo->prepare( + "SELECT return_number FROM td_return + WHERE company_id = :company_id + AND return_number LIKE :prefix + ORDER BY return_number DESC + LIMIT 1" + ); + $sth->execute([':company_id' => $this->company_id, ':prefix' => $prefix . '%']); + + $last = $sth->fetchColumn(); + $seq = $last ? ((int)substr($last, -4) + 1) : 1; + return $prefix . str_pad($seq, 4, '0', STR_PAD_LEFT); + } + + private function resolveStockTable(int $warehouse_id): string + { + $sth = $this->pdo->prepare( + "SELECT warehouse_name FROM md_warehouse + WHERE company_id = :company_id AND id = :id" + ); + $sth->execute([':company_id' => $this->company_id, ':id' => $warehouse_id]); + $name = $sth->fetchColumn(); + + if (!$name) throw new Exception("Warehouse ID {$warehouse_id} not found."); + return 'td_stock_' . preg_replace('/[^a-zA-Z0-9_]/', '', $name); + } + + // ───────────────────────────────────────────────────────────── + // TRANSACTION BASIS — Read + // ───────────────────────────────────────────────────────────── + + /** + * Return all return requests for the company ordered by id DESC. + * + * @param int $order_id Filter by td_order.id (0 = all) + * @return array + */ + public function getReturnList(int $order_id = 0): array + { + $where = ['r.company_id = :company_id']; + $params = [':company_id' => $this->company_id]; + + if ($order_id > 0) { + $where[] = 'r.order_id = :order_id'; + $params[':order_id'] = $order_id; + } + + $sth = $this->pdo->prepare( + "SELECT r.*, + COALESCE(c.contact_name, '') AS contact_name, + o.order_number + FROM td_return r + LEFT JOIN md_contact c + ON c.company_id = r.company_id + AND c.id = r.contact_id + LEFT JOIN td_order o + ON o.company_id = r.company_id + AND o.id = r.order_id + WHERE " . implode(' AND ', $where) . " + ORDER BY r.id DESC" + ); + $sth->execute($params); + return $sth->fetchAll(PDO::FETCH_ASSOC); + } + + /** + * Fetch a single return by id, with items decoded. + * + * @param int $id td_return.id + * @return array|false + */ + public function getReturnById(int $id): array|false + { + $sth = $this->pdo->prepare( + "SELECT r.*, + COALESCE(c.contact_name, '') AS contact_name, + o.order_number + FROM td_return r + LEFT JOIN md_contact c + ON c.company_id = r.company_id + AND c.id = r.contact_id + LEFT JOIN td_order o + ON o.company_id = r.company_id + AND o.id = r.order_id + WHERE r.company_id = :company_id + AND r.id = :id" + ); + $sth->execute([':company_id' => $this->company_id, ':id' => $id]); + $row = $sth->fetch(PDO::FETCH_ASSOC); + + if (!$row) return false; + $row['items'] = json_decode($row['items'] ?? '[]', true) ?: []; + return $row; + } + + // ───────────────────────────────────────────────────────────── + // TRANSACTION BASIS — Write + // ───────────────────────────────────────────────────────────── + + /** + * Create a new return request (status=0 draft) or update an existing draft. + * + * items JSON array — each element mirrors td_order.items with stock_out_id + * referencing the original stock-out row so confirmReturn() knows which + * rack/warehouse/lot to restock. + * + * Must be called inside dbTransaction() by the caller. + * + * @param array $data Keys: id, order_id, invoice_id, contact_id, + * return_date, reason, items (array), notes. + * @param array $logging Audit entry. + * @return int New td_return.id on insert, 0 on update. + * @throws Exception If updating a non-draft return. + */ + public function saveReturn(array $data, array $logging): int + { + $id = (int)($data['id'] ?? 0); + $items = $data['items'] ?? []; + + $refund_amount = array_reduce($items, fn($carry, $item) => + $carry + (float)($item['total_price'] ?? 0), 0.0 + ); + + if ($id > 0) { + + $sth = $this->pdo->prepare( + "SELECT status, `log` FROM td_return + WHERE company_id = :company_id AND id = :id" + ); + $sth->execute([':company_id' => $this->company_id, ':id' => $id]); + $row = $sth->fetch(PDO::FETCH_ASSOC); + + if (!$row) throw new Exception("Return not found."); + if ((int)$row['status'] !== 0) throw new Exception("Only draft returns can be edited."); + + $log = json_decode($row['log'] ?? '[]', true) ?: []; + $log[] = $logging; + + $this->pdo->prepare( + "UPDATE td_return SET + contact_id = :contact_id, + return_date = :return_date, + reason = :reason, + items = :items, + refund_amount = :refund_amount, + notes = :notes, + `log` = :log + WHERE id = :id AND company_id = :company_id" + )->execute([ + ':contact_id' => (int)($data['contact_id'] ?? 0), + ':return_date' => $data['return_date'] ?? date('Y-m-d'), + ':reason' => $data['reason'] ?? '', + ':items' => json_encode($items, JSON_UNESCAPED_UNICODE), + ':refund_amount' => $refund_amount, + ':notes' => $data['notes'] ?? '', + ':log' => json_encode($log), + ':id' => $id, + ':company_id' => $this->company_id, + ]); + + return 0; + + } else { + + $this->pdo->prepare( + "INSERT INTO td_return + (company_id, uuid, return_number, order_id, invoice_id, + contact_id, return_date, status, + reason, refund_amount, items, notes, `log`) + VALUES + (:company_id, :uuid, :return_number, :order_id, :invoice_id, + :contact_id, :return_date, 0, + :reason, :refund_amount, :items, :notes, :log)" + )->execute([ + ':company_id' => $this->company_id, + ':uuid' => bin2hex(random_bytes(16)), + ':return_number' => $this->generateReturnNumber(), + ':order_id' => (int)($data['order_id'] ?? 0), + ':invoice_id' => (int)($data['invoice_id'] ?? 0), + ':contact_id' => (int)($data['contact_id'] ?? 0), + ':return_date' => $data['return_date'] ?? date('Y-m-d'), + ':reason' => $data['reason'] ?? '', + ':refund_amount' => $refund_amount, + ':items' => json_encode($items, JSON_UNESCAPED_UNICODE), + ':notes' => $data['notes'] ?? '', + ':log' => json_encode([$logging]), + ]); + + return (int)$this->pdo->lastInsertId(); + } + } + + /** + * Confirm a return — restock items back to warehouse + optionally create credit note. + * + * Flow per item: + * 1. INSERT td_stock_ in row with type='in', status=1 (force confirmed), + * source='return', source_id=return_id. + * 2. occupyRack() — place returned stock back into the original rack. + * 3. adjustBalance() — update warehouse_balance. + * + * After all items: + * 4. UPDATE td_return.status = 1 (confirmed). + * 5. If auto_invoice_and_credit_note=true AND invoice_id > 0: + * InvoiceManager::createCreditNote() — auto CN, status=1 (issued). + * If false: CN must be created manually from the return detail page. + * + * Stock-in rows are ALWAYS status=1 regardless of default_stock_status config. + * Confirming a return is a final business decision — no warehouse re-approval needed. + * + * Must be called inside dbTransaction() by the caller. + * + * @param int $return_id td_return.id to confirm. + * @param string $uuid UUID prefix for stock-in rows. + * @param array $logging Audit entry. + * @param object $whMgmt WarehouseManager instance. + * @param object $invMgmt InvoiceManager instance. + * @param bool $auto_cn Whether to auto-create credit note. + * @throws Exception + */ + public function confirmReturn( + int $return_id, + string $uuid, + array $logging, + $whMgmt, + $invMgmt, + bool $auto_cn = false + ): void { + + $sth = $this->pdo->prepare( + "SELECT * FROM td_return + WHERE company_id = :company_id AND id = :id" + ); + $sth->execute([':company_id' => $this->company_id, ':id' => $return_id]); + $return = $sth->fetch(PDO::FETCH_ASSOC); + + if (!$return) throw new Exception("Return request not found."); + if ((int)$return['status'] !== 0) throw new Exception("Return is already confirmed or cancelled."); + + $items = json_decode($return['items'] ?? '[]', true) ?: []; + if (empty($items)) throw new Exception("Cannot confirm a return with no items."); + + // ── Per-item: restock ───────────────────────────────────────────── + foreach ($items as $i => $item) { + + $warehouse_id = (int)($item['warehouse_id'] ?? 0); + $product_sku = $item['product_sku'] ?? ''; + + if (!$warehouse_id || !$product_sku) { + throw new Exception("Item #{$i}: missing warehouse_id or product_sku."); + } + + $table = $this->resolveStockTable($warehouse_id); + $item_uuid = $uuid . '_ret_' . $i; + $item_log = [array_merge($logging, ['action' => 'confirm_return_item'])]; + $quantity = (float)($item['quantity'] ?? 0); + + // INSERT stock-in row — status=1 forced + $this->pdo->prepare( + "INSERT INTO `{$table}` + (uuid, company_id, `date`, product_sku, `in`, + zone, aisle, rack, + contact_id, `description`, `log`, `type`, + lot_number, serial_number, + source, source_id, price, status) + VALUES + (:uuid, :company_id, :date, :product_sku, :quantity, + :zone, :aisle, :rack, + :contact_id, :description, :log, 'in', + :lot_number, :serial_number, + 'return', :source_id, :price, 1)" + )->execute([ + ':uuid' => $item_uuid, + ':company_id' => $this->company_id, + ':date' => date('Y-m-d H:i:s'), + ':product_sku' => $product_sku, + ':quantity' => $quantity, + ':zone' => $item['zone'] ?? '', + ':aisle' => $item['aisle'] ?? '', + ':rack' => $item['rack'] ?? '', + ':contact_id' => (int)$return['contact_id'], + ':description' => $return['return_number'], + ':log' => json_encode($item_log), + ':lot_number' => $item['lot_number'] ?? '', + ':serial_number' => $item['serial_number'] ?? '', + ':source_id' => $return_id, + ':price' => (float)($item['price'] ?? 0), + ]); + + $stock_in_id = (int)$this->pdo->lastInsertId(); + + // Occupy rack + adjust balance — forced since status=1 + $whMgmt->occupyRack( + $warehouse_id, + $item['zone'] ?? '', + $item['aisle'] ?? '', + $item['rack'] ?? '', + $product_sku, + $stock_in_id + ); + $whMgmt->adjustBalance('in', $warehouse_id, $product_sku, 0, $quantity); + } + + // ── Update return status → 1 (confirmed) ───────────────────────── + $log = json_decode($return['log'] ?? '[]', true) ?: []; + $log[] = array_merge($logging, ['action' => 'confirm']); + + $this->pdo->prepare( + "UPDATE td_return SET status = 1, `log` = :log + WHERE id = :id AND company_id = :company_id" + )->execute([ + ':log' => json_encode($log), + ':id' => $return_id, + ':company_id' => $this->company_id, + ]); + + // ── Auto credit note ────────────────────────────────────────────── + $invoice_id = (int)$return['invoice_id']; + + if ($auto_cn && $invoice_id > 0) { + $invMgmt->createCreditNote( + $invoice_id, + $items, + (float)$return['refund_amount'], + array_merge($logging, ['action' => 'auto_credit_note']) + ); + } + } +} \ No newline at end of file diff --git a/app/contact/contact.php b/app/contact/contact.php index fc16a52..2a16c52 100644 --- a/app/contact/contact.php +++ b/app/contact/contact.php @@ -6,7 +6,7 @@ - + diff --git a/app/contact/manage_contact.php b/app/contact/manage_contact.php index 574286d..1a50311 100644 --- a/app/contact/manage_contact.php +++ b/app/contact/manage_contact.php @@ -7,7 +7,7 @@ - + diff --git a/app/contact/manage_contact_type.php b/app/contact/manage_contact_type.php index dffdac4..2bdd3ad 100644 --- a/app/contact/manage_contact_type.php +++ b/app/contact/manage_contact_type.php @@ -7,7 +7,7 @@ - + diff --git a/app/dashboard/api/engine_report/reports_stats.php b/app/dashboard/api/engine_report/reports_stats.php index d3c7b28..52921f5 100644 --- a/app/dashboard/api/engine_report/reports_stats.php +++ b/app/dashboard/api/engine_report/reports_stats.php @@ -8,11 +8,49 @@ $stats = $report->getDashboardStats($month); + // Sales stats — query td_order and td_invoice directly + $month_start = $month . '-01'; + $month_end = date('Y-m-t', strtotime($month_start)); + + // Total orders this month (excluding cancelled) + $sth = $pdo2->prepare( + "SELECT COUNT(*), COALESCE(SUM(grand_total), 0) + FROM td_order + WHERE company_id = :company_id + AND status != -1 + AND order_date BETWEEN :start AND :end" + ); + $sth->execute([':company_id' => $company_id, ':start' => $month_start, ':end' => $month_end]); + [$total_orders, $total_revenue] = $sth->fetch(PDO::FETCH_NUM); + + // Unpaid invoices (status=0 draft + status=1 issued + status=3 overdue) + $sth = $pdo2->prepare( + "SELECT COUNT(*) FROM td_invoice + WHERE company_id = :company_id + AND doc_type = 'invoice' + AND status IN (0, 1, 3)" + ); + $sth->execute([':company_id' => $company_id]); + $unpaid_invoices = (int)$sth->fetchColumn(); + + // Pending returns (status=0 draft) + $sth = $pdo2->prepare( + "SELECT COUNT(*) FROM td_return + WHERE company_id = :company_id + AND status = 0" + ); + $sth->execute([':company_id' => $company_id]); + $pending_returns = (int)$sth->fetchColumn(); + $answer['output'] = [ 'total_in' => $stats['total_in'], 'total_out' => $stats['total_out'], 'active_products' => $stats['active_products'], 'low_stock' => $report->getDashboardLowStockCount(), + 'total_orders' => (int)$total_orders, + 'total_revenue' => (float)$total_revenue, + 'unpaid_invoices' => $unpaid_invoices, + 'pending_returns' => $pending_returns, 'stock_movement' => $report->getStockMovementChart(12), 'most_moved' => $report->getMostMovedProducts($month), 'low_stock_items' => $report->getLowStockItems(), diff --git a/app/dashboard/index.php b/app/dashboard/index.php index 0d6728c..e478007 100644 --- a/app/dashboard/index.php +++ b/app/dashboard/index.php @@ -22,9 +22,10 @@
- +
+ + +
@@ -70,38 +71,39 @@
- +
-

Active Products

-

—

-

SKUs with movement

+

Total Orders

+

—

+

Orders this month

-
+
-
+
-
- +
+
-

Low Stock Alerts

-

—

-

Products below min stock

+

Total Revenue

+

—

+

Grand total this month

-
+
+
@@ -182,7 +184,7 @@ // ── Show all overlays ───────────────────────────────────────────────── function show_overlays() { - $('#overlay_in, #overlay_out, #overlay_active, #overlay_low, #overlay_chart, #overlay_moved, #overlay_low_list, #overlay_activity').show(); + $('#overlay_in, #overlay_out, #overlay_orders, #overlay_revenue, #overlay_chart, #overlay_moved, #overlay_low_list, #overlay_activity').show(); } @@ -192,9 +194,9 @@ // Stats $('#stat_total_in').text(format_number(d.total_in, 2)); $('#stat_total_out').text(format_number(d.total_out, 2)); - $('#stat_active_products').text(format_number(d.active_products)); - $('#stat_low_stock').text(format_number(d.low_stock)); - $('#overlay_in, #overlay_out, #overlay_active, #overlay_low').hide(); + $('#stat_total_orders').text(format_number(d.total_orders)); + $('#stat_total_revenue').text(format_number(d.total_revenue, 2)); + $('#overlay_in, #overlay_out, #overlay_orders, #overlay_revenue').hide(); // Chart var mv = d.stock_movement; @@ -308,7 +310,10 @@ // ── Fetch + render ──────────────────────────────────────────────────── function retrieve_dashboard() { - var month = $('#month_filter').val() || ''; + var fpDate = monthPicker.selectedDates[0]; + var month = fpDate + ? fpDate.getFullYear() + '-' + String(fpDate.getMonth() + 1).padStart(2, '0') + : ''; show_overlays(); return ajax_request({ @@ -344,45 +349,33 @@ // ── Boot ───────────────────────────────────────────────────────────── + var monthPicker = flatpickr('#month_filter', { + plugins: [ + new monthSelectPlugin({ + shorthand: true, + dateFormat: 'M Y', + altFormat: 'M Y', + theme: 'light' + }) + ], + disableMobile: true, + onChange: function() { + retrieve_dashboard(); + } + }); + monthPicker.setDate('', false); // false = don't trigger onChange + $(async function() { try { - var month = ''; show_overlays(); - - await ajax_request({ - url: "dashboard/api/engine_report/reports_stats.php", - autoPrepare: true, - checkRequired: 0, - action: 'read', - data: { "month": month }, - onSuccess: async function(res) { - - // Populate month picker - var option = ''; - var months = res.output.months || [month]; - $.each(months, function(i, m) { - var sel = (m === month) ? 'selected' : ''; - option += ``; - }); - if (!months.length) { - option = ``; - } - $('#month_filter').html(option); - - await render(res.output, month); - } - }); - + await retrieve_dashboard(); } catch(e) { console.log(e); } }); - // ── Month filter change ─────────────────────────────────────────────── - $('#month_filter').on('change', function() { - retrieve_dashboard(); - }); + diff --git a/app/ics/manage_stock_out.php b/app/ics/manage_stock_out.php index 88b766e..44ce297 100644 --- a/app/ics/manage_stock_out.php +++ b/app/ics/manage_stock_out.php @@ -358,6 +358,7 @@ // ── Boot ───────────────────────────────────────────────────────────── $(async function() { try { + await retrieve_warehouse(); await retrieve_for_edit(); } catch(e) { console.log(e); diff --git a/app/include_header.php b/app/include_header.php index b7a2d5d..d66fb79 100644 --- a/app/include_header.php +++ b/app/include_header.php @@ -30,6 +30,10 @@ + + + + diff --git a/app/include_master_sidebar.php b/app/include_master_sidebar.php new file mode 100644 index 0000000..bd0b24f --- /dev/null +++ b/app/include_master_sidebar.php @@ -0,0 +1,65 @@ + + + + \ No newline at end of file diff --git a/app/include_sidebar.php b/app/include_sidebar.php index 6d0b412..b100acf 100644 --- a/app/include_sidebar.php +++ b/app/include_sidebar.php @@ -34,32 +34,6 @@ - - - -
  • - - - Products - -
  • - -
  • - - - Warehouse Location - -
  • - -
  • - - - Contact - -
  • - + + + +
  • + + + Orders + +
  • + +
  • + + + Returns + +
  • + +
  • + + + Invoices + +
  • +