From 1d2b51e5c5a38ffb6d9fec14d4b3fe3e8d44431e Mon Sep 17 00:00:00 2001 From: ssbg04 Date: Fri, 24 Apr 2026 11:00:14 +0800 Subject: [PATCH] with authentication --- 2.html | 487 +++++++++++++++++ Dockerfile | 12 + README.md | 32 ++ assets/css/style.css | 131 +++++ assets/js/analytics.js | 151 ++++++ assets/js/app.js | 1138 +++++++++++++++++++++++++++++++++++++++ assets/js/auth.js | 112 ++++ assets/js/inventory.js | 294 ++++++++++ assets/js/main.js | 80 +++ assets/js/po.js | 336 ++++++++++++ assets/js/vendors.js | 204 +++++++ includes/api/api.php | 766 ++++++++++++++++++++++++++ includes/db/db.php | 15 + includes/footer.php | 7 + includes/header.php | 16 + index.php | 66 +++ modals/alerts_modal.php | 46 ++ modals/item_modal.php | 127 +++++ modals/po_modal.php | 83 +++ modals/vendor_modal.php | 164 ++++++ pages/analytics.php | 102 ++++ pages/inventory.php | 57 ++ pages/po.php | 58 ++ pages/vendors.php | 24 + partials/login.php | 34 ++ partials/navbar.php | 24 + 26 files changed, 4566 insertions(+) create mode 100644 2.html create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 assets/css/style.css create mode 100644 assets/js/analytics.js create mode 100644 assets/js/app.js create mode 100644 assets/js/auth.js create mode 100644 assets/js/inventory.js create mode 100644 assets/js/main.js create mode 100644 assets/js/po.js create mode 100644 assets/js/vendors.js create mode 100644 includes/api/api.php create mode 100644 includes/db/db.php create mode 100644 includes/footer.php create mode 100644 includes/header.php create mode 100644 index.php create mode 100644 modals/alerts_modal.php create mode 100644 modals/item_modal.php create mode 100644 modals/po_modal.php create mode 100644 modals/vendor_modal.php create mode 100644 pages/analytics.php create mode 100644 pages/inventory.php create mode 100644 pages/po.php create mode 100644 pages/vendors.php create mode 100644 partials/login.php create mode 100644 partials/navbar.php diff --git a/2.html b/2.html new file mode 100644 index 0000000..97850f5 --- /dev/null +++ b/2.html @@ -0,0 +1,487 @@ + + + + + + ICIS Inventory System + + + + + + + + + + + + +
+ + +
+
+ + + +
+
+

Overview

+ +
+ +
+
+
+ + + + + + + + + + + + +
Item DetailsSupplierUnit PriceStock LevelStatusAction
+
+
+
+
+ + + + +
+
+ + + + + + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b3185ee --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM php:8.2-apache + +# Install MySQL extensions +RUN docker-php-ext-install mysqli pdo pdo_mysql + +# Copy project files +COPY . /var/www/html/ + +# Enable Apache rewrite (optional but useful) +RUN a2enmod rewrite + +EXPOSE 80 \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..af09f5b --- /dev/null +++ b/README.md @@ -0,0 +1,32 @@ +# Campus Supply & Inventory Management Module + +This is the PHP/HTML prototype for the Supply and Inventory Management subsystem of the Integrated Campus Information System (ICIS). + +## 🚀 Features Included +* **Secure Authentication:** Session-based login for staff/admins. +* **Inventory Tracking:** View items, descriptions, quantities, and supplier details. +* **Automated Stock Control:** Dynamic badging that flags items when stock drops below the defined `reorder_level`. +* **Procurement Workflow:** Create Purchase Orders (POs) linked to specific suppliers and items. "Receiving" a PO automatically updates stock quantities in the `inventory_items` table. +* **Reporting & Analytics:** Dashboard with summary KPIs and a Chart.js visualization of inventory valuation by supplier. + +## 📋 Prerequisites +1. A local server environment (XAMPP, WAMP, MAMP, or equivalent). +2. PHP 7.4 or higher. +3. MySQL 8.0 or higher. + +## 🛠️ Setup Instructions + +### 1. Database Configuration +1. Open your database manager (phpMyAdmin, HeidiSQL, etc.). +2. Import the master `icis.sql` schema to create the database and tables. +3. Run the following SQL snippet to create the users table and default admin account (if not already in your schema): + ```sql + CREATE TABLE IF NOT EXISTS users ( + id INT AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(50) NOT NULL UNIQUE, + password VARCHAR(255) NOT NULL, + role ENUM('Admin', 'Staff') DEFAULT 'Staff' + ); + + INSERT INTO users (username, password, role) + VALUES ('admin', '$2y$10$2ztNQLWAfMbamxC1H0BV6e.o2YoZZSXqmbzrRJrStd15GXcDuk97m', 'Admin'); \ No newline at end of file diff --git a/assets/css/style.css b/assets/css/style.css new file mode 100644 index 0000000..a223727 --- /dev/null +++ b/assets/css/style.css @@ -0,0 +1,131 @@ +:root { + --bg-color: #f3f4f6; + --card-bg: #ffffff; + --primary-accent: #4f46e5; + --primary-hover: #4338ca; + --text-main: #1f2937; + --text-muted: #6b7280; + } + + body { + font-family: "Inter", sans-serif; + background-color: var(--bg-color); + color: var(--text-main); + padding-bottom: 2rem; + } + + /* Navbar Modernization */ + .navbar-custom { + background-color: rgba(255, 255, 255, 0.9); + backdrop-filter: blur(10px); + border-bottom: 1px solid rgba(0, 0, 0, 0.05); + position: sticky; + top: 0; + z-index: 1020; + } + + /* Sleek Cards */ + .card-custom { + background: var(--card-bg); + border: 1px solid rgba(0, 0, 0, 0.04); + border-radius: 16px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.03); + transition: transform 0.2s ease, box-shadow 0.2s ease; + } + + .kpi-card:hover { + transform: translateY(-3px); + box-shadow: 0 8px 25px rgba(0, 0, 0, 0.06); + } + + /* Modern Tabs */ + .nav-pills { + background: #e5e7eb; + padding: 0.35rem; + border-radius: 12px; + display: inline-flex; + overflow-x: auto; + white-space: nowrap; + flex-wrap: nowrap; + max-width: 100%; + } + + .nav-pills .nav-item { + margin: 0; + } + + .nav-pills .nav-link { + color: var(--text-muted); + font-weight: 500; + border-radius: 8px; + padding: 0.5rem 1.25rem; + transition: all 0.3s ease; + } + + .nav-pills .nav-link:hover { + color: var(--text-main); + } + + .nav-pills .nav-link.active { + background-color: var(--card-bg); + color: var(--primary-accent); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); + } + + /* Responsive Tables */ + .table-custom { + margin-bottom: 0; + } + + .table-custom th { + font-weight: 600; + text-transform: uppercase; + font-size: 0.75rem; + letter-spacing: 0.5px; + color: var(--text-muted); + border-bottom: 2px solid #e5e7eb; + background-color: transparent; + } + + .table-custom td { + vertical-align: middle; + padding: 1rem 0.75rem; + border-bottom: 1px solid #f3f4f6; + } + + /* Form styling */ + .form-control, .form-select { + border-radius: 10px; + border: 1px solid #d1d5db; + padding: 0.6rem 1rem; + } + + .form-control:focus, .form-select:focus { + border-color: var(--primary-accent); + box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1); + } + + /* Buttons */ + .btn-primary { + background-color: var(--primary-accent); + border-color: var(--primary-accent); + } + .btn-primary:hover { + background-color: var(--primary-hover); + border-color: var(--primary-hover); + } + + /* Mobile Specific Hiding Rules */ + @media (max-width: 768px) { + .hide-on-mobile { + display: none !important; + } + .header-actions { + flex-direction: column; + align-items: stretch !important; + gap: 1rem; + } + .header-actions button { + width: 100%; + } + } \ No newline at end of file diff --git a/assets/js/analytics.js b/assets/js/analytics.js new file mode 100644 index 0000000..8637e6f --- /dev/null +++ b/assets/js/analytics.js @@ -0,0 +1,151 @@ +// ========================================== +// ANALYTICS & EXPORTS LOGIC (analytics.js) +// ========================================== +$(document).ready(function () { + + // --- 1. Load Analytics Dashboard --- + window.loadAnalytics = function (btn = null) { + let originalText = ""; + if (btn) { + originalText = $(btn).html(); + $(btn).prop("disabled", true).html(' Refreshing...'); + } + + $.ajax({ + url: "includes/api/api.php?action=get_analytics", + type: "GET", + dataType: "json", + success: function (res) { + if (btn) $(btn).prop("disabled", false).html(originalText); + + if (res.status === "success") { + let data = res.data; + currentAnalyticsData = data; // Save globally for exports + + // KPI Cards + $("#statTotalValue").text("₱" + parseFloat(data.total_value).toLocaleString("en-US", { minimumFractionDigits: 2 })); + $("#statTotalSpend").text("₱" + parseFloat(data.total_spend).toLocaleString("en-US", { minimumFractionDigits: 2 })); + $("#statTotalItems").text(data.total_items); + $("#statTurnover").text(data.turnover_ratio + "x"); + + // Predictive List + let predHtml = ""; + if (data.predictions && data.predictions.length > 0) { + data.predictions.forEach(item => { + predHtml += `
  • +
    +
    ${item.name}
    +
    Stock: ${item.quantity} | Burns: ${parseFloat(item.daily_burn).toFixed(1)}/day
    +
    + Out in ~${item.days_left} days +
  • `; + }); + } else { + predHtml = `
  • No imminent stockouts predicted!
  • `; + } + $("#predictiveList").html(predHtml); + + // Prescriptive Action Card + $("#prescriptiveLowCount").text(data.low_stock); + if (data.low_stock > 0) { + $("#prescriptiveAutoRestockBtn").prop("disabled", false).removeClass("btn-secondary").addClass("btn-success").html(' Auto-Generate POs'); + } else { + $("#prescriptiveAutoRestockBtn").prop("disabled", true).removeClass("btn-success").addClass("btn-secondary").html(' Stock Optimal'); + } + + // Charts + let valLabels = []; let valData = []; + data.valuation_data.forEach(row => { valLabels.push(row.supplier_name); valData.push(row.supplier_value); }); + + let spendLabels = []; let spendData = []; + data.spend_by_category.forEach(row => { spendLabels.push(row.category_name); spendData.push(row.category_spend); }); + + if (window.valuationChartInstance) window.valuationChartInstance.destroy(); + if (window.spendChartInstance) window.spendChartInstance.destroy(); + + let ctxVal = document.getElementById("valuationChart"); + if(ctxVal) { + window.valuationChartInstance = new Chart(ctxVal.getContext("2d"), { + type: "bar", + data: { labels: valLabels, datasets: [{ label: "Valuation (₱)", data: valData, backgroundColor: "rgba(13, 110, 253, 0.8)", borderRadius: 4 }] }, + plugins: [ChartDataLabels], + options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, datalabels: { color: '#ffffff', anchor: 'end', align: 'bottom', font: { weight: 'bold' }, formatter: value => '₱' + parseFloat(value).toLocaleString("en-US") } } } + }); + } + + let ctxSpend = document.getElementById("spendChart"); + if (ctxSpend) { + window.spendChartInstance = new Chart(ctxSpend.getContext("2d"), { + type: "doughnut", + data: { labels: spendLabels, datasets: [{ data: spendData, backgroundColor: ["#198754", "#ffc107", "#0dcaf0", "#d63384", "#6f42c1"], borderWidth: 2, borderColor: '#ffffff' }] }, + plugins: [ChartDataLabels], + options: { responsive: true, maintainAspectRatio: false, cutout: "60%", plugins: { legend: { position: "right", labels: { padding: 15, generateLabels: function(chart) { const data = chart.data; if (data.labels.length && data.datasets.length) { return data.labels.map(function(label, i) { const meta = chart.getDatasetMeta(0); const style = meta.controller.getStyle(i); const value = data.datasets[0].data[i]; const formattedVal = '₱' + parseFloat(value).toLocaleString("en-US"); return { text: `${label} (${formattedVal})`, fillStyle: style.backgroundColor, strokeStyle: style.borderColor, lineWidth: style.borderWidth, hidden: !chart.getDataVisibility(i), index: i }; }); } return []; } } }, datalabels: { color: '#ffffff', font: { weight: 'bold', size: 11 }, formatter: value => value == 0 ? '' : '₱' + parseFloat(value).toLocaleString("en-US") } } } + }); + } + + if (btn) showAlert("Dashboard data refreshed successfully!", "success"); + } + }, + error: function () { + if (btn) $(btn).prop("disabled", false).html(originalText); + showAlert("Network error while trying to refresh data.", "danger"); + } + }); + }; + + // --- 2. Prescriptive Auto-Restock --- + $(document).on("click", "#prescriptiveAutoRestockBtn", function() { + let btn = $(this); + let originalText = btn.html(); + btn.prop("disabled", true).html(' Generating POs...'); + + $.ajax({ + url: "includes/api/api.php?action=auto_restock", + type: "POST", + dataType: "json", + success: function (res) { + if (res.status === "success") { + showAlert(res.message, "success"); + if (window.currentTab === "analytics") loadAnalytics(); + } else { + btn.prop("disabled", false).html(originalText); + showAlert(res.message, "danger"); + } + } + }); + }); + + // --- 3. EXPORT TO EXCEL --- + window.exportExcel = function() { + if (!currentAnalyticsData) { showAlert("Please wait for analytics data to load.", "warning"); return; } + const wb = XLSX.utils.book_new(); + const kpiData = [ ["Metric", "Value"], ["Total Stock Valuation", parseFloat(currentAnalyticsData.total_value)], ["Total PO Spend", parseFloat(currentAnalyticsData.total_spend)], ["Inventory Turnover Ratio", parseFloat(currentAnalyticsData.turnover_ratio)], ["Total Unique Items", parseInt(currentAnalyticsData.total_items)], ["Shortages (Low Stock)", parseInt(currentAnalyticsData.low_stock)] ]; + XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(kpiData), "KPI Summary"); + const valData = [["Supplier Name", "Stock Valuation (₱)"]]; currentAnalyticsData.valuation_data.forEach(row => { valData.push([row.supplier_name, parseFloat(row.supplier_value)]); }); XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(valData), "Valuation by Supplier"); + const spendData = [["Category/Supplier", "Total Spend (₱)"]]; currentAnalyticsData.spend_by_category.forEach(row => { spendData.push([row.category_name, parseFloat(row.category_spend)]); }); XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(spendData), "Spend by Category"); + XLSX.writeFile(wb, `Inventory_Analytics_${new Date().toISOString().split('T')[0]}.xlsx`); + }; + + // --- 4. EXPORT TO PDF --- + window.exportPDF = function() { + const { jsPDF } = window.jspdf; + const element = document.getElementById('analyticsContainer'); + const buttons = document.getElementById('analyticsButtons'); + + buttons.style.display = 'none'; + const originalWidth = element.style.width; const originalMaxWidth = element.style.maxWidth; const originalBg = element.style.backgroundColor; const originalPadding = element.style.padding; + const targetWidth = 1200; element.style.width = targetWidth + 'px'; element.style.maxWidth = targetWidth + 'px'; element.style.backgroundColor = '#f3f4f6'; element.style.padding = '20px'; + + html2canvas(element, { scale: 2, useCORS: true, logging: false, width: targetWidth, windowWidth: targetWidth }).then((canvas) => { + buttons.style.display = 'flex'; element.style.width = originalWidth; element.style.maxWidth = originalMaxWidth; element.style.backgroundColor = originalBg; element.style.padding = originalPadding; + const imgData = canvas.toDataURL('image/jpeg', 1.0); const pdf = new jsPDF('landscape', 'in', 'letter'); const pdfWidth = pdf.internal.pageSize.getWidth(); const pdfHeight = pdf.internal.pageSize.getHeight(); + const imgProps = pdf.getImageProperties(imgData); const margin = 0.3; const maxImgWidth = pdfWidth - (margin * 2); const maxImgHeight = pdfHeight - (margin * 2); + const ratio = Math.min(maxImgWidth / imgProps.width, maxImgHeight / imgProps.height); const finalWidth = imgProps.width * ratio; const finalHeight = imgProps.height * ratio; + pdf.addImage(imgData, 'JPEG', (pdfWidth - finalWidth) / 2, margin, finalWidth, finalHeight); pdf.save(`Inventory_Report_${new Date().toISOString().split('T')[0]}.pdf`); + }).catch(err => { + buttons.style.display = 'flex'; element.style.width = originalWidth; element.style.maxWidth = originalMaxWidth; element.style.backgroundColor = originalBg; element.style.padding = originalPadding; + showAlert("Failed to generate PDF.", "danger"); + }); + }; + +}); \ No newline at end of file diff --git a/assets/js/app.js b/assets/js/app.js new file mode 100644 index 0000000..77b0a10 --- /dev/null +++ b/assets/js/app.js @@ -0,0 +1,1138 @@ +$(document).ready(function () { + // ========================================== + // 1. GLOBAL VARIABLES & STATE + // ========================================== + let currentUserRole = ''; + let allInventoryData = []; + let hasCheckedLowStock = false; + + let filteredInventoryData = []; + let inventoryCurrentPage = 1; + const inventoryItemsPerPage = 5; + + let allPOData = []; + let filteredPOData = []; + let poCurrentPage = 1; + const poItemsPerPage = 5; + + let currentAnalyticsData = null; + let valuationChartInstance = null; + let spendChartInstance = null; + + // ========================================== + // 2. AUTHENTICATION LOGIC + // ========================================== + checkAuth(); + + function checkAuth() { + $.ajax({ + url: "includes/api/api.php?action=check_auth", + type: "GET", + dataType: "json", + success: function (res) { + if (res.status === "success") { + currentUserRole = res.role; + + $("#currentUser").text(res.full_name); + $("#loginSection").removeClass("d-flex").hide(); + $("#topNavbar, #dashboardSection").fadeIn(); + + // SPA Initial Load + if (!window.currentTab) { + switchTab('inventory'); + } + } else { + $("#topNavbar, #dashboardSection").hide(); + $("#loginSection").addClass("d-flex").fadeIn(); + } + }, + error: function() { + $("#topNavbar, #dashboardSection").hide(); + $("#loginSection").addClass("d-flex").fadeIn(); + } + }); + } + + let loginCooldownInterval; + $(document).on("submit", "#loginForm", function (e) { + e.preventDefault(); + let btn = $(this).find('button[type="submit"]'); + let originalText = 'Sign In '; + + btn.prop('disabled', true).html(' Authenticating...'); + + $.ajax({ + url: "includes/api/api.php?action=login", + type: "POST", + data: $(this).serialize(), + dataType: "json", + success: function (res) { + if (res.status === "success") { + btn.prop('disabled', false).html(originalText); + $("#loginForm")[0].reset(); + $("#loginAlert").empty(); + checkAuth(); + } else if (res.status === "locked") { + let timeLeft = res.remaining; + clearInterval(loginCooldownInterval); + + loginCooldownInterval = setInterval(function() { + if (timeLeft <= 0) { + clearInterval(loginCooldownInterval); + btn.prop('disabled', false).html(originalText); + $("#loginAlert").html(`
    Cooldown finished. You may try again.
    `); + } else { + btn.prop('disabled', true).html(` Locked (${timeLeft}s)`); + $("#loginAlert").html(`
    + Too many failed attempts. Try again in ${timeLeft} seconds. +
    `); + timeLeft--; + } + }, 1000); + } else { + btn.prop('disabled', false).html(originalText); + $("#loginAlert").html(`
    ${res.message}
    `); + } + }, + error: function() { + btn.prop('disabled', false).html(originalText); + $("#loginAlert").html(`
    Network error connecting to server.
    `); + } + }); + }); + + $(document).on("click", "#logoutBtn", function () { + let btn = $(this); + btn.prop('disabled', true).html(''); + + $.ajax({ + url: "includes/api/api.php?action=logout", + type: "GET", + dataType: "json", + success: function () { + btn.prop('disabled', false).html(' Logout'); + + allInventoryData = []; + allPOData = []; + hasCheckedLowStock = false; + window.currentTab = null; + + checkAuth(); + } + }); + }); + + // ========================================== + // 3. SPA LAZY LOADING ENGINE + // ========================================== + function initPageScripts(page) { + if (page === "inventory") { + loadInventory(); + loadSuppliers(); + } else if (page === "po") { + loadPOs(); + loadSuppliers(); + populatePODropdowns(); + } else if (page === "analytics") { + loadAnalytics(); + } else if (page === "vendors") { + loadVendors(); + } + } + + function loadPage(page) { + $("#pageContainer").html(` +
    +
    +
    Loading ${page}...
    +
    + `); + + $("#pageContainer").load("pages/" + page + ".php", function (response, status) { + if (status === "error") { + $("#pageContainer").html(` +
    + Failed to load ${page} module. Ensure pages/${page}.php exists. +
    + `); + } else { + initPageScripts(page); + } + }); + } + + window.switchTab = function (tab) { + window.currentTab = tab; + $(".nav-link").removeClass("active"); + $("#tab-" + tab).addClass("active"); + loadPage(tab); + }; + + // ========================================== + // 4. INVENTORY & FILTER LOGIC + // ========================================== + function loadInventory() { + $.ajax({ + url: "includes/api/api.php?action=get_inventory", + type: "GET", + cache: false, + dataType: "json", + success: function (res) { + if (res.status === "success") { + allInventoryData = res.data; + applyFilters(); + + if (!hasCheckedLowStock) { + checkAndShowLowStockAlert(res.data); + hasCheckedLowStock = true; + } + } + }, + }); + } + + function applyFilters() { + // Failsafe in case elements don't exist yet + if ($("#searchInput").length === 0) return; + + let searchTerm = $("#searchInput").val().toLowerCase(); + let supplierFilter = $("#filterSupplier").val(); + let stockFilter = $("#filterStock").val(); + + filteredInventoryData = allInventoryData.filter(function (item) { + let matchesSearch = item.name.toLowerCase().includes(searchTerm) || + item.inventory_id.toString().includes(searchTerm) || + (item.description && item.description.toLowerCase().includes(searchTerm)); + let matchesSupplier = supplierFilter === "" || item.supplier_id.toString() === supplierFilter; + let isLowStock = parseInt(item.quantity) <= parseInt(item.reorder_level); + let matchesStock = stockFilter === "" || (stockFilter === "low" && isLowStock) || (stockFilter === "healthy" && !isLowStock); + + return matchesSearch && matchesSupplier && matchesStock; + }); + + inventoryCurrentPage = 1; + renderInventoryTable(); + } + + // 🔥 DELEGATED BINDINGS FOR LAZY LOADED INPUTS + $(document).on("keyup", "#searchInput", applyFilters); + $(document).on("change", "#filterSupplier, #filterStock", applyFilters); + + function renderInventoryTable() { + let rows = ""; + let totalItems = filteredInventoryData.length; + let totalPages = Math.ceil(totalItems / inventoryItemsPerPage) || 1; + let startIndex = (inventoryCurrentPage - 1) * inventoryItemsPerPage; + let endIndex = Math.min(startIndex + inventoryItemsPerPage, totalItems); + + let pageData = filteredInventoryData.slice(startIndex, endIndex); + + if (pageData.length === 0) { + rows = `No items found matching your filters.`; + } else { + pageData.forEach(function (item) { + let isLowStock = parseInt(item.quantity) <= parseInt(item.reorder_level); + let statusBadge = isLowStock + ? ' Low Stock' + : ' Healthy'; + + let forecastHtml = ""; + let daysOut = parseInt(item.days_to_stockout); + let deleteItemBtn = (currentUserRole === 'admin') + ? `` + : ''; + + if (daysOut === 999 || item.quantity == 0) forecastHtml = ` No recent usage`; + else if (daysOut <= 7) forecastHtml = ` Out in ~${daysOut} days`; + else if (daysOut <= 21) forecastHtml = ` Out in ~${daysOut} days`; + else forecastHtml = ` Safe (>3 wks)`; + + if (item.daily_burn_rate > 0) forecastHtml += `
    Issues ${item.daily_burn_rate}/day`; + + rows += ` + +
    ${item.name}
    +
    ${item.description || "No description"}
    +
    ID: #${item.inventory_id}
    + + ${item.supplier_name || "N/A"} + ₱${parseFloat(item.unit_price).toFixed(2)} + +
    ${item.quantity} units
    +
    Reorder at ${item.reorder_level}
    + + ${forecastHtml} + ${statusBadge} + + + + ${deleteItemBtn} + + `; + }); + } + $("#inventoryTableBody").html(rows); + + if (totalItems > 0) $("#inventoryPaginationInfo").text(`Showing ${startIndex + 1} to ${endIndex} of ${totalItems} entries`); + else $("#inventoryPaginationInfo").text("Showing 0 entries"); + + renderInventoryPagination(totalPages); + } + + function renderInventoryPagination(totalPages) { + let paginationHtml = `
  • Previous
  • `; + for (let i = 1; i <= totalPages; i++) { + paginationHtml += `
  • ${i}
  • `; + } + paginationHtml += `
  • Next
  • `; + $("#inventoryPaginationControls").html(paginationHtml); + } + + window.changeInventoryPage = function(newPage) { + inventoryCurrentPage = newPage; + renderInventoryTable(); + }; + + // ========================================== + // 5. ITEM CRUD MODALS + // ========================================== + $(document).on("submit", "#addItemForm", function (e) { + e.preventDefault(); + let submitBtn = $(this).find('button[type="submit"]'); + let originalText = submitBtn.html(); + submitBtn.prop("disabled", true).html(' Saving...'); + + $.ajax({ + url: "includes/api/api.php?action=add_item", + type: "POST", + data: $(this).serialize(), + dataType: "json", + success: function (res) { + submitBtn.prop("disabled", false).html(originalText); + if (res.status === "success") { + $("#addItemModal").modal("hide"); + $("#addItemForm")[0].reset(); + showAlert(res.message, "success"); + if (window.currentTab === "inventory") loadInventory(); + } else { + showAlert(res.message, "danger"); + } + }, + error: function () { + submitBtn.prop("disabled", false).html(originalText); + showAlert("A network error occurred.", "danger"); + }, + }); + }); + + $(document).on("click", ".edit-btn", function () { + $("#edit_supplier_id").html($("#supplierSelect").html()); + let btn = $(this); + $("#edit_inventory_id").val(btn.data("id")); + $("#edit_name").val(btn.data("name")); + $("#edit_description").val(btn.data("desc")); + $("#edit_quantity").val(btn.data("qty")); + $("#edit_unit_price").val(btn.data("price")); + $("#edit_reorder_level").val(btn.data("reorder")); + $("#edit_supplier_id").val(btn.data("supplier")); + $("#editItemModal").modal("show"); + }); + + $(document).on("submit", "#editItemForm", function (e) { + e.preventDefault(); + let submitBtn = $(this).find('button[type="submit"]'); + let originalText = submitBtn.html(); + submitBtn.prop("disabled", true).html(' Updating...'); + + $.ajax({ + url: "includes/api/api.php?action=update_item", + type: "POST", + data: $(this).serialize(), + dataType: "json", + success: function (res) { + submitBtn.prop("disabled", false).html(originalText); + if (res.status === "success") { + $("#editItemModal").modal("hide"); + showAlert(res.message, "success"); + if (window.currentTab === "inventory") loadInventory(); + } else { + showAlert(res.message, "danger"); + } + }, + }); + }); + + $(document).on("click", ".delete-btn", function () { + let itemId = $(this).data("id"); + if (confirm("Are you sure you want to delete this item? This cannot be undone.")) { + $.ajax({ + url: "includes/api/api.php?action=delete_item", + type: "POST", + data: { inventory_id: itemId }, + dataType: "json", + success: function (res) { + if (res.status === "success") { + showAlert(res.message, "success"); + if (window.currentTab === "inventory") loadInventory(); + } else { + showAlert(res.message, "danger"); + } + }, + }); + } + }); + + $(document).on("click", ".issue-btn", function () { + let btn = $(this); + $("#issue_inventory_id").val(btn.data("id")); + $("#issue_item_name").text(btn.data("name")); + $("#issue_qty").val(1); + $("#issueItemModal").modal("show"); + }); + + $(document).on("submit", "#issueItemForm", function (e) { + e.preventDefault(); + let submitBtn = $(this).find('button[type="submit"]'); + let originalText = submitBtn.html(); + submitBtn.prop("disabled", true).html(' Processing...'); + + $.ajax({ + url: "includes/api/api.php?action=issue_item", + type: "POST", + data: $(this).serialize(), + dataType: "json", + success: function (res) { + submitBtn.prop("disabled", false).html(originalText); + if (res.status === "success") { + $("#issueItemModal").modal("hide"); + showAlert(res.message, "success"); + if (window.currentTab === "inventory") loadInventory(); + } else { + showAlert(res.message, "danger"); + } + }, + }); + }); + + // ========================================== + // 6. LOW STOCK NOTIFICATION ENGINE + // ========================================== + function checkAndShowLowStockAlert(inventoryData) { + let lowStockItems = inventoryData.filter(item => parseInt(item.quantity) <= parseInt(item.reorder_level)); + + if (lowStockItems.length > 0) { + let rows = ""; + lowStockItems.forEach(item => { + rows += ` + ${item.name} + ${item.quantity} + ${item.reorder_level} + ${item.supplier_name || "No Supplier"} + `; + }); + $("#lowStockTableBody").html(rows); + setTimeout(() => { $("#lowStockModal").modal("show"); }, 500); + } + } + + $(document).on("click", "#manualRestockBtn", function() { + $("#lowStockModal").modal("hide"); + switchTab('po'); + }); + + $(document).on("click", "#autoRestockBtn", function() { + let btn = $(this); + let originalText = btn.html(); + btn.prop("disabled", true).html(' Processing...'); + + $.ajax({ + url: "includes/api/api.php?action=auto_restock", + type: "POST", + dataType: "json", + success: function (res) { + btn.prop("disabled", false).html(originalText); + $("#lowStockModal").modal("hide"); + if (res.status === "success") { + showAlert(res.message, "success"); + switchTab('po'); + } else { + showAlert(res.message, "danger"); + } + }, + error: function() { + btn.prop("disabled", false).html(originalText); + showAlert("Network error during auto-restock.", "danger"); + } + }); + }); + + // ========================================== + // 7. VENDORS LOGIC + // ========================================== + function loadSuppliers() { + $.ajax({ + url: "includes/api/api.php?action=get_suppliers", + type: "GET", + dataType: "json", + success: function (res) { + if (res.status === "success") { + let options = ''; + let filterOptions = ''; + res.data.forEach(function (sup) { + options += ``; + filterOptions += ``; + }); + $("#supplierSelect").html(options); + $("#filterSupplier").html(filterOptions); + $("#filterPoSupplier").html(filterOptions); + + if ($("#poSupplierSelect").length) { + $("#poSupplierSelect").html(options); + $("#poItemSelect").html(''); + } + } + }, + }); + } + + function loadVendors() { + $.ajax({ + url: "includes/api/api.php?action=get_suppliers", + type: "GET", + dataType: "json", + success: function (res) { + if (res.status === "success") { + let rows = ""; + res.data.forEach(function (vendor) { + let deleteVendorBtn = (currentUserRole === 'admin') + ? `` + : ''; + + rows += ` + +
    ${vendor.name}
    +
    ${vendor.contact_person || "N/A"}
    + + +
    ${vendor.email || "N/A"}
    +
    ${vendor.phone || "N/A"}
    + + + + ${deleteVendorBtn} + + + `; + }); + $("#vendorTableBody").html(rows); + } + }, + }); + } + + $(document).on("click", ".edit-vendor-btn", function () { + let btn = $(this); + $("#edit_vendor_id").val(btn.data("id")); + $("#edit_vendor_name").val(btn.data("name")); + $("#edit_vendor_contact_person").val(btn.data("person")); + $("#edit_vendor_phone").val(btn.data("phone")); + $("#edit_vendor_email").val(btn.data("email")); + $("#edit_vendor_address").val(btn.data("address")); + $("#editVendorModal").modal("show"); + }); + + $(document).on("submit", "#addVendorForm", function (e) { + e.preventDefault(); + let submitBtn = $(this).find('button[type="submit"]'); + let originalText = submitBtn.html(); + submitBtn.prop("disabled", true).html(' Saving...'); + + $.ajax({ + url: "includes/api/api.php?action=add_supplier", + type: "POST", + data: $(this).serialize(), + dataType: "json", + success: function (res) { + submitBtn.prop("disabled", false).html(originalText); + if (res.status === "success") { + $("#addVendorModal").modal("hide"); + $("#addVendorForm")[0].reset(); + showAlert(res.message, "success"); + if (window.currentTab === "vendors") loadVendors(); + loadSuppliers(); + } else { + showAlert(res.message, "danger"); + } + } + }); + }); + + $(document).on("submit", "#editVendorForm", function (e) { + e.preventDefault(); + let submitBtn = $(this).find('button[type="submit"]'); + let originalText = submitBtn.html(); + submitBtn.prop("disabled", true).html(' Updating...'); + + $.ajax({ + url: "includes/api/api.php?action=update_supplier", + type: "POST", + data: $(this).serialize(), + dataType: "json", + success: function (res) { + submitBtn.prop("disabled", false).html(originalText); + if (res.status === "success") { + $("#editVendorModal").modal("hide"); + showAlert(res.message, "success"); + if (window.currentTab === "vendors") loadVendors(); + loadSuppliers(); + } else { + showAlert(res.message, "danger"); + } + } + }); + }); + + $(document).on("click", ".delete-vendor-btn", function () { + let supplierId = $(this).data("id"); + if (confirm("Are you sure you want to delete this vendor? This will fail if they have active inventory items or purchase orders.")) { + $.ajax({ + url: "includes/api/api.php?action=delete_supplier", + type: "POST", + data: { supplier_id: supplierId }, + dataType: "json", + success: function (res) { + if (res.status === "success") { + showAlert(res.message, "success"); + if (window.currentTab === "vendors") loadVendors(); + loadSuppliers(); + } else { + showAlert(res.message, "danger"); + } + } + }); + } + }); + + $(document).on("click", ".view-history-btn", function () { + let supplierId = $(this).data("id"); + let supplierName = $(this).data("name"); + + $("#historyVendorName").text(supplierName); + $("#vendorHistoryTableBody").html(' Loading history...'); + $("#vendorHistoryModal").modal("show"); + + $.ajax({ + url: "includes/api/api.php?action=get_vendor_history&supplier_id=" + supplierId, + type: "GET", + dataType: "json", + success: function (res) { + if (res.status === "success") { + let rows = ""; + if (res.data.length === 0) { + rows = `No procurement history found for this vendor.`; + } else { + res.data.forEach(function (po) { + let statusClass = po.status === "Received" ? "text-success" : (po.status === "Cancelled" ? "text-danger" : "text-warning"); + rows += ` + #PO-${po.po_id} + ${po.order_date} + ₱${parseFloat(po.total_amount).toFixed(2)} + ${po.status} + + + + `; + }); + } + $("#vendorHistoryTableBody").html(rows); + } else { + $("#vendorHistoryTableBody").html(`Error loading data.`); + } + } + }); + }); + + // ========================================== + // 8. PURCHASE ORDERS LOGIC + // ========================================== + function loadPOs() { + $.ajax({ + url: "includes/api/api.php?action=get_pos", + type: "GET", + cache: false, + dataType: "json", + success: function (res) { + if (res.status === "success") { + allPOData = res.data; + applyPOFilters(); + } + } + }); + } + + function applyPOFilters() { + // Failsafe in case elements don't exist yet + if ($("#filterPoDateFrom").length === 0) return; + + let dateFrom = $("#filterPoDateFrom").val(); + let dateTo = $("#filterPoDateTo").val(); + let supplierFilter = $("#filterPoSupplier").val(); + + filteredPOData = allPOData.filter(function (po) { + let matchesDateFrom = dateFrom === "" || po.order_date >= dateFrom; + let matchesDateTo = dateTo === "" || po.order_date <= dateTo; + let matchesSupplier = supplierFilter === "" || po.supplier_id.toString() === supplierFilter; + return matchesDateFrom && matchesDateTo && matchesSupplier; + }); + + poCurrentPage = 1; + renderPOTable(); + } + + // 🔥 DELEGATED BINDINGS FOR LAZY LOADED INPUTS + $(document).on("change", "#filterPoDateFrom, #filterPoDateTo, #filterPoSupplier", applyPOFilters); + + function renderPOTable() { + let rows = ""; + let totalItems = filteredPOData.length; + let totalPages = Math.ceil(totalItems / poItemsPerPage) || 1; + let startIndex = (poCurrentPage - 1) * poItemsPerPage; + let endIndex = Math.min(startIndex + poItemsPerPage, totalItems); + let pageData = filteredPOData.slice(startIndex, endIndex); + + if (pageData.length === 0) { + rows = `No purchase orders found matching your filters.`; + } else { + pageData.forEach(function (po) { + let statusDisplay = ""; + let role = currentUserRole.toLowerCase(); + + if (po.status === "Pending") { + // 1. PENDING: Only Admins can approve or cancel + if (role === 'admin') { + statusDisplay = ` + `; + } else { + statusDisplay = ' Pending Finance'; + } + } + else if (po.status === "Approved") { + // 2. APPROVED: ANY user (Admin or Employee) can Receive or report an Issue + statusDisplay = ` + `; + } + else if (po.status === "Missing") { + // 3. MISSING: Locked! Static badge, no longer a dropdown. + statusDisplay = ' Missing'; + } + else if (po.status === "Issues") { + // 4. ISSUES: Locked! Static badge, no longer a dropdown. + statusDisplay = ' Issues Reported'; + } + else if (po.status === "Received") { + // 5. RECEIVED: Locked! + statusDisplay = ' Received'; + } + else { + // 6. CANCELLED / OTHER: Locked! + statusDisplay = ` ${po.status}`; + } + + let editBtn = po.status === "Pending" ? `
    ` : ""; + + rows += ` + #PO-${po.po_id} + ${po.order_date} + ${po.supplier_name} + +
    ${po.item_name || "No Item Data"}
    +
    Qty: ${po.item_qty || 0} @ ₱${parseFloat(po.unit_price).toFixed(2)} ${editBtn}
    + + ₱${parseFloat(po.total_amount).toFixed(2)} + ${statusDisplay} + `; + }); + } + + $("#poTableBody").html(rows); + if (totalItems > 0) $("#poPaginationInfo").text(`Showing ${startIndex + 1} to ${endIndex} of ${totalItems} entries`); + else $("#poPaginationInfo").text("Showing 0 entries"); + renderPOPagination(totalPages); + } + + function renderPOPagination(totalPages) { + let paginationHtml = `
  • Previous
  • `; + for (let i = 1; i <= totalPages; i++) { + paginationHtml += `
  • ${i}
  • `; + } + paginationHtml += `
  • Next
  • `; + $("#poPaginationControls").html(paginationHtml); + } + + window.changePOPage = function(newPage) { + poCurrentPage = newPage; + renderPOTable(); + }; + + $(document).on("click", ".edit-po-qty-btn", function () { + let poId = $(this).data("id"); + let currentQty = $(this).data("qty"); + let unitPrice = parseFloat($(this).data("price")); + + $("#edit_po_id").val(poId); + $("#edit_po_unit_price").val(unitPrice); + $("#edit_po_unit_price_display").text("₱" + unitPrice.toFixed(2)); + $("#edit_po_qty").val(currentQty); + $("#edit_po_total_display").text("₱" + (currentQty * unitPrice).toFixed(2)); + $("#editPoQtyModal").modal("show"); + }); + + $(document).on("input", "#edit_po_qty", function() { + let qty = parseInt($(this).val()) || 0; + let price = parseFloat($("#edit_po_unit_price").val()); + $("#edit_po_total_display").text("₱" + (qty * price).toFixed(2)); + }); + + $(document).on("submit", "#editPoQtyForm", function (e) { + e.preventDefault(); + let submitBtn = $(this).find('button[type="submit"]'); + let originalText = submitBtn.html(); + submitBtn.prop("disabled", true).html(' Updating...'); + + $.ajax({ + url: "includes/api/api.php?action=update_po_qty", + type: "POST", + data: $(this).serialize(), + dataType: "json", + success: function (res) { + submitBtn.prop("disabled", false).html(originalText); + if (res.status === "success") { + $("#editPoQtyModal").modal("hide"); + showAlert(res.message, "success"); + if (window.currentTab === "po") loadPOs(); + } else { + showAlert(res.message, "danger"); + } + } + }); + }); + + function populatePODropdowns() { + $("#poSupplierSelect").html($("#supplierSelect").html()); + $("#poItemSelect").html(''); + } + + $(document).on("change", "#poItemSelect", function () { + let selectedOption = $(this).find("option:selected"); + let itemPrice = selectedOption.data("price"); + let priceInput = $('#createPOForm input[name="unit_price"]'); + if (itemPrice !== undefined) priceInput.val(itemPrice); + else priceInput.val(""); + }); + + $(document).on("change", "#poSupplierSelect", function () { + let supplier_id = $(this).val(); + let itemSelect = $("#poItemSelect"); + + if (!supplier_id) { + itemSelect.html(''); + return; + } + itemSelect.html(''); + + $.ajax({ + url: "includes/api/api.php?action=get_supplier_items&supplier_id=" + supplier_id, + type: "GET", + dataType: "json", + success: function (res) { + if (res.status === "success") { + if (res.data.length === 0) { + itemSelect.html(''); + } else { + let options = ''; + res.data.forEach(function (item) { + options += ``; + }); + itemSelect.html(options); + } + } + }, + }); + }); + + $(document).on("submit", "#createPOForm", function (e) { + e.preventDefault(); + $.ajax({ + url: "includes/api/api.php?action=create_po", + type: "POST", + data: $(this).serialize(), + dataType: "json", + success: function (res) { + if (res.status === "success") { + $("#createPOModal").modal("hide"); + $("#createPOForm")[0].reset(); + showAlert(res.message, "success"); + if (window.currentTab === "po") loadPOs(); + } else { + showAlert(res.message, "danger"); + } + }, + }); + }); + + $(document).on("change", ".status-dropdown", function () { + let selectElement = $(this); + let po_id = selectElement.data("id"); + let currentStatus = selectElement.data("current"); + let newStatus = selectElement.val(); + + if (newStatus === "Received") { + if (confirm("Are you sure you want to mark this as RECEIVED? This will officially add stock to your inventory and generate a Finance invoice. This cannot be undone.")) { + selectElement.prop("disabled", true); + $.ajax({ + url: "includes/api/api.php?action=receive_po", + type: "POST", + dataType: "json", + data: { po_id: po_id }, + success: function (res) { + showAlert(res.message, res.status === "success" ? "success" : "danger"); + if (window.currentTab === "po") loadPOs(); + } + }); + } else selectElement.val(currentStatus); + } else if (newStatus === "Cancelled") { + if (confirm("Are you sure you want to CANCEL this Purchase Order?")) { + $.ajax({ + url: "includes/api/api.php?action=cancel_po", + type: "POST", + dataType: "json", + data: { po_id: po_id }, + success: function (res) { + showAlert(res.message, res.status === "success" ? "success" : "danger"); + if (window.currentTab === "po") loadPOs(); + } + }); + } else selectElement.val(currentStatus); + } else { + $.ajax({ + url: "includes/api/api.php?action=update_po_status", + type: "POST", + dataType: "json", + data: { po_id: po_id, status: newStatus }, + success: function (res) { + showAlert(res.message, res.status === "success" ? "success" : "danger"); + if (res.status === "success") { + if (window.currentTab === "po") loadPOs(); + } else { + selectElement.val(currentStatus); + } + }, + }); + } + }); + + $(document).on("click", ".view-po-details-btn", function () { + let poId = $(this).data("id"); + $("#detailsPoId").text("#PO-" + poId); + $("#poDetailsTableBody").html(' Loading items...'); + $("#vendorHistoryModal").modal("hide"); + $("#poDetailsModal").modal("show"); + + $.ajax({ + url: "includes/api/api.php?action=get_po_details&po_id=" + poId, + type: "GET", + dataType: "json", + success: function (res) { + if (res.status === "success") { + let rows = ""; + if (res.data.length === 0) { + rows = `No items found for this PO.`; + } else { + res.data.forEach(function (item) { + let subtotal = item.quantity * item.unit_price; + rows += ` + ${item.name} + ${item.quantity} + ₱${parseFloat(item.unit_price).toFixed(2)} + ₱${subtotal.toFixed(2)} + `; + }); + } + $("#poDetailsTableBody").html(rows); + } else { + $("#poDetailsTableBody").html(`Error loading details.`); + } + }, + error: function() { + $("#poDetailsTableBody").html(`Network error. Check connection.`); + } + }); + }); + + // ========================================== + // 9. ANALYTICS LOGIC + // ========================================== + window.loadAnalytics = function (btn = null) { + let originalText = ""; + if (btn) { + originalText = $(btn).html(); + $(btn).prop("disabled", true).html(' Refreshing...'); + } + + $.ajax({ + url: "includes/api/api.php?action=get_analytics", + type: "GET", + dataType: "json", + success: function (res) { + if (btn) $(btn).prop("disabled", false).html(originalText); + + if (res.status === "success") { + let data = res.data; + currentAnalyticsData = data; + + $("#statTotalValue").text("₱" + parseFloat(data.total_value).toLocaleString("en-US", { minimumFractionDigits: 2 })); + $("#statTotalSpend").text("₱" + parseFloat(data.total_spend).toLocaleString("en-US", { minimumFractionDigits: 2 })); + $("#statTotalItems").text(data.total_items); + $("#statTurnover").text(data.turnover_ratio + "x"); + + let predHtml = ""; + if (data.predictions && data.predictions.length > 0) { + data.predictions.forEach(item => { + predHtml += `
  • +
    +
    ${item.name}
    +
    Stock: ${item.quantity} | Burns: ${parseFloat(item.daily_burn).toFixed(1)}/day
    +
    + Out in ~${item.days_left} days +
  • `; + }); + } else { + predHtml = `
  • No imminent stockouts predicted!
  • `; + } + $("#predictiveList").html(predHtml); + + $("#prescriptiveLowCount").text(data.low_stock); + if (data.low_stock > 0) { + $("#prescriptiveAutoRestockBtn").prop("disabled", false).removeClass("btn-secondary").addClass("btn-success"); + } else { + $("#prescriptiveAutoRestockBtn").prop("disabled", true).removeClass("btn-success").addClass("btn-secondary").html(' Stock Optimal'); + } + + let valLabels = []; let valData = []; + data.valuation_data.forEach(row => { valLabels.push(row.supplier_name); valData.push(row.supplier_value); }); + + let spendLabels = []; let spendData = []; + data.spend_by_category.forEach(row => { spendLabels.push(row.category_name); spendData.push(row.category_spend); }); + + if (window.valuationChartInstance) window.valuationChartInstance.destroy(); + if (window.spendChartInstance) window.spendChartInstance.destroy(); + + let ctxVal = document.getElementById("valuationChart"); + if(ctxVal) { + window.valuationChartInstance = new Chart(ctxVal.getContext("2d"), { + type: "bar", + data: { labels: valLabels, datasets: [{ label: "Valuation (₱)", data: valData, backgroundColor: "rgba(13, 110, 253, 0.8)", borderRadius: 4 }] }, + plugins: [ChartDataLabels], + options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, datalabels: { color: '#ffffff', anchor: 'end', align: 'bottom', font: { weight: 'bold' }, formatter: value => '₱' + parseFloat(value).toLocaleString("en-US") } } } + }); + } + + let ctxSpend = document.getElementById("spendChart"); + if (ctxSpend) { + window.spendChartInstance = new Chart(ctxSpend.getContext("2d"), { + type: "doughnut", + data: { labels: spendLabels, datasets: [{ data: spendData, backgroundColor: ["#198754", "#ffc107", "#0dcaf0", "#d63384", "#6f42c1"], borderWidth: 2, borderColor: '#ffffff' }] }, + plugins: [ChartDataLabels], + options: { responsive: true, maintainAspectRatio: false, cutout: "60%", plugins: { legend: { position: "right", labels: { padding: 15, generateLabels: function(chart) { const data = chart.data; if (data.labels.length && data.datasets.length) { return data.labels.map(function(label, i) { const meta = chart.getDatasetMeta(0); const style = meta.controller.getStyle(i); const value = data.datasets[0].data[i]; const formattedVal = '₱' + parseFloat(value).toLocaleString("en-US"); return { text: `${label} (${formattedVal})`, fillStyle: style.backgroundColor, strokeStyle: style.borderColor, lineWidth: style.borderWidth, hidden: !chart.getDataVisibility(i), index: i }; }); } return []; } } }, datalabels: { color: '#ffffff', font: { weight: 'bold', size: 11 }, formatter: value => value == 0 ? '' : '₱' + parseFloat(value).toLocaleString("en-US") } } } + }); + } + + if (btn) showAlert("Dashboard data refreshed successfully!", "success"); + } + }, + error: function () { + if (btn) $(btn).prop("disabled", false).html(originalText); + showAlert("Network error while trying to refresh data.", "danger"); + } + }); + }; + + $(document).on("click", "#prescriptiveAutoRestockBtn", function() { + let btn = $(this); + let originalText = btn.html(); + btn.prop("disabled", true).html(' Generating POs...'); + + $.ajax({ + url: "includes/api/api.php?action=auto_restock", + type: "POST", + dataType: "json", + success: function (res) { + if (res.status === "success") { + showAlert(res.message, "success"); + if (window.currentTab === "analytics") loadAnalytics(); + } else { + btn.prop("disabled", false).html(originalText); + showAlert(res.message, "danger"); + } + } + }); + }); + + // ========================================== + // 10. HELPER EXPORTS & ALERTS + // ========================================== + function showAlert(msg, type) { + $("#alertBox").html(`
    + ${msg} + +
    `); + } + + window.exportExcel = function() { + if (!currentAnalyticsData) { showAlert("Please wait for analytics data to load.", "warning"); return; } + const wb = XLSX.utils.book_new(); + const kpiData = [ ["Metric", "Value"], ["Total Stock Valuation", parseFloat(currentAnalyticsData.total_value)], ["Total PO Spend", parseFloat(currentAnalyticsData.total_spend)], ["Inventory Turnover Ratio", parseFloat(currentAnalyticsData.turnover_ratio)], ["Total Unique Items", parseInt(currentAnalyticsData.total_items)], ["Shortages (Low Stock)", parseInt(currentAnalyticsData.low_stock)], ["Overstock Alerts", parseInt(currentAnalyticsData.overstock)] ]; + XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(kpiData), "KPI Summary"); + const valData = [["Supplier Name", "Stock Valuation (₱)"]]; currentAnalyticsData.valuation_data.forEach(row => { valData.push([row.supplier_name, parseFloat(row.supplier_value)]); }); XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(valData), "Valuation by Supplier"); + const spendData = [["Category/Supplier", "Total Spend (₱)"]]; currentAnalyticsData.spend_by_category.forEach(row => { spendData.push([row.category_name, parseFloat(row.category_spend)]); }); XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(spendData), "Spend by Category"); + XLSX.writeFile(wb, `Inventory_Analytics_${new Date().toISOString().split('T')[0]}.xlsx`); + }; + + window.exportPDF = function() { + const { jsPDF } = window.jspdf; const element = document.getElementById('analyticsContainer'); const buttons = document.getElementById('analyticsButtons'); + buttons.style.display = 'none'; const originalWidth = element.style.width; const originalMaxWidth = element.style.maxWidth; const originalBg = element.style.backgroundColor; const originalPadding = element.style.padding; + const targetWidth = 1200; element.style.width = targetWidth + 'px'; element.style.maxWidth = targetWidth + 'px'; element.style.backgroundColor = '#f3f4f6'; element.style.padding = '20px'; + html2canvas(element, { scale: 2, useCORS: true, logging: false, width: targetWidth, windowWidth: targetWidth }).then((canvas) => { + buttons.style.display = 'flex'; element.style.width = originalWidth; element.style.maxWidth = originalMaxWidth; element.style.backgroundColor = originalBg; element.style.padding = originalPadding; + const imgData = canvas.toDataURL('image/jpeg', 1.0); const pdf = new jsPDF('landscape', 'in', 'letter'); const pdfWidth = pdf.internal.pageSize.getWidth(); const pdfHeight = pdf.internal.pageSize.getHeight(); + const imgProps = pdf.getImageProperties(imgData); const margin = 0.3; const maxImgWidth = pdfWidth - (margin * 2); const maxImgHeight = pdfHeight - (margin * 2); + const ratio = Math.min(maxImgWidth / imgProps.width, maxImgHeight / imgProps.height); const finalWidth = imgProps.width * ratio; const finalHeight = imgProps.height * ratio; + pdf.addImage(imgData, 'JPEG', (pdfWidth - finalWidth) / 2, margin, finalWidth, finalHeight); pdf.save(`Inventory_Report_${new Date().toISOString().split('T')[0]}.pdf`); + }).catch(err => { + buttons.style.display = 'flex'; element.style.width = originalWidth; element.style.maxWidth = originalMaxWidth; element.style.backgroundColor = originalBg; element.style.padding = originalPadding; + showAlert("Failed to generate PDF.", "danger"); + }); + }; +}); \ No newline at end of file diff --git a/assets/js/auth.js b/assets/js/auth.js new file mode 100644 index 0000000..e9413d8 --- /dev/null +++ b/assets/js/auth.js @@ -0,0 +1,112 @@ +// ========================================== +// AUTHENTICATION LOGIC (auth.js) +// ========================================== +$(document).ready(function () { + + // 1. Check auth immediately when the page loads + checkAuth(); + + function checkAuth() { + $.ajax({ + url: "includes/api/api.php?action=check_auth", + type: "GET", + dataType: "json", + success: function (res) { + if (res.status === "success") { + // Grab global variable from main.js + currentUserRole = res.role; + + $("#currentUser").text(res.full_name); + $("#loginSection").removeClass("d-flex").hide(); + $("#topNavbar, #dashboardSection").fadeIn(); + + // SPA Initial Load: If no tab is active, load inventory + if (!window.currentTab) { + switchTab('inventory'); + } + } else { + $("#topNavbar, #dashboardSection").hide(); + $("#loginSection").addClass("d-flex").fadeIn(); + } + }, + error: function() { + $("#topNavbar, #dashboardSection").hide(); + $("#loginSection").addClass("d-flex").fadeIn(); + } + }); + } + + // 2. Handle Login Form & Brute Force Cooldown Timer + let loginCooldownInterval; + + $(document).on("submit", "#loginForm", function (e) { + e.preventDefault(); + let btn = $(this).find('button[type="submit"]'); + let originalText = 'Sign In '; + + btn.prop('disabled', true).html(' Authenticating...'); + + $.ajax({ + url: "includes/api/api.php?action=login", + type: "POST", + data: $(this).serialize(), + dataType: "json", + success: function (res) { + if (res.status === "success") { + btn.prop('disabled', false).html(originalText); + $("#loginForm")[0].reset(); + $("#loginAlert").empty(); + checkAuth(); + } else if (res.status === "locked") { + let timeLeft = res.remaining; + clearInterval(loginCooldownInterval); + + loginCooldownInterval = setInterval(function() { + if (timeLeft <= 0) { + clearInterval(loginCooldownInterval); + btn.prop('disabled', false).html(originalText); + $("#loginAlert").html(`
    Cooldown finished. You may try again.
    `); + } else { + btn.prop('disabled', true).html(` Locked (${timeLeft}s)`); + $("#loginAlert").html(`
    + Too many failed attempts. Try again in ${timeLeft} seconds. +
    `); + timeLeft--; + } + }, 1000); + } else { + btn.prop('disabled', false).html(originalText); + $("#loginAlert").html(`
    ${res.message}
    `); + } + }, + error: function() { + btn.prop('disabled', false).html(originalText); + $("#loginAlert").html(`
    Network error connecting to server.
    `); + } + }); + }); + + // 3. Handle Logout Button + $(document).on("click", "#logoutBtn", function () { + let btn = $(this); + btn.prop('disabled', true).html(''); + + $.ajax({ + url: "includes/api/api.php?action=logout", + type: "GET", + dataType: "json", + success: function () { + btn.prop('disabled', false).html(' Logout'); + + // Wipe global data from main.js so the next person can't inspect element to see it + allInventoryData = []; + allPOData = []; + hasCheckedLowStock = false; + window.currentTab = null; + + checkAuth(); // Hides dashboard and shows login screen + } + }); + }); + +}); \ No newline at end of file diff --git a/assets/js/inventory.js b/assets/js/inventory.js new file mode 100644 index 0000000..94e3d18 --- /dev/null +++ b/assets/js/inventory.js @@ -0,0 +1,294 @@ +// ========================================== +// INVENTORY LOGIC (inventory.js) +// ========================================== +$(document).ready(function () { + + // --- 1. Load Data --- + window.loadInventory = function() { + $.ajax({ + url: "includes/api/api.php?action=get_inventory", + type: "GET", + cache: false, + dataType: "json", + success: function (res) { + if (res.status === "success") { + allInventoryData = res.data; + applyFilters(); + + // Low stock alert check + if (!hasCheckedLowStock) { + checkAndShowLowStockAlert(res.data); + hasCheckedLowStock = true; + } + } + }, + }); + }; + + // --- 2. Filtering --- + function applyFilters() { + if ($("#searchInput").length === 0) return; // Failsafe if DOM isn't ready + + let searchTerm = $("#searchInput").val().toLowerCase(); + let supplierFilter = $("#filterSupplier").val(); + let stockFilter = $("#filterStock").val(); + + filteredInventoryData = allInventoryData.filter(function (item) { + let matchesSearch = item.name.toLowerCase().includes(searchTerm) || + item.inventory_id.toString().includes(searchTerm) || + (item.description && item.description.toLowerCase().includes(searchTerm)); + let matchesSupplier = supplierFilter === "" || item.supplier_id.toString() === supplierFilter; + let isLowStock = parseInt(item.quantity) <= parseInt(item.reorder_level); + let matchesStock = stockFilter === "" || (stockFilter === "low" && isLowStock) || (stockFilter === "healthy" && !isLowStock); + + return matchesSearch && matchesSupplier && matchesStock; + }); + + inventoryCurrentPage = 1; + renderInventoryTable(); + } + + $(document).on("keyup", "#searchInput", applyFilters); + $(document).on("change", "#filterSupplier, #filterStock", applyFilters); + + // --- 3. Rendering --- + function renderInventoryTable() { + let rows = ""; + let totalItems = filteredInventoryData.length; + let totalPages = Math.ceil(totalItems / inventoryItemsPerPage) || 1; + let startIndex = (inventoryCurrentPage - 1) * inventoryItemsPerPage; + let endIndex = Math.min(startIndex + inventoryItemsPerPage, totalItems); + + let pageData = filteredInventoryData.slice(startIndex, endIndex); + + if (pageData.length === 0) { + rows = `No items found matching your filters.`; + } else { + pageData.forEach(function (item) { + let isLowStock = parseInt(item.quantity) <= parseInt(item.reorder_level); + let statusBadge = isLowStock + ? ' Low Stock' + : ' Healthy'; + + let forecastHtml = ""; + let daysOut = parseInt(item.days_to_stockout); + let deleteItemBtn = (currentUserRole === 'admin') + ? `` + : ''; + + if (daysOut === 999 || item.quantity == 0) forecastHtml = ` No recent usage`; + else if (daysOut <= 7) forecastHtml = ` Out in ~${daysOut} days`; + else if (daysOut <= 21) forecastHtml = ` Out in ~${daysOut} days`; + else forecastHtml = ` Safe (>3 wks)`; + + if (item.daily_burn_rate > 0) forecastHtml += `
    Issues ${item.daily_burn_rate}/day`; + + rows += ` + +
    ${item.name}
    +
    ${item.description || "No description"}
    +
    ID: #${item.inventory_id}
    + + ${item.supplier_name || "N/A"} + ₱${parseFloat(item.unit_price).toFixed(2)} + +
    ${item.quantity} units
    +
    Reorder at ${item.reorder_level}
    + + ${forecastHtml} + ${statusBadge} + + + + ${deleteItemBtn} + + `; + }); + } + $("#inventoryTableBody").html(rows); + + if (totalItems > 0) $("#inventoryPaginationInfo").text(`Showing ${startIndex + 1} to ${endIndex} of ${totalItems} entries`); + else $("#inventoryPaginationInfo").text("Showing 0 entries"); + + renderInventoryPagination(totalPages); + } + + function renderInventoryPagination(totalPages) { + let paginationHtml = `
  • Previous
  • `; + for (let i = 1; i <= totalPages; i++) { + paginationHtml += `
  • ${i}
  • `; + } + paginationHtml += `
  • Next
  • `; + $("#inventoryPaginationControls").html(paginationHtml); + } + + window.changeInventoryPage = function(newPage) { + inventoryCurrentPage = newPage; + renderInventoryTable(); + }; + + // --- 4. CRUD Operations --- + $(document).on("submit", "#addItemForm", function (e) { + e.preventDefault(); + let submitBtn = $(this).find('button[type="submit"]'); + let originalText = submitBtn.html(); + submitBtn.prop("disabled", true).html(' Saving...'); + + $.ajax({ + url: "includes/api/api.php?action=add_item", + type: "POST", + data: $(this).serialize(), + dataType: "json", + success: function (res) { + submitBtn.prop("disabled", false).html(originalText); + if (res.status === "success") { + $("#addItemModal").modal("hide"); + $("#addItemForm")[0].reset(); + showAlert(res.message, "success"); + if (window.currentTab === "inventory") loadInventory(); + } else { + showAlert(res.message, "danger"); + } + } + }); + }); + + $(document).on("click", ".edit-btn", function () { + $("#edit_supplier_id").html($("#supplierSelect").html()); // Copy current suppliers + let btn = $(this); + $("#edit_inventory_id").val(btn.data("id")); + $("#edit_name").val(btn.data("name")); + $("#edit_description").val(btn.data("desc")); + $("#edit_quantity").val(btn.data("qty")); + $("#edit_unit_price").val(btn.data("price")); + $("#edit_reorder_level").val(btn.data("reorder")); + $("#edit_supplier_id").val(btn.data("supplier")); + $("#editItemModal").modal("show"); + }); + + $(document).on("submit", "#editItemForm", function (e) { + e.preventDefault(); + let submitBtn = $(this).find('button[type="submit"]'); + let originalText = submitBtn.html(); + submitBtn.prop("disabled", true).html(' Updating...'); + + $.ajax({ + url: "includes/api/api.php?action=update_item", + type: "POST", + data: $(this).serialize(), + dataType: "json", + success: function (res) { + submitBtn.prop("disabled", false).html(originalText); + if (res.status === "success") { + $("#editItemModal").modal("hide"); + showAlert(res.message, "success"); + if (window.currentTab === "inventory") loadInventory(); + } else { + showAlert(res.message, "danger"); + } + }, + }); + }); + + $(document).on("click", ".delete-btn", function () { + let itemId = $(this).data("id"); + if (confirm("Are you sure you want to delete this item? This cannot be undone.")) { + $.ajax({ + url: "includes/api/api.php?action=delete_item", + type: "POST", + data: { inventory_id: itemId }, + dataType: "json", + success: function (res) { + if (res.status === "success") { + showAlert(res.message, "success"); + if (window.currentTab === "inventory") loadInventory(); + } else { + showAlert(res.message, "danger"); + } + }, + }); + } + }); + + $(document).on("click", ".issue-btn", function () { + let btn = $(this); + $("#issue_inventory_id").val(btn.data("id")); + $("#issue_item_name").text(btn.data("name")); + $("#issue_qty").val(1); + $("#issueItemModal").modal("show"); + }); + + $(document).on("submit", "#issueItemForm", function (e) { + e.preventDefault(); + let submitBtn = $(this).find('button[type="submit"]'); + let originalText = submitBtn.html(); + submitBtn.prop("disabled", true).html(' Processing...'); + + $.ajax({ + url: "includes/api/api.php?action=issue_item", + type: "POST", + data: $(this).serialize(), + dataType: "json", + success: function (res) { + submitBtn.prop("disabled", false).html(originalText); + if (res.status === "success") { + $("#issueItemModal").modal("hide"); + showAlert(res.message, "success"); + if (window.currentTab === "inventory") loadInventory(); + } else { + showAlert(res.message, "danger"); + } + }, + }); + }); + + // --- 5. Low Stock Alerts --- + function checkAndShowLowStockAlert(inventoryData) { + let lowStockItems = inventoryData.filter(item => parseInt(item.quantity) <= parseInt(item.reorder_level)); + + if (lowStockItems.length > 0) { + let rows = ""; + lowStockItems.forEach(item => { + rows += ` + ${item.name} + ${item.quantity} + ${item.reorder_level} + ${item.supplier_name || "No Supplier"} + `; + }); + $("#lowStockTableBody").html(rows); + setTimeout(() => { $("#lowStockModal").modal("show"); }, 500); + } + } + + $(document).on("click", "#manualRestockBtn", function() { + $("#lowStockModal").modal("hide"); + switchTab('po'); + }); + + $(document).on("click", "#autoRestockBtn", function() { + let btn = $(this); + let originalText = btn.html(); + btn.prop("disabled", true).html(' Processing...'); + + $.ajax({ + url: "includes/api/api.php?action=auto_restock", + type: "POST", + dataType: "json", + success: function (res) { + btn.prop("disabled", false).html(originalText); + $("#lowStockModal").modal("hide"); + if (res.status === "success") { + showAlert(res.message, "success"); + switchTab('po'); + } else { + showAlert(res.message, "danger"); + } + } + }); + }); + +}); \ No newline at end of file diff --git a/assets/js/main.js b/assets/js/main.js new file mode 100644 index 0000000..bf55bd6 --- /dev/null +++ b/assets/js/main.js @@ -0,0 +1,80 @@ +// ========================================== +// 1. GLOBAL VARIABLES & STATE +// ========================================== +let currentUserRole = ''; + +// Inventory State +let allInventoryData = []; +let filteredInventoryData = []; +let inventoryCurrentPage = 1; +const inventoryItemsPerPage = 5; +let hasCheckedLowStock = false; + +// PO State +let allPOData = []; +let filteredPOData = []; +let poCurrentPage = 1; +const poItemsPerPage = 5; + +// Analytics State +let currentAnalyticsData = null; +let valuationChartInstance = null; +let spendChartInstance = null; + +// ========================================== +// 2. GLOBAL HELPER FUNCTIONS +// ========================================== +function showAlert(msg, type) { + $("#alertBox").html(` +
    + ${msg} + +
    + `); +} + +// ========================================== +// 3. SPA LAZY LOADING ROUTER +// ========================================== +function initPageScripts(page) { + // These functions will live in their respective module files! + if (page === "inventory") { + if (typeof loadInventory === "function") loadInventory(); + if (typeof loadSuppliers === "function") loadSuppliers(); + } else if (page === "po") { + if (typeof loadPOs === "function") loadPOs(); + if (typeof loadSuppliers === "function") loadSuppliers(); + } else if (page === "analytics") { + if (typeof loadAnalytics === "function") loadAnalytics(); + } else if (page === "vendors") { + if (typeof loadVendors === "function") loadVendors(); + } +} + +function loadPage(page) { + $("#pageContainer").html(` +
    +
    +
    Loading ${page}...
    +
    + `); + + $("#pageContainer").load("pages/" + page + ".php", function (response, status) { + if (status === "error") { + $("#pageContainer").html(` +
    + Failed to load ${page} module. Ensure pages/${page}.php exists. +
    + `); + } else { + initPageScripts(page); + } + }); +} + +window.switchTab = function (tab) { + window.currentTab = tab; + $(".nav-link").removeClass("active"); + $("#tab-" + tab).addClass("active"); + loadPage(tab); +}; \ No newline at end of file diff --git a/assets/js/po.js b/assets/js/po.js new file mode 100644 index 0000000..0f686e3 --- /dev/null +++ b/assets/js/po.js @@ -0,0 +1,336 @@ +// ========================================== +// PURCHASE ORDERS LOGIC (po.js) +// ========================================== +$(document).ready(function () { + + // --- 1. Load Data & Filters --- + window.loadPOs = function() { + $.ajax({ + url: "includes/api/api.php?action=get_pos", + type: "GET", + cache: false, + dataType: "json", + success: function (res) { + if (res.status === "success") { + allPOData = res.data; + applyPOFilters(); + } + } + }); + }; + + function applyPOFilters() { + if ($("#filterPoDateFrom").length === 0) return; // Failsafe + + let dateFrom = $("#filterPoDateFrom").val(); + let dateTo = $("#filterPoDateTo").val(); + let supplierFilter = $("#filterPoSupplier").val(); + + filteredPOData = allPOData.filter(function (po) { + let matchesDateFrom = dateFrom === "" || po.order_date >= dateFrom; + let matchesDateTo = dateTo === "" || po.order_date <= dateTo; + let matchesSupplier = supplierFilter === "" || po.supplier_id.toString() === supplierFilter; + return matchesDateFrom && matchesDateTo && matchesSupplier; + }); + + poCurrentPage = 1; + renderPOTable(); + } + + $(document).on("change", "#filterPoDateFrom, #filterPoDateTo, #filterPoSupplier", applyPOFilters); + + // --- 2. Render Table --- + function renderPOTable() { + let rows = ""; + let totalItems = filteredPOData.length; + let totalPages = Math.ceil(totalItems / poItemsPerPage) || 1; + let startIndex = (poCurrentPage - 1) * poItemsPerPage; + let endIndex = Math.min(startIndex + poItemsPerPage, totalItems); + let pageData = filteredPOData.slice(startIndex, endIndex); + + if (pageData.length === 0) { + rows = `No purchase orders found matching your filters.`; + } else { + pageData.forEach(function (po) { + let statusDisplay = ""; + let role = currentUserRole.toLowerCase(); + + if (po.status === "Pending") { + // PENDING: Only Admins can approve or cancel + if (role === 'admin') { + statusDisplay = ` + `; + } else { + statusDisplay = ' Pending Finance'; + } + } + else if (po.status === "Approved") { + // APPROVED: ANY user (Admin or Employee) can Receive or report an Issue + statusDisplay = ` + `; + } + else if (po.status === "Missing") { + statusDisplay = ' Missing'; + } + else if (po.status === "Issues") { + statusDisplay = ' Issues Reported'; + } + else if (po.status === "Received") { + statusDisplay = ' Received'; + } + else { + statusDisplay = ` ${po.status}`; + } + + let editBtn = po.status === "Pending" ? `
    ` : ""; + + rows += ` + #PO-${po.po_id} + ${po.order_date} + ${po.supplier_name} + +
    ${po.item_name || "No Item Data"}
    +
    Qty: ${po.item_qty || 0} @ ₱${parseFloat(po.unit_price).toFixed(2)} ${editBtn}
    + + ₱${parseFloat(po.total_amount).toFixed(2)} + ${statusDisplay} + `; + }); + } + + $("#poTableBody").html(rows); + if (totalItems > 0) $("#poPaginationInfo").text(`Showing ${startIndex + 1} to ${endIndex} of ${totalItems} entries`); + else $("#poPaginationInfo").text("Showing 0 entries"); + renderPOPagination(totalPages); + } + + function renderPOPagination(totalPages) { + let paginationHtml = `
  • Previous
  • `; + for (let i = 1; i <= totalPages; i++) { + paginationHtml += `
  • ${i}
  • `; + } + paginationHtml += `
  • Next
  • `; + $("#poPaginationControls").html(paginationHtml); + } + + window.changePOPage = function(newPage) { + poCurrentPage = newPage; + renderPOTable(); + }; + + // --- 3. Edit Quantities --- + $(document).on("click", ".edit-po-qty-btn", function () { + let poId = $(this).data("id"); + let currentQty = $(this).data("qty"); + let unitPrice = parseFloat($(this).data("price")); + + $("#edit_po_id").val(poId); + $("#edit_po_unit_price").val(unitPrice); + $("#edit_po_unit_price_display").text("₱" + unitPrice.toFixed(2)); + $("#edit_po_qty").val(currentQty); + $("#edit_po_total_display").text("₱" + (currentQty * unitPrice).toFixed(2)); + $("#editPoQtyModal").modal("show"); + }); + + $(document).on("input", "#edit_po_qty", function() { + let qty = parseInt($(this).val()) || 0; + let price = parseFloat($("#edit_po_unit_price").val()); + $("#edit_po_total_display").text("₱" + (qty * price).toFixed(2)); + }); + + $(document).on("submit", "#editPoQtyForm", function (e) { + e.preventDefault(); + let submitBtn = $(this).find('button[type="submit"]'); + let originalText = submitBtn.html(); + submitBtn.prop("disabled", true).html(' Updating...'); + + $.ajax({ + url: "includes/api/api.php?action=update_po_qty", + type: "POST", + data: $(this).serialize(), + dataType: "json", + success: function (res) { + submitBtn.prop("disabled", false).html(originalText); + if (res.status === "success") { + $("#editPoQtyModal").modal("hide"); + showAlert(res.message, "success"); + if (window.currentTab === "po") loadPOs(); + } else { + showAlert(res.message, "danger"); + } + } + }); + }); + + // --- 4. Create PO Logic --- + window.populatePODropdowns = function() { + $("#poSupplierSelect").html($("#supplierSelect").html()); + $("#poItemSelect").html(''); + }; + + $(document).on("change", "#poItemSelect", function () { + let selectedOption = $(this).find("option:selected"); + let itemPrice = selectedOption.data("price"); + let priceInput = $('#createPOForm input[name="unit_price"]'); + if (itemPrice !== undefined) priceInput.val(itemPrice); + else priceInput.val(""); + }); + + $(document).on("change", "#poSupplierSelect", function () { + let supplier_id = $(this).val(); + let itemSelect = $("#poItemSelect"); + + if (!supplier_id) { + itemSelect.html(''); + return; + } + itemSelect.html(''); + + $.ajax({ + url: "includes/api/api.php?action=get_supplier_items&supplier_id=" + supplier_id, + type: "GET", + dataType: "json", + success: function (res) { + if (res.status === "success") { + if (res.data.length === 0) { + itemSelect.html(''); + } else { + let options = ''; + res.data.forEach(function (item) { + options += ``; + }); + itemSelect.html(options); + } + } + }, + }); + }); + + $(document).on("submit", "#createPOForm", function (e) { + e.preventDefault(); + let submitBtn = $(this).find('button[type="submit"]'); + let originalText = submitBtn.html(); + submitBtn.prop("disabled", true).html(' Submitting...'); + + $.ajax({ + url: "includes/api/api.php?action=create_po", + type: "POST", + data: $(this).serialize(), + dataType: "json", + success: function (res) { + submitBtn.prop("disabled", false).html(originalText); + if (res.status === "success") { + $("#createPOModal").modal("hide"); + $("#createPOForm")[0].reset(); + showAlert(res.message, "success"); + if (window.currentTab === "po") loadPOs(); + } else { + showAlert(res.message, "danger"); + } + }, + }); + }); + + // --- 5. Status Actions (Approval / Receive) --- + $(document).on("change", ".status-dropdown", function () { + let selectElement = $(this); + let po_id = selectElement.data("id"); + let currentStatus = selectElement.data("current"); + let newStatus = selectElement.val(); + + if (newStatus === "Received") { + if (confirm("Are you sure you want to mark this as RECEIVED? This will officially add stock to your inventory and generate a Finance invoice. This cannot be undone.")) { + selectElement.prop("disabled", true); + $.ajax({ + url: "includes/api/api.php?action=receive_po", + type: "POST", + dataType: "json", + data: { po_id: po_id }, + success: function (res) { + showAlert(res.message, res.status === "success" ? "success" : "danger"); + if (window.currentTab === "po") loadPOs(); + } + }); + } else selectElement.val(currentStatus); + } else if (newStatus === "Cancelled") { + if (confirm("Are you sure you want to CANCEL this Purchase Order?")) { + $.ajax({ + url: "includes/api/api.php?action=cancel_po", + type: "POST", + dataType: "json", + data: { po_id: po_id }, + success: function (res) { + showAlert(res.message, res.status === "success" ? "success" : "danger"); + if (window.currentTab === "po") loadPOs(); + } + }); + } else selectElement.val(currentStatus); + } else { + $.ajax({ + url: "includes/api/api.php?action=update_po_status", + type: "POST", + dataType: "json", + data: { po_id: po_id, status: newStatus }, + success: function (res) { + showAlert(res.message, res.status === "success" ? "success" : "danger"); + if (res.status === "success") { + if (window.currentTab === "po") loadPOs(); + } else { + selectElement.val(currentStatus); + } + }, + }); + } + }); + + // --- 6. PO Details Modal --- + $(document).on("click", ".view-po-details-btn", function () { + let poId = $(this).data("id"); + $("#detailsPoId").text("#PO-" + poId); + $("#poDetailsTableBody").html(' Loading items...'); + $("#vendorHistoryModal").modal("hide"); + $("#poDetailsModal").modal("show"); + + $.ajax({ + url: "includes/api/api.php?action=get_po_details&po_id=" + poId, + type: "GET", + dataType: "json", + success: function (res) { + if (res.status === "success") { + let rows = ""; + if (res.data.length === 0) { + rows = `No items found for this PO.`; + } else { + res.data.forEach(function (item) { + let subtotal = item.quantity * item.unit_price; + rows += ` + ${item.name} + ${item.quantity} + ₱${parseFloat(item.unit_price).toFixed(2)} + ₱${subtotal.toFixed(2)} + `; + }); + } + $("#poDetailsTableBody").html(rows); + } else { + $("#poDetailsTableBody").html(`Error loading details.`); + } + }, + error: function() { + $("#poDetailsTableBody").html(`Network error. Check connection.`); + } + }); + }); + +}); \ No newline at end of file diff --git a/assets/js/vendors.js b/assets/js/vendors.js new file mode 100644 index 0000000..d59e851 --- /dev/null +++ b/assets/js/vendors.js @@ -0,0 +1,204 @@ +// ========================================== +// VENDORS & SUPPLIERS LOGIC (vendors.js) +// ========================================== +$(document).ready(function () { + + // --- 1. Global Supplier Fetch (Fills dropdowns on all pages) --- + window.loadSuppliers = function() { + $.ajax({ + url: "includes/api/api.php?action=get_suppliers", + type: "GET", + dataType: "json", + success: function (res) { + if (res.status === "success") { + let options = ''; + let filterOptions = ''; + + res.data.forEach(function (sup) { + options += ``; + filterOptions += ``; + }); + + $("#supplierSelect").html(options); + $("#filterSupplier").html(filterOptions); + $("#filterPoSupplier").html(filterOptions); + + // Auto-fill Create PO modal if it exists + if ($("#poSupplierSelect").length) { + $("#poSupplierSelect").html(options); + $("#poItemSelect").html(''); + } + } + }, + }); + }; + + // --- 2. Load Vendor Table --- + window.loadVendors = function() { + $.ajax({ + url: "includes/api/api.php?action=get_suppliers", + type: "GET", + dataType: "json", + success: function (res) { + if (res.status === "success") { + let rows = ""; + res.data.forEach(function (vendor) { + let deleteVendorBtn = (currentUserRole === 'admin') + ? `` + : ''; + + rows += ` + +
    ${vendor.name}
    +
    ${vendor.contact_person || "N/A"}
    + + +
    ${vendor.email || "N/A"}
    +
    ${vendor.phone || "N/A"}
    + + + + ${deleteVendorBtn} + + + `; + }); + $("#vendorTableBody").html(rows); + } + }, + }); + }; + + // --- 3. CRUD Operations --- + $(document).on("click", ".edit-vendor-btn", function () { + let btn = $(this); + $("#edit_vendor_id").val(btn.data("id")); + $("#edit_vendor_name").val(btn.data("name")); + $("#edit_vendor_contact_person").val(btn.data("person")); + $("#edit_vendor_phone").val(btn.data("phone")); + $("#edit_vendor_email").val(btn.data("email")); + $("#edit_vendor_address").val(btn.data("address")); + $("#editVendorModal").modal("show"); + }); + + $(document).on("submit", "#addVendorForm", function (e) { + e.preventDefault(); + let submitBtn = $(this).find('button[type="submit"]'); + let originalText = submitBtn.html(); + submitBtn.prop("disabled", true).html(' Saving...'); + + $.ajax({ + url: "includes/api/api.php?action=add_supplier", + type: "POST", + data: $(this).serialize(), + dataType: "json", + success: function (res) { + submitBtn.prop("disabled", false).html(originalText); + if (res.status === "success") { + $("#addVendorModal").modal("hide"); + $("#addVendorForm")[0].reset(); + showAlert(res.message, "success"); + if (window.currentTab === "vendors") loadVendors(); + loadSuppliers(); // Refresh global dropdowns + } else { + showAlert(res.message, "danger"); + } + } + }); + }); + + $(document).on("submit", "#editVendorForm", function (e) { + e.preventDefault(); + let submitBtn = $(this).find('button[type="submit"]'); + let originalText = submitBtn.html(); + submitBtn.prop("disabled", true).html(' Updating...'); + + $.ajax({ + url: "includes/api/api.php?action=update_supplier", + type: "POST", + data: $(this).serialize(), + dataType: "json", + success: function (res) { + submitBtn.prop("disabled", false).html(originalText); + if (res.status === "success") { + $("#editVendorModal").modal("hide"); + showAlert(res.message, "success"); + if (window.currentTab === "vendors") loadVendors(); + loadSuppliers(); + } else { + showAlert(res.message, "danger"); + } + } + }); + }); + + $(document).on("click", ".delete-vendor-btn", function () { + let supplierId = $(this).data("id"); + if (confirm("Are you sure you want to delete this vendor? This will fail if they have active inventory items or purchase orders.")) { + $.ajax({ + url: "includes/api/api.php?action=delete_supplier", + type: "POST", + data: { supplier_id: supplierId }, + dataType: "json", + success: function (res) { + if (res.status === "success") { + showAlert(res.message, "success"); + if (window.currentTab === "vendors") loadVendors(); + loadSuppliers(); + } else { + showAlert(res.message, "danger"); + } + } + }); + } + }); + + // --- 4. Vendor History Modal --- + $(document).on("click", ".view-history-btn", function () { + let supplierId = $(this).data("id"); + let supplierName = $(this).data("name"); + + $("#historyVendorName").text(supplierName); + $("#vendorHistoryTableBody").html(' Loading history...'); + $("#vendorHistoryModal").modal("show"); + + $.ajax({ + url: "includes/api/api.php?action=get_vendor_history&supplier_id=" + supplierId, + type: "GET", + dataType: "json", + success: function (res) { + if (res.status === "success") { + let rows = ""; + if (res.data.length === 0) { + rows = `No procurement history found for this vendor.`; + } else { + res.data.forEach(function (po) { + let statusClass = po.status === "Received" ? "text-success" : (po.status === "Cancelled" ? "text-danger" : "text-warning"); + rows += ` + #PO-${po.po_id} + ${po.order_date} + ₱${parseFloat(po.total_amount).toFixed(2)} + ${po.status} + + + + `; + }); + } + $("#vendorHistoryTableBody").html(rows); + } else { + $("#vendorHistoryTableBody").html(`Error loading data.`); + } + } + }); + }); + +}); \ No newline at end of file diff --git a/includes/api/api.php b/includes/api/api.php new file mode 100644 index 0000000..df8c199 --- /dev/null +++ b/includes/api/api.php @@ -0,0 +1,766 @@ + 'error', 'message' => 'Unauthorized access. Please log in.']); +// exit; +// } + +switch ($action) { + case 'check_auth': + if (isset($_SESSION['user_id'])) { + echo json_encode([ + 'status' => 'success', + 'username' => $_SESSION['username'], + 'role' => $_SESSION['role'], + 'full_name' => $_SESSION['full_name'] + ]); + } else { + echo json_encode(['status' => 'error', 'message' => 'Not logged in']); + } + break; + + case 'login': + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + + // --- 1. COOLDOWN CHECK --- + // If the user is currently locked out, reject the request immediately + if (isset($_SESSION['lockout_time']) && time() < $_SESSION['lockout_time']) { + $remaining = $_SESSION['lockout_time'] - time(); + echo json_encode([ + 'status' => 'locked', + 'message' => "Too many failed attempts. Locked out.", + 'remaining' => $remaining + ]); + exit; + } + + $username = $_POST['username'] ?? ''; + $password = $_POST['password'] ?? ''; + + $stmt = $pdo->prepare(" + SELECT u.id, u.username, u.password, u.role, e.first_name, e.last_name + FROM users u + LEFT JOIN employees e ON u.employee_id = e.employee_id + WHERE u.username = :username + "); + $stmt->execute([':username' => $username]); + $user = $stmt->fetch(PDO::FETCH_ASSOC); + + // --- 2. VERIFY PASSWORD --- + if ($user && password_verify($password, $user['password'])) { + // SUCCESS: Reset the penalty counters + unset($_SESSION['login_attempts']); + unset($_SESSION['lockout_time']); + + $_SESSION['user_id'] = $user['id']; + $_SESSION['username'] = $user['username']; + $_SESSION['role'] = $user['role']; + $_SESSION['full_name'] = $user['first_name'] ? $user['first_name'] . ' ' . $user['last_name'] : 'Administrator'; + + echo json_encode(['status' => 'success', 'message' => 'Login successful']); + } else { + // FAILURE: Increase the strike counter + $_SESSION['login_attempts'] = isset($_SESSION['login_attempts']) ? $_SESSION['login_attempts'] + 1 : 1; + $max_free_attempts = 3; + + if ($_SESSION['login_attempts'] >= $max_free_attempts) { + // Progressive Math: 30s, then 60s, then 120s, then 240s... + $multiplier = $_SESSION['login_attempts'] - $max_free_attempts; + $penalty_seconds = 30 * pow(2, $multiplier); + + $_SESSION['lockout_time'] = time() + $penalty_seconds; + + echo json_encode([ + 'status' => 'locked', + 'message' => "Too many failed attempts.", + 'remaining' => $penalty_seconds + ]); + } else { + $attempts_left = $max_free_attempts - $_SESSION['login_attempts']; + echo json_encode([ + 'status' => 'error', + 'message' => "Invalid credentials. $attempts_left attempt(s) remaining." + ]); + } + } + } + break; + + case 'logout': + session_unset(); + session_destroy(); + echo json_encode(['status' => 'success', 'message' => 'Logged out successfully']); + break; + + case 'get_inventory': + $stmt = $pdo->query(" + SELECT i.*, s.name as supplier_name + FROM inventory_items i + LEFT JOIN suppliers s ON i.supplier_id = s.supplier_id + ORDER BY i.name ASC + "); + $items = $stmt->fetchAll(); + echo json_encode(['status' => 'success', 'data' => $items]); + break; + + case 'get_available_items': + try { + // Only fetch items where quantity is greater than 0 + $stmt = $pdo->query(" + SELECT inventory_id, name, description, quantity, unit_price + FROM inventory_items + WHERE quantity > 0 + ORDER BY name ASC + "); + $items = $stmt->fetchAll(PDO::FETCH_ASSOC); + echo json_encode(['status' => 'success', 'data' => $items]); + } catch (Exception $e) { + echo json_encode(['status' => 'error', 'message' => 'Failed to fetch items: ' . $e->getMessage()]); + } + break; + + case 'get_suppliers': + // UPDATED: Now fetches ALL columns so the edit modal gets the email, phone, etc. + $stmt = $pdo->query("SELECT * FROM suppliers ORDER BY name ASC"); + $suppliers = $stmt->fetchAll(); + echo json_encode(['status' => 'success', 'data' => $suppliers]); + break; + + case 'get_supplier_items': + $supplier_id = $_GET['supplier_id'] ?? 0; + + // Fetch only items that belong to the selected supplier + $stmt = $pdo->prepare("SELECT inventory_id, name, quantity, unit_price FROM inventory_items WHERE supplier_id = ? ORDER BY name ASC"); + $stmt->execute([$supplier_id]); + $items = $stmt->fetchAll(); + + echo json_encode(['status' => 'success', 'data' => $items]); + break; + + // --------------------------------------------------------- + // NEW VENDOR MANAGEMENT ENDPOINTS + // --------------------------------------------------------- + + case 'get_vendor_history': + $supplier_id = $_GET['supplier_id'] ?? 0; + try { + $stmt = $pdo->prepare("SELECT po_id, order_date, total_amount, status FROM purchase_orders WHERE supplier_id = ? ORDER BY order_date DESC"); + $stmt->execute([$supplier_id]); + $data = $stmt->fetchAll(PDO::FETCH_ASSOC); + echo json_encode(['status' => 'success', 'data' => $data]); + } catch (Exception $e) { + echo json_encode(['status' => 'error', 'message' => $e->getMessage()]); + } + break; + + case 'add_supplier': + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $name = $_POST['name'] ?? ''; + $person = $_POST['contact_person'] ?? ''; + $phone = $_POST['phone'] ?? ''; + $email = $_POST['email'] ?? ''; + $address = $_POST['address'] ?? ''; + + try { + $stmt = $pdo->prepare("INSERT INTO suppliers (name, contact_person, phone, email, address) VALUES (?, ?, ?, ?, ?)"); + $stmt->execute([$name, $person, $phone, $email, $address]); + echo json_encode(['status' => 'success', 'message' => 'Vendor added successfully.']); + } catch (Exception $e) { + echo json_encode(['status' => 'error', 'message' => 'Failed to add vendor: ' . $e->getMessage()]); + } + } + break; + + case 'update_supplier': + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $id = $_POST['supplier_id'] ?? ''; + $name = $_POST['name'] ?? ''; + $person = $_POST['contact_person'] ?? ''; + $phone = $_POST['phone'] ?? ''; + $email = $_POST['email'] ?? ''; + $address = $_POST['address'] ?? ''; + + try { + $stmt = $pdo->prepare("UPDATE suppliers SET name=?, contact_person=?, phone=?, email=?, address=? WHERE supplier_id=?"); + $stmt->execute([$name, $person, $phone, $email, $address, $id]); + echo json_encode(['status' => 'success', 'message' => 'Vendor updated successfully.']); + } catch (Exception $e) { + echo json_encode(['status' => 'error', 'message' => 'Failed to update vendor: ' . $e->getMessage()]); + } + } + break; + + case 'delete_supplier': + if ($_SESSION['role'] !== 'admin') { + echo json_encode(['status' => 'error', 'message' => 'Access Denied: Admins only.']); + break; + } + + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $id = $_POST['supplier_id'] ?? ''; + try { + $stmt = $pdo->prepare("DELETE FROM suppliers WHERE supplier_id = ?"); + $stmt->execute([$id]); + echo json_encode(['status' => 'success', 'message' => 'Vendor deleted successfully.']); + } catch (PDOException $e) { + // Catches Foreign Key constraint if supplier has active inventory/POs + if ($e->getCode() == '23000') { + echo json_encode(['status' => 'error', 'message' => 'Cannot delete: Vendor is tied to existing items or Purchase Orders.']); + } else { + echo json_encode(['status' => 'error', 'message' => 'Failed to delete: ' . $e->getMessage()]); + } + } + } + break; + + // --------------------------------------------------------- + // END VENDOR MANAGEMENT ENDPOINTS + // --------------------------------------------------------- + + case 'add_item': + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $name = $_POST['name'] ?? ''; + $description = $_POST['description'] ?? ''; + $quantity = $_POST['quantity'] ?? 0; + $unit_price = $_POST['unit_price'] ?? 0.00; + $reorder_level = $_POST['reorder_level'] ?? 0; + $supplier_id = $_POST['supplier_id'] ?? null; + + try { + $sql = "INSERT INTO inventory_items (name, description, quantity, unit_price, reorder_level, supplier_id) + VALUES (:name, :description, :quantity, :unit_price, :reorder_level, :supplier_id)"; + $stmt = $pdo->prepare($sql); + $stmt->execute([ + ':name' => $name, + ':description' => $description, + ':quantity' => $quantity, + ':unit_price' => $unit_price, + ':reorder_level' => $reorder_level, + ':supplier_id' => $supplier_id + ]); + echo json_encode(['status' => 'success', 'message' => 'Item added successfully']); + } catch (Exception $e) { + echo json_encode(['status' => 'error', 'message' => $e->getMessage()]); + } + } + break; + + case 'update_item': + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $id = $_POST['inventory_id']; + $name = $_POST['name']; + $description = $_POST['description']; + $quantity = $_POST['quantity']; + $unit_price = $_POST['unit_price']; + $reorder_level = $_POST['reorder_level']; + $supplier_id = $_POST['supplier_id']; + + try { + $stmt = $pdo->prepare("UPDATE inventory_items SET name=?, description=?, quantity=?, unit_price=?, reorder_level=?, supplier_id=? WHERE inventory_id=?"); + $stmt->execute([$name, $description, $quantity, $unit_price, $reorder_level, $supplier_id, $id]); + echo json_encode(['status' => 'success', 'message' => 'Item updated successfully']); + } catch (Exception $e) { + echo json_encode(['status' => 'error', 'message' => $e->getMessage()]); + } + } + break; + + case 'delete_item': + if ($_SESSION['role'] !== 'admin') { + echo json_encode(['status' => 'error', 'message' => 'Access Denied: Admins only.']); + break; + } + + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $id = $_POST['inventory_id']; + try { + $stmt = $pdo->prepare("DELETE FROM inventory_items WHERE inventory_id=?"); + $stmt->execute([$id]); + echo json_encode(['status' => 'success', 'message' => 'Item deleted successfully']); + } catch (PDOException $e) { + // Error 23000 is a Foreign Key Constraint violation + if ($e->getCode() == '23000') { + echo json_encode(['status' => 'error', 'message' => 'Cannot delete: This item is part of an existing Purchase Order.']); + } else { + echo json_encode(['status' => 'error', 'message' => 'Failed to delete: ' . $e->getMessage()]); + } + } + } + break; + + case 'issue_item': + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $id = $_POST['inventory_id'] ?? null; + $issue_qty = (int)($_POST['issue_qty'] ?? 0); + + if (!$id || $issue_qty <= 0) { + echo json_encode(['status' => 'error', 'message' => 'Invalid ID or Quantity.']); + break; + } + + try { + $pdo->beginTransaction(); + + // 1. Check current stock before deducting + $stmtCheck = $pdo->prepare("SELECT name, quantity FROM inventory_items WHERE inventory_id = ?"); + $stmtCheck->execute([$id]); + $item = $stmtCheck->fetch(PDO::FETCH_ASSOC); + + if (!$item) { + throw new Exception("Item not found in the database."); + } + + // 2. Prevent negative inventory + if ($item['quantity'] < $issue_qty) { + throw new Exception("Insufficient stock! You requested $issue_qty, but only {$item['quantity']} are available."); + } + + // 3. Subtract the quantity safely + $stmt = $pdo->prepare("UPDATE inventory_items SET quantity = quantity - ? WHERE inventory_id = ?"); + $stmt->execute([$issue_qty, $id]); + + // 4. Log the transaction for the predictive engine + $logStmt = $pdo->prepare("INSERT INTO inventory_transactions (inventory_id, qty_issued) VALUES (?, ?)"); + $logStmt->execute([$id, $issue_qty]); + + $pdo->commit(); + echo json_encode([ + 'status' => 'success', + 'message' => "Successfully issued $issue_qty unit(s) of {$item['name']}." + ]); + + } catch (Exception $e) { + $pdo->rollBack(); + echo json_encode(['status' => 'error', 'message' => $e->getMessage()]); + } + } + break; + + case 'get_po_details': + $po_id = $_GET['po_id'] ?? 0; + try { + // Join purchase_items with inventory_items to get the actual item name + $stmt = $pdo->prepare(" + SELECT pi.quantity, pi.unit_price, i.name + FROM purchase_items pi + JOIN inventory_items i ON pi.inventory_id = i.inventory_id + WHERE pi.po_id = ? + "); + $stmt->execute([$po_id]); + $data = $stmt->fetchAll(PDO::FETCH_ASSOC); + + echo json_encode(['status' => 'success', 'data' => $data]); + } catch (Exception $e) { + echo json_encode(['status' => 'error', 'message' => $e->getMessage()]); + } + break; + + case 'get_pos': + try { + $stmt = $pdo->query(" + SELECT + po.po_id, po.order_date, s.name as supplier_name, + po.total_amount, po.status, i.name as item_name, + pi.quantity as item_qty, pi.unit_price, s.supplier_id + FROM purchase_orders po + JOIN suppliers s ON po.supplier_id = s.supplier_id + LEFT JOIN purchase_items pi ON po.po_id = pi.po_id + LEFT JOIN inventory_items i ON pi.inventory_id = i.inventory_id + ORDER BY po.po_id DESC + "); + $pos = $stmt->fetchAll(); + echo json_encode(['status' => 'success', 'data' => $pos]); + } catch (Exception $e) { + echo json_encode(['status' => 'error', 'message' => $e->getMessage()]); + } + break; + + case 'auto_restock': + try { + $stmt = $pdo->query("SELECT * FROM inventory_items WHERE quantity <= reorder_level"); + $low_stock_items = $stmt->fetchAll(PDO::FETCH_ASSOC); + + if (count($low_stock_items) === 0) { + echo json_encode(['status' => 'error', 'message' => 'All stock levels are currently healthy. Nothing to restock.']); + exit; + } + + $pdo->beginTransaction(); + $po_created_count = 0; + + foreach ($low_stock_items as $item) { + $order_qty = ($item['reorder_level'] > 0) ? $item['reorder_level'] : 10; + $total_amount = $order_qty * $item['unit_price']; + + // 1. Create the Main Order Wrapper + $insertPo = $pdo->prepare(" + INSERT INTO purchase_orders (supplier_id, order_date, total_amount, status) + VALUES (?, NOW(), ?, 'Pending') + "); + $insertPo->execute([$item['supplier_id'], $total_amount]); + $new_po_id = $pdo->lastInsertId(); + + // 2. Insert the actual Item details into purchase_items + $insertItem = $pdo->prepare(" + INSERT INTO purchase_items (po_id, inventory_id, quantity, unit_price) + VALUES (?, ?, ?, ?) + "); + $insertItem->execute([$new_po_id, $item['inventory_id'], $order_qty, $item['unit_price']]); + + $po_created_count++; + } + + $pdo->commit(); + echo json_encode(['status' => 'success', 'message' => "Successfully generated $po_created_count individual Purchase Order(s)."]); + + } catch (Exception $e) { + $pdo->rollBack(); + echo json_encode(['status' => 'error', 'message' => 'System Error: ' . $e->getMessage()]); + } + break; + + case 'create_po': + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $supplier_id = $_POST['supplier_id']; + $inventory_id = $_POST['inventory_id']; + $quantity = $_POST['quantity']; + $unit_price = $_POST['unit_price']; + $total_amount = $quantity * $unit_price; + $order_date = date('Y-m-d'); + + try { + $pdo->beginTransaction(); + // Changed 'Ordered' to 'Pending' + $stmt = $pdo->prepare("INSERT INTO purchase_orders (supplier_id, order_date, total_amount, status) VALUES (?, ?, ?, 'Pending')"); + $stmt->execute([$supplier_id, $order_date, $total_amount]); + $po_id = $pdo->lastInsertId(); + + $stmt2 = $pdo->prepare("INSERT INTO purchase_items (po_id, inventory_id, quantity, unit_price) VALUES (?, ?, ?, ?)"); + $stmt2->execute([$po_id, $inventory_id, $quantity, $unit_price]); + + $pdo->commit(); + echo json_encode(['status' => 'success', 'message' => 'Purchase Order created and sent to Finance for approval.']); + } catch (Exception $e) { + $pdo->rollBack(); + echo json_encode(['status' => 'error', 'message' => 'Failed to create PO: ' . $e->getMessage()]); + } + } + break; + + case 'update_po_qty': + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $po_id = $_POST['po_id']; + $new_quantity = (int)$_POST['new_quantity']; + + try { + $pdo->beginTransaction(); + + // 1. Get the unit price for this PO item + $stmt1 = $pdo->prepare("SELECT unit_price FROM purchase_items WHERE po_id = ?"); + $stmt1->execute([$po_id]); + $item = $stmt1->fetch(); + + if (!$item) throw new Exception("PO Item not found."); + + $unit_price = $item['unit_price']; + $new_total = $new_quantity * $unit_price; + + // 2. Update Quantity in purchase_items + $stmt2 = $pdo->prepare("UPDATE purchase_items SET quantity = ? WHERE po_id = ?"); + $stmt2->execute([$new_quantity, $po_id]); + + // 3. Update Total Amount in purchase_orders (Ensure it's still Pending) + $stmt3 = $pdo->prepare("UPDATE purchase_orders SET total_amount = ? WHERE po_id = ? AND status = 'Pending'"); + $stmt3->execute([$new_total, $po_id]); + + if ($stmt3->rowCount() == 0) throw new Exception("Cannot update. PO might already be approved by Finance."); + + $pdo->commit(); + echo json_encode(['status' => 'success', 'message' => 'PO Quantity and Total Price updated successfully.']); + } catch (Exception $e) { + $pdo->rollBack(); + echo json_encode(['status' => 'error', 'message' => $e->getMessage()]); + } + } + break; + + case 'update_po_status': + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $po_id = $_POST['po_id'] ?? null; + $new_status = $_POST['status'] ?? ''; + + // 1. Strict Validation: Match your database ENUM exactly + $allowed_statuses = ['Pending', 'Approved', 'Received', 'Missing', 'Issues', 'Cancelled']; + + if (!$po_id || !in_array($new_status, $allowed_statuses)) { + echo json_encode(['status' => 'error', 'message' => 'Invalid Purchase Order ID or Status.']); + break; + } + + try { + // 2. Prevent status changes if the PO is already permanently finalized + $stmt = $pdo->prepare(" + UPDATE purchase_orders + SET status = ? + WHERE po_id = ? AND status NOT IN ('Received', 'Cancelled') + "); + $stmt->execute([$new_status, $po_id]); + + // 3. Verify that the database actually made a change + if ($stmt->rowCount() > 0) { + echo json_encode([ + 'status' => 'success', + 'message' => "Purchase Order #$po_id successfully updated to '$new_status'." + ]); + } else { + echo json_encode([ + 'status' => 'error', + 'message' => "Could not update. PO #$po_id may not exist or is already finalized." + ]); + } + + } catch (PDOException $e) { + echo json_encode(['status' => 'error', 'message' => 'Database Error: ' . $e->getMessage()]); + } catch (Exception $e) { + echo json_encode(['status' => 'error', 'message' => 'System Error: ' . $e->getMessage()]); + } + } + break; + + case 'receive_po': + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $po_id = $_POST['po_id']; + + try { + $pdo->beginTransaction(); + + // 1. Explicitly check the current status in the database first + $checkStmt = $pdo->prepare("SELECT status FROM purchase_orders WHERE po_id = ?"); + $checkStmt->execute([$po_id]); + $current_po = $checkStmt->fetch(PDO::FETCH_ASSOC); + + if (!$current_po) { + throw new Exception("Purchase Order #$po_id not found in the database."); + } + + if ($current_po['status'] === 'Received') { + throw new Exception("This PO has already been received and added to inventory."); + } + + // Make sure it matches exactly (Case sensitive) + if (strtolower($current_po['status']) !== 'approved') { + throw new Exception("Cannot receive. The database says this PO is currently '" . $current_po['status'] . "', but it must be 'Approved'."); + } + + // 2. Update PO status to Received + $stmt = $pdo->prepare("UPDATE purchase_orders SET status = 'Received' WHERE po_id = ?"); + $stmt->execute([$po_id]); + + // 3. Get the items from this PO + $stmtItems = $pdo->prepare("SELECT inventory_id, quantity FROM purchase_items WHERE po_id = ?"); + $stmtItems->execute([$po_id]); + $items = $stmtItems->fetchAll(); + + // 4. Loop through items and update inventory stock + $updateStock = $pdo->prepare("UPDATE inventory_items SET quantity = quantity + ? WHERE inventory_id = ?"); + foreach ($items as $item) { + $updateStock->execute([$item['quantity'], $item['inventory_id']]); + } + + // 5. 🔥 FINANCE INTEGRATION: Generate Vendor Payment (Invoice) 🔥 + $stmtPo = $pdo->prepare("SELECT supplier_id, total_amount FROM purchase_orders WHERE po_id = ?"); + $stmtPo->execute([$po_id]); + $poData = $stmtPo->fetch(); + + $stmtFinance = $pdo->prepare("INSERT INTO vendor_payments (supplier_id, po_id, pay_date, amount) VALUES (?, ?, ?, ?)"); + $stmtFinance->execute([ + $poData['supplier_id'], + $po_id, + date('Y-m-d'), + $poData['total_amount'] + ]); + + $pdo->commit(); + echo json_encode(['status' => 'success', 'message' => 'PO Received! Stock updated & Finance invoiced.']); + + } catch (Exception $e) { + $pdo->rollBack(); + echo json_encode(['status' => 'error', 'message' => $e->getMessage()]); + } + } + break; + + case 'cancel_po': + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $po_id = $_POST['po_id']; + try { + // Only allow cancellation if it hasn't been received yet! + $stmt = $pdo->prepare("UPDATE purchase_orders SET status = 'Cancelled' WHERE po_id = ? AND status != 'Received'"); + $stmt->execute([$po_id]); + + if($stmt->rowCount() > 0) { + echo json_encode(['status' => 'success', 'message' => 'Purchase Order Cancelled.']); + } else { + echo json_encode(['status' => 'error', 'message' => 'Cannot cancel this PO.']); + } + } catch (Exception $e) { + echo json_encode(['status' => 'error', 'message' => 'Database error: ' . $e->getMessage()]); + } + } + break; + + case 'issue_item': + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $id = $_POST['inventory_id']; + $issue_qty = (int)$_POST['issue_qty']; + + try { + $pdo->beginTransaction(); + + // 1. Subtract the quantity + $stmt = $pdo->prepare("UPDATE inventory_items SET quantity = GREATEST(0, quantity - ?) WHERE inventory_id = ?"); + $stmt->execute([$issue_qty, $id]); + + // 2. NEW: Log the transaction so we can predict future burn rates + $logStmt = $pdo->prepare("INSERT INTO inventory_transactions (inventory_id, qty_issued) VALUES (?, ?)"); + $logStmt->execute([$id, $issue_qty]); + + $pdo->commit(); + echo json_encode(['status' => 'success', 'message' => "Successfully issued $issue_qty item(s)."]); + } catch (Exception $e) { + $pdo->rollBack(); + echo json_encode(['status' => 'error', 'message' => 'Failed to issue item: ' . $e->getMessage()]); + } + } + break; + + case 'get_inventory': + // NEW: We join the last 30 days of transactions to calculate the Burn Rate + $stmt = $pdo->query(" + SELECT + i.*, + s.name as supplier_name, + COALESCE(SUM(t.qty_issued), 0) as issued_last_30_days + FROM inventory_items i + LEFT JOIN suppliers s ON i.supplier_id = s.supplier_id + LEFT JOIN inventory_transactions t ON i.inventory_id = t.inventory_id + AND t.transaction_date >= DATE_SUB(NOW(), INTERVAL 30 DAY) + GROUP BY i.inventory_id + ORDER BY i.name ASC + "); + + $items = $stmt->fetchAll(PDO::FETCH_ASSOC); + + // Calculate Predictive Metrics in PHP + foreach($items as &$item) { + // Average per day over the last 30 days + $daily_burn = $item['issued_last_30_days'] / 30; + $item['daily_burn_rate'] = round($daily_burn, 2); + + // Forecast: How many days until we hit 0? + if ($daily_burn > 0) { + $item['days_to_stockout'] = floor($item['quantity'] / $daily_burn); + } else { + // If 0 burn rate, they have a "Safe" 999 days + $item['days_to_stockout'] = 999; + } + } + + echo json_encode(['status' => 'success', 'data' => $items]); + break; + + case 'get_analytics': + try { + // 1. DESCRIPTIVE: Valuations & Item Count + $stmt1 = $pdo->query("SELECT SUM(quantity * unit_price) as total_value, COUNT(inventory_id) as total_items FROM inventory_items"); + $inv_stats = $stmt1->fetch(PDO::FETCH_ASSOC); + + // 2. DIAGNOSTIC: Total Spend & Turnover + $stmt4 = $pdo->query("SELECT SUM(total_amount) as total_spend FROM purchase_orders WHERE status = 'Received'"); + $po_stats = $stmt4->fetch(PDO::FETCH_ASSOC); + + $total_value = $inv_stats['total_value'] ?? 0; + $total_spend = $po_stats['total_spend'] ?? 0; + $turnover_ratio = ($total_value > 0) ? ($total_spend / $total_value) : 0; + + // Strict-Mode Safe: Added s.name to GROUP BY + $stmt5 = $pdo->query(" + SELECT s.name as category_name, SUM(po.total_amount) as category_spend + FROM purchase_orders po + JOIN suppliers s ON po.supplier_id = s.supplier_id + WHERE po.status = 'Received' + GROUP BY s.supplier_id, s.name + "); + $spend_by_category = $stmt5->fetchAll(PDO::FETCH_ASSOC); + + // Strict-Mode Safe: Added s.name to GROUP BY + $stmt6 = $pdo->query(" + SELECT s.name as supplier_name, SUM(i.quantity * i.unit_price) as supplier_value + FROM inventory_items i + JOIN suppliers s ON i.supplier_id = s.supplier_id + GROUP BY s.supplier_id, s.name + "); + $valuation_data = $stmt6->fetchAll(PDO::FETCH_ASSOC); + + // 3. PREDICTIVE: Calculate Burn Rates (100% Strict-Mode Safe) + $stmtPredict = $pdo->query(" + SELECT + i.inventory_id, + i.name, + i.quantity, + (COALESCE(SUM(t.qty_issued), 0) / 30) as daily_burn + FROM inventory_items i + LEFT JOIN inventory_transactions t ON i.inventory_id = t.inventory_id + AND t.transaction_date >= DATE_SUB(NOW(), INTERVAL 30 DAY) + GROUP BY i.inventory_id, i.name, i.quantity + HAVING (COALESCE(SUM(t.qty_issued), 0) / 30) > 0 + AND (i.quantity / (COALESCE(SUM(t.qty_issued), 0) / 30)) <= 7 + ORDER BY (i.quantity / (COALESCE(SUM(t.qty_issued), 0) / 30)) ASC + LIMIT 5 + "); + $predictive_data = $stmtPredict->fetchAll(PDO::FETCH_ASSOC); + + // Calculate explicit days for the JSON payload + foreach($predictive_data as &$pred) { + $pred['days_left'] = floor($pred['quantity'] / $pred['daily_burn']); + } + + // 4. PRESCRIPTIVE: Find exact number of low stock items + $stmt2 = $pdo->query("SELECT COUNT(inventory_id) as low_stock FROM inventory_items WHERE quantity <= reorder_level"); + $shortages = $stmt2->fetch(PDO::FETCH_ASSOC); + + // Send perfect JSON back to the browser + echo json_encode([ + 'status' => 'success', + 'data' => [ + 'total_value' => $total_value, + 'total_items' => $inv_stats['total_items'] ?? 0, + 'low_stock' => $shortages['low_stock'] ?? 0, + 'total_spend' => $total_spend, + 'turnover_ratio' => round($turnover_ratio, 2), + 'spend_by_category' => $spend_by_category, + 'valuation_data' => $valuation_data, + 'predictions' => $predictive_data + ] + ]); + + } catch (PDOException $e) { + // 🔥 THE SAFETY NET: If the database fails, it tells us EXACTLY why instead of a 500 error! + echo json_encode(['status' => 'error', 'message' => 'Database Error: ' . $e->getMessage()]); + } catch (Exception $e) { + echo json_encode(['status' => 'error', 'message' => 'System Error: ' . $e->getMessage()]); + } + break; + + default: + echo json_encode(['status' => 'error', 'message' => 'Invalid action']); + break; +} +?> \ No newline at end of file diff --git a/includes/db/db.php b/includes/db/db.php new file mode 100644 index 0000000..e15d0fd --- /dev/null +++ b/includes/db/db.php @@ -0,0 +1,15 @@ +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); +} catch(PDOException $e) { + die(json_encode(['status' => 'error', 'message' => 'Connection failed: ' . $e->getMessage()])); +} +?> \ No newline at end of file diff --git a/includes/footer.php b/includes/footer.php new file mode 100644 index 0000000..3f68ead --- /dev/null +++ b/includes/footer.php @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/includes/header.php b/includes/header.php new file mode 100644 index 0000000..bd33037 --- /dev/null +++ b/includes/header.php @@ -0,0 +1,16 @@ + \ No newline at end of file diff --git a/index.php b/index.php new file mode 100644 index 0000000..916433c --- /dev/null +++ b/index.php @@ -0,0 +1,66 @@ + + + + + + ICIS Inventory System + + + + + + + + + + + + + + + + + + + + + +
    + +
    + + + + + +
    + +
    + + + + + + + + + + + + + \ No newline at end of file diff --git a/modals/alerts_modal.php b/modals/alerts_modal.php new file mode 100644 index 0000000..8f47e85 --- /dev/null +++ b/modals/alerts_modal.php @@ -0,0 +1,46 @@ + \ No newline at end of file diff --git a/modals/item_modal.php b/modals/item_modal.php new file mode 100644 index 0000000..f413db8 --- /dev/null +++ b/modals/item_modal.php @@ -0,0 +1,127 @@ + + + + + + + + \ No newline at end of file diff --git a/modals/po_modal.php b/modals/po_modal.php new file mode 100644 index 0000000..9d5610d --- /dev/null +++ b/modals/po_modal.php @@ -0,0 +1,83 @@ + + + + + \ No newline at end of file diff --git a/modals/vendor_modal.php b/modals/vendor_modal.php new file mode 100644 index 0000000..66d16b3 --- /dev/null +++ b/modals/vendor_modal.php @@ -0,0 +1,164 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/pages/analytics.php b/pages/analytics.php new file mode 100644 index 0000000..461d26d --- /dev/null +++ b/pages/analytics.php @@ -0,0 +1,102 @@ +
    +
    +

    Dashboard Analytics

    +
    + + + +
    +
    + +
    +
    + +
    1. Descriptive (Current State)
    +
    +
    +
    +
    STOCK VALUATION
    +

    ₱0.00

    +
    +
    +
    +
    +
    TOTAL PO SPEND
    +

    ₱0.00

    +
    +
    +
    +
    +
    UNIQUE ITEMS
    +

    0

    +
    +
    +
    + +
    2. Diagnostic (Trends & Causes)
    +
    +
    +
    +
    INVENTORY TURNOVER
    +

    0.00x

    + Spend vs. Current Stock +
    +
    +
    +
    +
    SPEND BY CATEGORY (SUPPLIER)
    +
    + +
    +
    +
    +
    +
    +
    VALUATION BY SUPPLIER
    +
    + +
    +
    +
    +
    + +
    + +
    + +
    3. Predictive (Forecast)
    +
    +
    +
    High-Risk Stockouts
    + Items predicted to run out in < 7 days based on recent burn rates. +
    +
    +
      +
    • Loading predictions...
    • +
    +
    +
    + +
    4. Prescriptive (Action)
    +
    +
    + +
    +
    System Recommendation
    +

    + You have 0 items currently below their reorder thresholds. The system can automatically generate optimized Purchase Orders for all affected suppliers right now. +

    + +
    + +
    +
    +
    \ No newline at end of file diff --git a/pages/inventory.php b/pages/inventory.php new file mode 100644 index 0000000..d740560 --- /dev/null +++ b/pages/inventory.php @@ -0,0 +1,57 @@ +
    +
    +

    Inventory Overview

    + +
    + +
    +
    +
    + + +
    +
    +
    + +
    +
    + +
    +
    + +
    +
    + + + + + + + + + + + + + + +
    Item DetailsSupplierUnit PriceStockAI ForecastStatusAction
    + +
    +
    +
    + Showing 0 entries + +
    +
    \ No newline at end of file diff --git a/pages/po.php b/pages/po.php new file mode 100644 index 0000000..1e4613b --- /dev/null +++ b/pages/po.php @@ -0,0 +1,58 @@ +
    +
    +

    Purchase Orders

    + +
    + +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    + +
    +
    + + + + + + + + + + + + + +
    PO #DateSupplierDetailsTotal AmountStatus
    +
    +
    + +
    + Showing 0 entries + +
    +
    \ No newline at end of file diff --git a/pages/vendors.php b/pages/vendors.php new file mode 100644 index 0000000..df03843 --- /dev/null +++ b/pages/vendors.php @@ -0,0 +1,24 @@ +
    +
    +

    Vendor Management

    + +
    + +
    +
    + + + + + + + + + + +
    Supplier NameContact InfoAction
    +
    +
    +
    \ No newline at end of file diff --git a/partials/login.php b/partials/login.php new file mode 100644 index 0000000..24adcad --- /dev/null +++ b/partials/login.php @@ -0,0 +1,34 @@ + + +
    +
    +
    +
    +
    + +
    +

    Welcome Back

    +

    Please sign in to ICIS Inventory + + +

    +
    + +
    + +
    +
    + + +
    +
    + + +
    + +
    +
    +
    +
    \ No newline at end of file diff --git a/partials/navbar.php b/partials/navbar.php new file mode 100644 index 0000000..d52570a --- /dev/null +++ b/partials/navbar.php @@ -0,0 +1,24 @@ +
    + +
    \ No newline at end of file