Spaces:
Running
Running
The format of the results returned by the /api/jobs endpoint has changed to: {"id":5858,"end_time":"2025-04-01T14:54:00.000Z","description":"Fingerprint from photo on signet table","quantity":1,"customer_ref":"7964 / C Wilkinson","job_status":"Complete","customer_name":"Hockley Jewellers","customer_monthly_flag":null,"item_price":"32.50","total_amount":"32.50"}. Update the code to accommodate these changes. - Follow Up Deployment
d9a0d8e
verified
| <html lang="en"> | |
| <head><script>window.huggingface={variables:{"SPACE_CREATOR_USER_ID":"6381f077c927cd0dfd4ed8ca"}};</script> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Company Job Dashboard</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/chart.js"></script> | |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> | |
| <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/daterangepicker@3.1.0/daterangepicker.css"> | |
| <style> | |
| .sortable:hover { | |
| background-color: #f3f4f6; | |
| cursor: pointer; | |
| } | |
| .sort-asc::after { | |
| content: " ↑"; | |
| } | |
| .sort-desc::after { | |
| content: " ↓"; | |
| } | |
| .modal { | |
| transition: opacity 0.3s ease; | |
| } | |
| .chart-container { | |
| position: relative; | |
| height: 300px; | |
| } | |
| .invalid-row { | |
| background-color: #fee2e2; | |
| } | |
| #filter-dropdown a.active { | |
| background-color: #3b82f6; | |
| color: white; | |
| } | |
| .edit-icon:hover { | |
| color: #3b82f6; | |
| transform: scale(1.1); | |
| } | |
| .timeframe-btn.active { | |
| background-color: #3b82f6; | |
| color: white; | |
| } | |
| .daterangepicker { | |
| font-family: inherit; | |
| } | |
| #error-alert { | |
| transition: all 0.3s ease; | |
| } | |
| </style> | |
| </head> | |
| <body class="bg-gray-50"> | |
| <!-- Error Alert --> | |
| <div id="error-alert" class="fixed top-4 right-4 bg-red-500 text-white px-6 py-3 rounded-lg shadow-lg hidden z-50"> | |
| <div class="flex items-center"> | |
| <i class="fas fa-exclamation-circle mr-3"></i> | |
| <span id="error-message">Database connection error</span> | |
| <button id="close-error" class="ml-4"> | |
| <i class="fas fa-times"></i> | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Loading Overlay --> | |
| <div id="loading-overlay" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-40"> | |
| <div class="bg-white p-8 rounded-lg shadow-xl text-center"> | |
| <div class="animate-spin rounded-full h-16 w-16 border-b-2 border-blue-500 mx-auto mb-4"></div> | |
| <p class="text-lg font-semibold">Loading dashboard data...</p> | |
| </div> | |
| </div> | |
| <div class="container mx-auto px-4 py-8"> | |
| <!-- Header with Add Job button --> | |
| <div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 gap-4"> | |
| <div> | |
| <h1 class="text-3xl font-bold text-gray-800">Job Dashboard</h1> | |
| <p class="text-gray-600">Weekly job completion overview</p> | |
| </div> | |
| <div class="flex items-center gap-4"> | |
| <!-- Search --> | |
| <div class="relative w-full md:w-64"> | |
| <input type="text" id="search" placeholder="Search jobs..." class="w-full pl-10 pr-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"> | |
| <i class="fas fa-search absolute left-3 top-3 text-gray-400"></i> | |
| </div> | |
| <!-- Add Job Button --> | |
| <button id="add-job-btn" class="bg-green-500 hover:bg-green-600 text-white px-4 py-2 rounded-lg flex items-center"> | |
| <i class="fas fa-plus mr-2"></i> Add Job | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Stats Cards --> | |
| <div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8" id="stats-cards"> | |
| <!-- Will be populated by JavaScript --> | |
| </div> | |
| <!-- Charts Row --> | |
| <div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8"> | |
| <div class="bg-white p-6 rounded-xl shadow"> | |
| <div class="flex justify-between items-center mb-4"> | |
| <h3 class="text-lg font-semibold text-gray-800">Revenue Trend</h3> | |
| <div class="flex space-x-1"> | |
| <button class="timeframe-btn active px-3 py-1 rounded-md text-sm" data-timeframe="week">Week</button> | |
| <button class="timeframe-btn px-3 py-1 rounded-md text-sm" data-timeframe="month">Month</button> | |
| <button class="timeframe-btn px-3 py-1 rounded-md text-sm" data-timeframe="quarter">Quarter</button> | |
| <button class="timeframe-btn px-3 py-1 rounded-md text-sm" data-timeframe="year">Year</button> | |
| </div> | |
| </div> | |
| <div class="chart-container"> | |
| <canvas id="trendChart"></canvas> | |
| </div> | |
| </div> | |
| <div class="bg-white p-6 rounded-xl shadow"> | |
| <h3 class="text-lg font-semibold text-gray-800 mb-4">Jobs by Customer</h3> | |
| <div class="chart-container"> | |
| <canvas id="pieChart"></canvas> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Jobs Table --> | |
| <div class="bg-white rounded-xl shadow overflow-hidden mb-8"> | |
| <div class="px-6 py-4 border-b flex justify-between items-center"> | |
| <h2 class="text-xl font-semibold text-gray-800">Completed Jobs - <span id="date-range-text">Week of May 15, 2023</span></h2> | |
| <div class="flex items-center gap-2"> | |
| <input type="text" id="date-range-picker" class="border rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 cursor-pointer" placeholder="Select date range"> | |
| <button id="this-week-btn" class="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-lg">This Week</button> | |
| <div class="relative"> | |
| <button id="filter-btn" class="bg-gray-200 hover:bg-gray-300 text-gray-700 px-3 py-2 rounded-lg"> | |
| <i class="fas fa-filter"></i> | |
| </button> | |
| <div id="filter-dropdown" class="hidden absolute right-0 mt-2 w-32 bg-white rounded-md shadow-lg z-10"> | |
| <a href="#" class="filter-option block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" data-filter="weekly">Weekly</a> | |
| <a href="#" class="filter-option block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" data-filter="monthly">Monthly</a> | |
| <a href="#" class="filter-option block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" data-filter="all">All</a> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="overflow-x-auto"> | |
| <table class="min-w-full divide-y divide-gray-200"> | |
| <thead class="bg-gray-50"> | |
| <tr> | |
| <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider sortable sort-asc" data-column="customer"> | |
| Customer | |
| </th> | |
| <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider sortable" data-column="jobId"> | |
| Job ID | |
| </th> | |
| <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> | |
| Description | |
| </th> | |
| <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider sortable" data-column="quantity"> | |
| Qty | |
| </th> | |
| <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider sortable" data-column="price"> | |
| Price | |
| </th> | |
| <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider sortable" data-column="total"> | |
| Total | |
| </th> | |
| <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> | |
| Thumbnail | |
| </th> | |
| <th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider"> | |
| Actions | |
| </th> | |
| </tr> | |
| </thead> | |
| <tbody class="bg-white divide-y divide-gray-200" id="jobs-table-body"> | |
| <!-- Jobs will be inserted here by JavaScript --> | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| <!-- Invalid Jobs Table --> | |
| <div class="bg-white rounded-xl shadow overflow-hidden mb-8"> | |
| <div class="px-6 py-4 border-b bg-red-50"> | |
| <h2 class="text-xl font-semibold text-gray-800">Invalid Jobs - Missing Information</h2> | |
| <p class="text-sm text-red-600">These jobs require additional details to be processed</p> | |
| </div> | |
| <div class="overflow-x-auto"> | |
| <table class="min-w-full divide-y divide-gray-200"> | |
| <thead class="bg-gray-50"> | |
| <tr> | |
| <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider sortable" data-column="customer"> | |
| Customer | |
| </th> | |
| <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider sortable" data-column="jobId"> | |
| Job ID | |
| </th> | |
| <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> | |
| Description | |
| </th> | |
| <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> | |
| Missing Info | |
| </th> | |
| <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> | |
| Thumbnail | |
| </th> | |
| <th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider"> | |
| Actions | |
| </th> | |
| </tr> | |
| </thead> | |
| <tbody class="bg-white divide-y divide-gray-200" id="invalid-jobs-table-body"> | |
| <!-- Invalid jobs will be inserted here by JavaScript --> | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Image Modal --> | |
| <div id="image-modal" class="modal fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50 opacity-0 pointer-events-none"> | |
| <div class="bg-white rounded-lg max-w-4xl w-full max-h-screen overflow-auto"> | |
| <div class="flex justify-between items-center p-4 border-b"> | |
| <h3 class="text-lg font-semibold">Job Image</h3> | |
| <button id="close-modal" class="text-gray-500 hover:text-gray-700"> | |
| <i class="fas fa-times"></i> | |
| </button> | |
| </div> | |
| <div class="p-4"> | |
| <img id="modal-image" src="" alt="Job Image" class="w-full h-auto rounded"> | |
| </div> | |
| <div class="p-4 border-t text-right"> | |
| <button id="download-image" class="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded"> | |
| <i class="fas fa-download mr-2"></i>Download | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Edit Job Modal --> | |
| <div id="edit-modal" class="modal fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50 opacity-0 pointer-events-none"> | |
| <div class="bg-white rounded-lg max-w-2xl w-full max-h-screen overflow-auto"> | |
| <div class="flex justify-between items-center p-4 border-b"> | |
| <h3 class="text-lg font-semibold">Edit Job</h3> | |
| <button id="close-edit-modal" class="text-gray-500 hover:text-gray-700"> | |
| <i class="fas fa-times"></i> | |
| </button> | |
| </div> | |
| <div class="p-4"> | |
| <form id="edit-job-form"> | |
| <input type="hidden" id="edit-job-id"> | |
| <div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4"> | |
| <div> | |
| <label for="edit-customer" class="block text-sm font-medium text-gray-700 mb-1">Customer</label> | |
| <input type="text" id="edit-customer" class="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"> | |
| </div> | |
| <div> | |
| <label for="edit-job-id-display" class="block text-sm font-medium text-gray-700 mb-1">Job ID</label> | |
| <input type="text" id="edit-job-id-display" class="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" disabled> | |
| </div> | |
| </div> | |
| <div class="mb-4"> | |
| <label for="edit-description" class="block text-sm font-medium text-gray-700 mb-1">Description</label> | |
| <textarea id="edit-description" rows="3" class="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"></textarea> | |
| </div> | |
| <div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4"> | |
| <div> | |
| <label for="edit-quantity" class="block text-sm font-medium text-gray-700 mb-1">Quantity</label> | |
| <input type="number" id="edit-quantity" class="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"> | |
| </div> | |
| <div> | |
| <label for="edit-price" class="block text-sm font-medium text-gray-700 mb-1">Price</label> | |
| <input type="number" step="0.01" id="edit-price" class="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"> | |
| </div> | |
| <div> | |
| <label for="edit-total" class="block text-sm font-medium text-gray-700 mb-1">Total</label> | |
| <input type="number" step="0.01" id="edit-total" class="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" disabled> | |
| </div> | |
| </div> | |
| <div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4"> | |
| <div> | |
| <label for="edit-end-date" class="block text-sm font-medium text-gray-700 mb-1">End Date</label> | |
| <input type="date" id="edit-end-date" class="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"> | |
| </div> | |
| </div> | |
| <div class="mb-4"> | |
| <label class="block text-sm font-medium text-gray-700 mb-1">Thumbnail</label> | |
| <div class="flex items-center gap-4"> | |
| <img id="edit-thumbnail" src="" alt="Thumbnail" class="w-20 h-20 object-cover rounded border"> | |
| <button type="button" id="change-image" class="bg-gray-100 hover:bg-gray-200 text-gray-800 px-4 py-2 rounded-lg"> | |
| <i class="fas fa-image mr-2"></i>Change Image | |
| </button> | |
| </div> | |
| </div> | |
| </form> | |
| </div> | |
| <div class="p-4 border-t flex justify-between"> | |
| <button id="delete-job" class="bg-red-500 hover:bg-red-600 text-white px-4 py-2 rounded"> | |
| <i class="fas fa-trash mr-2"></i>Delete Job | |
| </button> | |
| <div> | |
| <button id="cancel-edit" class="bg-gray-300 hover:bg-gray-400 text-gray-800 px-4 py-2 rounded mr-2"> | |
| Cancel | |
| </button> | |
| <button id="save-job" class="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded"> | |
| <i class="fas fa-save mr-2"></i>Save Changes | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Add Job Modal --> | |
| <div id="add-modal" class="modal fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50 opacity-0 pointer-events-none"> | |
| <div class="bg-white rounded-lg max-w-2xl w-full max-h-screen overflow-auto"> | |
| <div class="flex justify-between items-center p-4 border-b"> | |
| <h3 class="text-lg font-semibold">Add New Job</h3> | |
| <button id="close-add-modal" class="text-gray-500 hover:text-gray-700"> | |
| <i class="fas fa-times"></i> | |
| </button> | |
| </div> | |
| <div class="p-4"> | |
| <form id="add-job-form"> | |
| <div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4"> | |
| <div> | |
| <label for="add-customer" class="block text-sm font-medium text-gray-700 mb-1">Customer</label> | |
| <input type="text" id="add-customer" class="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" required> | |
| </div> | |
| <div> | |
| <label for="add-job-id" class="block text-sm font-medium text-gray-700 mb-1">Job ID</label> | |
| <input type="text" id="add-job-id" class="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" required> | |
| </div> | |
| </div> | |
| <div class="mb-4"> | |
| <label for="add-description" class="block text-sm font-medium text-gray-700 mb-1">Description</label> | |
| <textarea id="add-description" rows="3" class="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" required></textarea> | |
| </div> | |
| <div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4"> | |
| <div> | |
| <label for="add-quantity" class="block text-sm font-medium text-gray-700 mb-1">Quantity</label> | |
| <input type="number" id="add-quantity" class="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" required> | |
| </div> | |
| <div> | |
| <label for="add-price" class="block text-sm font-medium text-gray-700 mb-1">Price</label> | |
| <input type="number" step="0.01" id="add-price" class="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" required> | |
| </div> | |
| <div> | |
| <label for="add-total" class="block text-sm font-medium text-gray-700 mb-1">Total</label> | |
| <input type="number" step="0.01" id="add-total" class="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" disabled> | |
| </div> | |
| </div> | |
| <div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4"> | |
| <div> | |
| <label for="add-end-date" class="block text-sm font-medium text-gray-700 mb-1">End Date</label> | |
| <input type="date" id="add-end-date" class="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" required> | |
| </div> | |
| </div> | |
| <div class="mb-4"> | |
| <label class="block text-sm font-medium text-gray-700 mb-1">Thumbnail</label> | |
| <div class="flex items-center gap-4"> | |
| <img id="add-thumbnail" src="https://via.placeholder.com/100" alt="Thumbnail" class="w-20 h-20 object-cover rounded border"> | |
| <button type="button" id="add-change-image" class="bg-gray-100 hover:bg-gray-200 text-gray-800 px-4 py-2 rounded-lg"> | |
| <i class="fas fa-image mr-2"></i>Upload Image | |
| </button> | |
| </div> | |
| </div> | |
| </form> | |
| </div> | |
| <div class="p-4 border-t flex justify-end"> | |
| <button id="cancel-add" class="bg-gray-300 hover:bg-gray-400 text-gray-800 px-4 py-2 rounded mr-2"> | |
| Cancel | |
| </button> | |
| <button id="save-new-job" class="bg-green-500 hover:bg-green-600 text-white px-4 py-2 rounded"> | |
| <i class="fas fa-plus mr-2"></i>Add Job | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| <script src="https://cdn.jsdelivr.net/npm/jquery@3.6.0/dist/jquery.min.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/moment@2.29.1/moment.min.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/daterangepicker@3.1.0/daterangepicker.min.js"></script> | |
| <script> | |
| // Global variables for data | |
| let jobsData = []; | |
| let invalidJobsData = []; | |
| let weeklyTrendData = []; | |
| let monthlyTrendData = []; | |
| let quarterlyTrendData = []; | |
| let yearlyTrendData = []; | |
| let statsData = {}; | |
| // Base URL for Node.js API endpoints | |
| const API_BASE_URL = 'http://localhost:3000/api'; | |
| // Initialize the dashboard | |
| document.addEventListener('DOMContentLoaded', async function() { | |
| // Show loading overlay | |
| document.getElementById('loading-overlay').classList.remove('hidden'); | |
| try { | |
| // Fetch all data from Node.js API | |
| await fetchAllData(); | |
| // Initialize date range picker | |
| initDateRangePicker(); | |
| // Set current week as default and fetch week's jobs | |
| const today = new Date(); | |
| const dayOfWeek = today.getDay(); | |
| const monday = new Date(today); | |
| monday.setDate(today.getDate() - (dayOfWeek === 0 ? 6 : dayOfWeek - 1)); | |
| const friday = new Date(monday); | |
| friday.setDate(monday.getDate() + 4); | |
| setCurrentWeek(); | |
| const response = await fetch(`${API_BASE_URL}/jobs?status=completed&startDate=${moment(monday).format('YYYY-MM-DD')}&endDate=${moment(friday).format('YYYY-MM-DD')}`); | |
| if (!response.ok) throw new Error('Failed to fetch jobs'); | |
| jobsData = await response.json(); | |
| // Render jobs tables | |
| renderJobsTable(); | |
| renderInvalidJobsTable(); | |
| // Render stats cards | |
| renderStatsCards(); | |
| // Initialize charts | |
| initTrendChart('week'); | |
| initPieChart(); | |
| // Set up event listeners | |
| setupEventListeners(); | |
| // Hide loading overlay | |
| document.getElementById('loading-overlay').classList.add('hidden'); | |
| } catch (error) { | |
| console.error('Error initializing dashboard:', error); | |
| document.getElementById('loading-overlay').classList.add('hidden'); | |
| showError('Failed to load dashboard data. Please try again later.'); | |
| } | |
| }); | |
| /* | |
| Required Node.js API Endpoints (using Express and node-postgres): | |
| 1. GET /api/jobs?status=completed&startDate=&endDate= | |
| - Returns completed jobs within date range (if provided) | |
| - SQL: SELECT * FROM jobs WHERE status = $1 AND ($2 IS NULL OR completion_date >= $2) | |
| AND ($3 IS NULL OR completion_date <= $3) ORDER BY completion_date DESC | |
| - Parameters: [status, startDate, endDate] | |
| 2. GET /api/jobs?status=invalid | |
| - Returns invalid jobs | |
| - SQL: SELECT * FROM jobs WHERE status = $1 ORDER BY created_at DESC | |
| - Parameters: [status] | |
| 3. GET /api/stats/trend?timeframe=week | |
| - Returns trend data for given timeframe (week/month/quarter/year) | |
| - SQL varies based on timeframe (see below for examples) | |
| 4. GET /api/stats/summary | |
| - Returns summary statistics | |
| - SQL: | |
| SELECT | |
| SUM(CASE WHEN completion_date BETWEEN CURRENT_DATE - INTERVAL '7 days' AND CURRENT_DATE THEN quantity * price ELSE 0 END) AS week_total, | |
| ...other stats... | |
| FROM jobs | |
| WHERE status = 'completed' | |
| 5. POST /api/jobs | |
| - Creates a new job | |
| - SQL: INSERT INTO jobs (job_id, customer_name, description, quantity, price, status, completion_date) | |
| VALUES ($1, $2, $3, $4, $5, $6, $7) | |
| - Parameters: [jobId, customer, description, quantity, price, 'completed', new Date()] | |
| 6. PUT /api/jobs/:id | |
| - Updates a job | |
| - SQL: UPDATE jobs SET customer_name = $1, description = $2, quantity = $3, price = $4, | |
| updated_at = NOW() WHERE job_id = $5 | |
| - Parameters: [customer, description, quantity, price, jobId] | |
| 7. DELETE /api/jobs/:id | |
| - Deletes a job | |
| - SQL: DELETE FROM jobs WHERE job_id = $1 | |
| - Parameters: [jobId] | |
| */ | |
| // Fetch all data from Node.js API | |
| async function fetchAllData() { | |
| try { | |
| // Fetch jobs data | |
| const jobsResponse = await fetch(`${API_BASE_URL}/jobs?status=completed`); | |
| if (!jobsResponse.ok) throw new Error('Failed to fetch jobs'); | |
| jobsData = await jobsResponse.json(); | |
| // Fetch invalid jobs data | |
| const invalidJobsResponse = await fetch(`${API_BASE_URL}/jobs?status=invalid`); | |
| if (!invalidJobsResponse.ok) throw new Error('Failed to fetch invalid jobs'); | |
| invalidJobsData = await invalidJobsResponse.json(); | |
| // Fetch trend data | |
| const weeklyTrendResponse = await fetch(`${API_BASE_URL}/stats/trend?timeframe=week`); | |
| if (!weeklyTrendResponse.ok) throw new Error('Failed to fetch weekly trend'); | |
| weeklyTrendData = await weeklyTrendResponse.json(); | |
| const monthlyTrendResponse = await fetch(`${API_BASE_URL}/stats/trend?timeframe=month`); | |
| if (!monthlyTrendResponse.ok) throw new Error('Failed to fetch monthly trend'); | |
| monthlyTrendData = await monthlyTrendResponse.json(); | |
| const quarterlyTrendResponse = await fetch(`${API_BASE_URL}/stats/trend?timeframe=quarter`); | |
| if (!quarterlyTrendResponse.ok) throw new Error('Failed to fetch quarterly trend'); | |
| quarterlyTrendData = await quarterlyTrendResponse.json(); | |
| const yearlyTrendResponse = await fetch(`${API_BASE_URL}/stats/trend?timeframe=year`); | |
| if (!yearlyTrendResponse.ok) throw new Error('Failed to fetch yearly trend'); | |
| yearlyTrendData = await yearlyTrendResponse.json(); | |
| // Fetch stats data | |
| const statsResponse = await fetch(`${API_BASE_URL}/stats/summary`); | |
| if (!statsResponse.ok) throw new Error('Failed to fetch stats'); | |
| const stats = await statsResponse.json(); | |
| statsData = stats[0] || {}; | |
| } catch (error) { | |
| console.error('Error fetching data:', error); | |
| throw error; | |
| } | |
| } | |
| // Render stats cards | |
| function renderStatsCards() { | |
| const statsContainer = document.getElementById('stats-cards'); | |
| statsContainer.innerHTML = ` | |
| <div class="bg-white p-6 rounded-xl shadow"> | |
| <div class="flex justify-between items-start"> | |
| <div> | |
| <p class="text-gray-500">This Week's Total</p> | |
| <p class="text-3xl font-bold text-gray-800">£${(statsData.week_total || 0).toLocaleString('en-GB', {minimumFractionDigits: 2, maximumFractionDigits: 2})}</p> | |
| </div> | |
| <div class="flex items-center"> | |
| <span class="${statsData.week_change >= 0 ? 'text-green-500' : 'text-red-500'} font-bold">${statsData.week_change >= 0 ? '+' : ''}${(statsData.week_change || 0).toFixed(1)}%</span> | |
| <i class="fas ${statsData.week_change >= 0 ? 'fa-arrow-up text-green-500' : 'fa-arrow-down text-red-500'} ml-1"></i> | |
| </div> | |
| </div> | |
| <p class="text-sm text-gray-500 mt-2">vs last week</p> | |
| </div> | |
| <div class="bg-white p-6 rounded-xl shadow"> | |
| <div class="flex justify-between items-start"> | |
| <div> | |
| <p class="text-gray-500">Jobs Completed</p> | |
| <p class="text-3xl font-bold text-gray-800">${statsData.jobs_completed || 0}</p> | |
| </div> | |
| <div class="flex items-center"> | |
| <span class="${statsData.jobs_change >= 0 ? 'text-green-500' : 'text-red-500'} font-bold">${statsData.jobs_change >= 0 ? '+' : ''}${(statsData.jobs_change || 0).toFixed(1)}%</span> | |
| <i class="fas ${statsData.jobs_change >= 0 ? 'fa-arrow-up text-green-500' : 'fa-arrow-down text-red-500'} ml-1"></i> | |
| </div> | |
| </div> | |
| <p class="text-sm text-gray-500 mt-2">vs last week</p> | |
| </div> | |
| <div class="bg-white p-6 rounded-xl shadow"> | |
| <div class="flex justify-between items-start"> | |
| <div> | |
| <p class="text-gray-500">Avg. Job Value</p> | |
| <p class="text-3xl font-bold text-gray-800">£${(statsData.avg_job_value || 0).toLocaleString('en-GB', {minimumFractionDigits: 2, maximumFractionDigits: 2})}</p> | |
| </div> | |
| <div class="flex items-center"> | |
| <span class="${statsData.avg_value_change >= 0 ? 'text-green-500' : 'text-red-500'} font-bold">${statsData.avg_value_change >= 0 ? '+' : ''}${(statsData.avg_value_change || 0).toFixed(1)}%</span> | |
| <i class="fas ${statsData.avg_value_change >= 0 ? 'fa-arrow-up text-green-500' : 'fa-arrow-down text-red-500'} ml-1"></i> | |
| </div> | |
| </div> | |
| <p class="text-sm text-gray-500 mt-2">vs last week</p> | |
| </div> | |
| `; | |
| } | |
| // Initialize date range picker | |
| function initDateRangePicker() { | |
| $('#date-range-picker').daterangepicker({ | |
| opens: 'left', | |
| autoUpdateInput: false, | |
| locale: { | |
| cancelLabel: 'Clear', | |
| format: 'DD MMM YYYY', | |
| applyLabel: 'Apply', | |
| cancelLabel: 'Cancel', | |
| fromLabel: 'From', | |
| toLabel: 'To', | |
| customRangeLabel: 'Custom', | |
| daysOfWeek: ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'], | |
| monthNames: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'], | |
| firstDay: 1 // Monday | |
| } | |
| }); | |
| $('#date-range-picker').on('apply.daterangepicker', async function(ev, picker) { | |
| $(this).val(picker.startDate.format('DD MMM YYYY') + ' - ' + picker.endDate.format('DD MMM YYYY')); | |
| updateDateRangeText(picker.startDate, picker.endDate); | |
| try { | |
| // Show loading overlay | |
| document.getElementById('loading-overlay').classList.remove('hidden'); | |
| // Fetch jobs for selected date range | |
| const response = await fetch(`${API_BASE_URL}/jobs?status=completed&startDate=${picker.startDate.format('YYYY-MM-DD')}&endDate=${picker.endDate.format('YYYY-MM-DD')}`); | |
| if (!response.ok) throw new Error('Failed to fetch jobs'); | |
| jobsData = await response.json(); | |
| renderJobsTable(); | |
| initPieChart(); // Recompute pie chart with new date range | |
| // Hide loading overlay | |
| document.getElementById('loading-overlay').classList.add('hidden'); | |
| } catch (error) { | |
| console.error('Error fetching jobs:', error); | |
| document.getElementById('loading-overlay').classList.add('hidden'); | |
| showError('Failed to load jobs for selected date range.'); | |
| } | |
| }); | |
| $('#date-range-picker').on('cancel.daterangepicker', function(ev, picker) { | |
| $(this).val(''); | |
| showError('Date range selection cleared'); | |
| }); | |
| } | |
| // Set current week (Monday to Friday) | |
| function setCurrentWeek() { | |
| const today = new Date(); | |
| const dayOfWeek = today.getDay(); // 0 (Sunday) to 6 (Saturday) | |
| // Calculate Monday of current week | |
| const monday = new Date(today); | |
| monday.setDate(today.getDate() - (dayOfWeek === 0 ? 6 : dayOfWeek - 1)); | |
| // Calculate Friday of current week | |
| const friday = new Date(monday); | |
| friday.setDate(monday.getDate() + 4); | |
| // Format dates for display | |
| const formattedMonday = moment(monday).format('DD MMM YYYY'); | |
| const formattedFriday = moment(friday).format('DD MMM YYYY'); | |
| // Update date range picker | |
| $('#date-range-picker').val(formattedMonday + ' - ' + formattedFriday); | |
| // Update date range text | |
| updateDateRangeText(monday, friday); | |
| } | |
| // Update the date range text above the table | |
| function updateDateRangeText(startDate, endDate) { | |
| const start = moment(startDate); | |
| const end = moment(endDate); | |
| if (start.isSame(end, 'day')) { | |
| document.getElementById('date-range-text').textContent = start.format('DD MMM YYYY'); | |
| } else if (start.isSame(end, 'month')) { | |
| document.getElementById('date-range-text').textContent = | |
| `${start.format('DD')}-${end.format('DD MMM YYYY')}`; | |
| } else if (start.isSame(end, 'year')) { | |
| document.getElementById('date-range-text').textContent = | |
| `${start.format('DD MMM')}-${end.format('DD MMM YYYY')}`; | |
| } else { | |
| document.getElementById('date-range-text').textContent = | |
| `${start.format('DD MMM YYYY')}-${end.format('DD MMM YYYY')}`; | |
| } | |
| } | |
| // Render the main jobs table | |
| function renderJobsTable() { | |
| const tableBody = document.getElementById('jobs-table-body'); | |
| tableBody.innerHTML = ''; | |
| if (jobsData.length === 0) { | |
| const row = document.createElement('tr'); | |
| row.innerHTML = ` | |
| <td colspan="8" class="px-6 py-4 text-center text-gray-500"> | |
| No jobs found for the selected date range | |
| </td> | |
| `; | |
| tableBody.appendChild(row); | |
| return; | |
| } | |
| jobsData.forEach(job => { | |
| const row = document.createElement('tr'); | |
| row.className = 'hover:bg-gray-50'; | |
| row.innerHTML = ` | |
| <td class="px-6 py-4 whitespace-nowrap"> | |
| <div class="font-medium text-gray-900">${job.customer_name || 'N/A'}</div> | |
| <div class="text-xs text-gray-500">${job.customer_ref || ''}</div> | |
| </td> | |
| <td class="px-6 py-4 whitespace-nowrap"> | |
| <div class="text-gray-900">${job.id}</div> | |
| </td> | |
| <td class="px-6 py-4"> | |
| <div class="text-gray-900 max-w-xs truncate">${job.description || 'No description'}</div> | |
| </td> | |
| <td class="px-6 py-4 whitespace-nowrap"> | |
| <div class="text-gray-900">${job.quantity || 0}</div> | |
| </td> | |
| <td class="px-6 py-4 whitespace-nowrap"> | |
| <div class="text-gray-900">£${parseFloat(job.item_price || 0).toFixed(2)}</div> | |
| </td> | |
| <td class="px-6 py-4 whitespace-nowrap"> | |
| <div class="font-medium text-gray-900">£${parseFloat(job.total_amount || 0).toFixed(2)}</div> | |
| </td> | |
| <td class="px-6 py-4 whitespace-nowrap"> | |
| <img src="${job.thumbnail_url || 'https://via.placeholder.com/100'}" alt="Thumbnail" class="w-12 h-12 object-cover rounded cursor-pointer thumbnail" data-image="${job.image_url || 'https://via.placeholder.com/800'}"> | |
| </td> | |
| <td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> | |
| <i class="fas fa-edit text-gray-400 hover:text-blue-500 cursor-pointer edit-icon" data-job-id="${job.job_id}"></i> | |
| </td> | |
| `; | |
| tableBody.appendChild(row); | |
| }); | |
| } | |
| // Render the invalid jobs table | |
| function renderInvalidJobsTable() { | |
| const tableBody = document.getElementById('invalid-jobs-table-body'); | |
| tableBody.innerHTML = ''; | |
| if (invalidJobsData.length === 0) { | |
| const row = document.createElement('tr'); | |
| row.innerHTML = ` | |
| <td colspan="6" class="px-6 py-4 text-center text-gray-500"> | |
| No invalid jobs found | |
| </td> | |
| `; | |
| tableBody.appendChild(row); | |
| return; | |
| } | |
| invalidJobsData.forEach(job => { | |
| const row = document.createElement('tr'); | |
| row.className = 'hover:bg-gray-50 invalid-row'; | |
| row.innerHTML = ` | |
| <td class="px-6 py-4 whitespace-nowrap"> | |
| <div class="font-medium text-gray-900">${job.customer_name || 'N/A'}</div> | |
| <div class="text-xs text-gray-500">${job.customer_ref || ''}</div> | |
| </td> | |
| <td class="px-6 py-4 whitespace-nowrap"> | |
| <div class="text-gray-900">${job.id}</div> | |
| </td> | |
| <td class="px-6 py-4"> | |
| <div class="text-gray-900 max-w-xs truncate">${job.description || 'No description'}</div> | |
| </td> | |
| <td class="px-6 py-4 whitespace-nowrap"> | |
| <div class="text-red-600">${job.missing_info || 'Unknown issue'}</div> | |
| </td> | |
| <td class="px-6 py-4 whitespace-nowrap"> | |
| <img src="${job.thumbnail_url || 'https://via.placeholder.com/100'}" alt="Thumbnail" class="w-12 h-12 object-cover rounded cursor-pointer thumbnail" data-image="${job.image_url || 'https://via.placeholder.com/800'}"> | |
| </td> | |
| <td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> | |
| <i class="fas fa-edit text-gray-400 hover:text-blue-500 cursor-pointer edit-icon" data-job-id="${job.job_id}"></i> | |
| </td> | |
| `; | |
| tableBody.appendChild(row); | |
| }); | |
| } | |
| // Initialize the trend chart | |
| let trendChart; | |
| function initTrendChart(timeframe = 'week') { | |
| const ctx = document.getElementById('trendChart').getContext('2d'); | |
| let labels, data; | |
| switch(timeframe) { | |
| case 'month': | |
| labels = monthlyTrendData.map(item => item.month); | |
| data = monthlyTrendData.map(item => item.total); | |
| break; | |
| case 'quarter': | |
| labels = quarterlyTrendData.map(item => item.quarter); | |
| data = quarterlyTrendData.map(item => item.total); | |
| break; | |
| case 'year': | |
| labels = yearlyTrendData.map(item => item.year); | |
| data = yearlyTrendData.map(item => item.total); | |
| break; | |
| default: // week | |
| labels = weeklyTrendData.map(item => item.week); | |
| data = weeklyTrendData.map(item => item.total); | |
| } | |
| if (trendChart) { | |
| trendChart.destroy(); | |
| } | |
| trendChart = new Chart(ctx, { | |
| type: 'bar', | |
| data: { | |
| labels: labels, | |
| datasets: [{ | |
| label: 'Revenue', | |
| data: data, | |
| backgroundColor: 'rgba(59, 130, 246, 0.7)', | |
| borderColor: 'rgba(59, 130, 246, 1)', | |
| borderWidth: 1 | |
| }] | |
| }, | |
| options: { | |
| responsive: true, | |
| maintainAspectRatio: false, | |
| plugins: { | |
| legend: { | |
| display: false | |
| }, | |
| tooltip: { | |
| callbacks: { | |
| label: function(context) { | |
| return `£${context.raw.toLocaleString()}`; | |
| } | |
| } | |
| } | |
| }, | |
| scales: { | |
| y: { | |
| beginAtZero: false, | |
| ticks: { | |
| callback: function(value) { | |
| return `£${value.toLocaleString()}`; | |
| } | |
| }, | |
| grid: { | |
| drawBorder: false | |
| } | |
| }, | |
| x: { | |
| grid: { | |
| display: false | |
| } | |
| } | |
| } | |
| } | |
| }); | |
| } | |
| // Initialize the pie chart | |
| function initPieChart() { | |
| // Clear any existing chart | |
| const pieChartCanvas = document.getElementById('pieChart'); | |
| const existingChart = Chart.getChart(pieChartCanvas); | |
| if (existingChart) { | |
| existingChart.destroy(); | |
| } | |
| const customerCounts = {}; | |
| jobsData.forEach(job => { | |
| const customer = job.customer_name || 'Unknown'; | |
| customerCounts[customer] = (customerCounts[customer] || 0) + 1; | |
| }); | |
| // Sort customers alphabetically | |
| const customers = Object.keys(customerCounts).sort((a, b) => a.localeCompare(b)); | |
| const counts = customers.map(customer => customerCounts[customer]); | |
| const colors = [ | |
| 'rgba(59, 130, 246, 0.7)', | |
| 'rgba(16, 185, 129, 0.7)', | |
| 'rgba(245, 158, 11, 0.7)', | |
| 'rgba(139, 92, 246, 0.7)', | |
| 'rgba(220, 38, 38, 0.7)' | |
| ]; | |
| const ctx = pieChartCanvas.getContext('2d'); | |
| new Chart(ctx, { | |
| type: 'pie', | |
| data: { | |
| labels: customers, | |
| datasets: [{ | |
| data: counts, | |
| backgroundColor: colors, | |
| borderWidth: 1 | |
| }] | |
| }, | |
| options: { | |
| responsive: true, | |
| maintainAspectRatio: false, | |
| plugins: { | |
| legend: { | |
| position: 'right', | |
| }, | |
| tooltip: { | |
| callbacks: { | |
| label: function(context) { | |
| const label = context.label || ''; | |
| const value = context.raw || 0; | |
| const total = context.dataset.data.reduce((a, b) => a + b, 0); | |
| const percentage = Math.round((value / total) * 100); | |
| return `${label}: ${value} job${value !== 1 ? 's' : ''} (${percentage}%)`; | |
| } | |
| } | |
| } | |
| } | |
| } | |
| }); | |
| } | |
| // Show error message | |
| function showError(message) { | |
| const errorAlert = document.getElementById('error-alert'); | |
| const errorMessage = document.getElementById('error-message'); | |
| errorMessage.textContent = message; | |
| errorAlert.classList.remove('hidden'); | |
| setTimeout(() => { | |
| errorAlert.classList.add('hidden'); | |
| }, 5000); | |
| } | |
| // Set up all event listeners | |
| function setupEventListeners() { | |
| // Image modal | |
| document.addEventListener('click', function(e) { | |
| if (e.target.classList.contains('thumbnail')) { | |
| const imageUrl = e.target.getAttribute('data-image'); | |
| document.getElementById('modal-image').src = imageUrl; | |
| document.getElementById('image-modal').classList.remove('opacity-0', 'pointer-events-none'); | |
| } | |
| }); | |
| // Close modal | |
| document.getElementById('close-modal').addEventListener('click', function() { | |
| document.getElementById('image-modal').classList.add('opacity-0', 'pointer-events-none'); | |
| }); | |
| // Close error alert | |
| document.getElementById('close-error').addEventListener('click', function() { | |
| document.getElementById('error-alert').classList.add('hidden'); | |
| }); | |
| // Download image | |
| document.getElementById('download-image').addEventListener('click', function() { | |
| const imageUrl = document.getElementById('modal-image').src; | |
| const link = document.createElement('a'); | |
| link.href = imageUrl; | |
| link.download = 'job-image.jpg'; | |
| document.body.appendChild(link); | |
| link.click(); | |
| document.body.removeChild(link); | |
| }); | |
| // Edit job | |
| document.addEventListener('click', function(e) { | |
| if (e.target.classList.contains('edit-icon')) { | |
| const jobId = e.target.getAttribute('data-job-id'); | |
| const job = [...jobsData, ...invalidJobsData].find(j => j.job_id === jobId); | |
| if (job) { | |
| document.getElementById('edit-job-id').value = job.id; | |
| document.getElementById('edit-job-id-display').value = job.id; | |
| document.getElementById('edit-customer').value = job.customer_name || ''; | |
| document.getElementById('edit-description').value = job.description || ''; | |
| document.getElementById('edit-quantity').value = job.quantity || ''; | |
| document.getElementById('edit-price').value = job.item_price || ''; | |
| document.getElementById('edit-total').value = job.total_amount || 0; | |
| document.getElementById('edit-end-date').value = job.end_time ? moment(job.end_time).format('YYYY-MM-DD') : ''; | |
| document.getElementById('edit-thumbnail').src = job.thumbnail_url || 'https://via.placeholder.com/100'; | |
| document.getElementById('edit-modal').classList.remove('opacity-0', 'pointer-events-none'); | |
| } | |
| } | |
| }); | |
| // Close edit modal | |
| document.getElementById('close-edit-modal').addEventListener('click', function() { | |
| document.getElementById('edit-modal').classList.add('opacity-0', 'pointer-events-none'); | |
| }); | |
| document.getElementById('cancel-edit').addEventListener('click', function() { | |
| document.getElementById('edit-modal').classList.add('opacity-0', 'pointer-events-none'); | |
| }); | |
| // Calculate total when quantity or price changes | |
| document.getElementById('edit-quantity').addEventListener('input', calculateTotal); | |
| document.getElementById('edit-price').addEventListener('input', calculateTotal); | |
| function calculateTotal() { | |
| const quantity = parseFloat(document.getElementById('edit-quantity').value) || 0; | |
| const price = parseFloat(document.getElementById('edit-price').value) || 0; | |
| const total = quantity * price; | |
| document.getElementById('edit-total').value = total.toFixed(2); | |
| } | |
| // Save job | |
| document.getElementById('save-job').addEventListener('click', async function() { | |
| const jobId = document.getElementById('edit-job-id').value; | |
| const customer = document.getElementById('edit-customer').value; | |
| const description = document.getElementById('edit-description').value; | |
| const quantity = parseFloat(document.getElementById('edit-quantity').value) || 0; | |
| const price = parseFloat(document.getElementById('edit-price').value) || 0; | |
| try { | |
| const response = await fetch(`${API_BASE_URL}/jobs/${jobId}`, { | |
| method: 'PUT', | |
| headers: { | |
| 'Content-Type': 'application/json' | |
| }, | |
| body: JSON.stringify({ | |
| customer_name: customer, | |
| description: description, | |
| quantity: quantity, | |
| price: price, | |
| end_date: document.getElementById('edit-end-date').value | |
| }) | |
| }); | |
| if (!response.ok) throw new Error('Failed to update job'); | |
| // Refresh data | |
| await fetchAllData(); | |
| renderJobsTable(); | |
| renderInvalidJobsTable(); | |
| renderStatsCards(); | |
| document.getElementById('edit-modal').classList.add('opacity-0', 'pointer-events-none'); | |
| } catch (error) { | |
| console.error('Error updating job:', error); | |
| showError('Failed to update job. Please try again.'); | |
| } | |
| }); | |
| // Delete job | |
| document.getElementById('delete-job').addEventListener('click', async function() { | |
| if (!confirm('Are you sure you want to delete this job?')) return; | |
| const jobId = document.getElementById('edit-job-id').value; | |
| try { | |
| const response = await fetch(`${API_BASE_URL}/jobs/${jobId}`, { | |
| method: 'DELETE' | |
| }); | |
| if (!response.ok) throw new Error('Failed to delete job'); | |
| // Refresh data | |
| await fetchAllData(); | |
| renderJobsTable(); | |
| renderInvalidJobsTable(); | |
| renderStatsCards(); | |
| document.getElementById('edit-modal').classList.add('opacity-0', 'pointer-events-none'); | |
| } catch (error) { | |
| console.error('Error deleting job:', error); | |
| showError('Failed to delete job. Please try again.'); | |
| } | |
| }); | |
| // Filter button toggle | |
| document.getElementById('filter-btn').addEventListener('click', function(e) { | |
| e.stopPropagation(); | |
| document.getElementById('filter-dropdown').classList.toggle('hidden'); | |
| }); | |
| // Close dropdown when clicking elsewhere | |
| document.addEventListener('click', function() { | |
| document.getElementById('filter-dropdown').classList.add('hidden'); | |
| }); | |
| // Filter option selection | |
| document.querySelectorAll('.filter-option').forEach(option => { | |
| option.addEventListener('click', function(e) { | |
| e.preventDefault(); | |
| const filter = this.getAttribute('data-filter'); | |
| // Update active state | |
| document.querySelectorAll('.filter-option').forEach(opt => { | |
| opt.classList.remove('active'); | |
| }); | |
| this.classList.add('active'); | |
| // Handle filter selection | |
| handleFilterSelection(filter); | |
| }); | |
| }); | |
| function handleFilterSelection(filter) { | |
| // You'll need to implement the actual filtering logic here | |
| // For now, just show which filter was selected | |
| console.log(`Filter selected: ${filter}`); | |
| showError(`Showing ${filter} jobs`); | |
| // Close dropdown | |
| document.getElementById('filter-dropdown').classList.add('hidden'); | |
| } | |
| // This Week button | |
| document.getElementById('this-week-btn').addEventListener('click', async function() { | |
| setCurrentWeek(); | |
| try { | |
| // Show loading overlay | |
| document.getElementById('loading-overlay').classList.remove('hidden'); | |
| // Fetch jobs for current week | |
| const today = new Date(); | |
| const dayOfWeek = today.getDay(); | |
| const monday = new Date(today); | |
| monday.setDate(today.getDate() - (dayOfWeek === 0 ? 6 : dayOfWeek - 1)); | |
| const friday = new Date(monday); | |
| friday.setDate(monday.getDate() + 4); | |
| const response = await fetch(`${API_BASE_URL}/jobs?status=completed&startDate=${moment(monday).format('YYYY-MM-DD')}&endDate=${moment(friday).format('YYYY-MM-DD')}`); | |
| if (!response.ok) throw new Error('Failed to fetch jobs'); | |
| jobsData = await response.json(); | |
| renderJobsTable(); | |
| initPieChart(); // Recompute pie chart with new date range | |
| // Hide loading overlay | |
| document.getElementById('loading-overlay').classList.add('hidden'); | |
| } catch (error) { | |
| console.error('Error fetching jobs:', error); | |
| document.getElementById('loading-overlay').classList.add('hidden'); | |
| showError('Failed to load jobs for current week.'); | |
| } | |
| }); | |
| // Search functionality | |
| document.getElementById('search').addEventListener('input', function() { | |
| const searchTerm = this.value.toLowerCase(); | |
| if (searchTerm.length < 2) { | |
| renderJobsTable(); | |
| return; | |
| } | |
| const filteredJobs = jobsData.filter(job => | |
| (job.customer_name && job.customer_name.toLowerCase().includes(searchTerm)) || | |
| (job.id && job.id.toString().includes(searchTerm)) || | |
| (job.description && job.description.toLowerCase().includes(searchTerm)) || | |
| (job.customer_ref && job.customer_ref.toLowerCase().includes(searchTerm)) | |
| ); | |
| const tableBody = document.getElementById('jobs-table-body'); | |
| tableBody.innerHTML = ''; | |
| if (filteredJobs.length === 0) { | |
| const row = document.createElement('tr'); | |
| row.innerHTML = ` | |
| <td colspan="8" class="px-6 py-4 text-center text-gray-500"> | |
| No jobs match your search criteria | |
| </td> | |
| `; | |
| tableBody.appendChild(row); | |
| return; | |
| } | |
| filteredJobs.forEach(job => { | |
| const row = document.createElement('tr'); | |
| row.className = 'hover:bg-gray-50'; | |
| row.innerHTML = ` | |
| <td class="px-6 py-4 whitespace-nowrap"> | |
| <div class="font-medium text-gray-900">${job.customer_name || 'N/A'}</div> | |
| </td> | |
| <td class="px-6 py-4 whitespace-nowrap"> | |
| <div class="text-gray-900">${job.job_id}</div> | |
| </td> | |
| <td class="px-6 py-4"> | |
| <div class="text-gray-900 max-w-xs truncate">${job.description || 'No description'}</div> | |
| </td> | |
| <td class="px-6 py-4 whitespace-nowrap"> | |
| <div class="text-gray-900">${job.quantity || 0}</div> | |
| </td> | |
| <td class="px-6 py-4 whitespace-nowrap"> | |
| <div class="text-gray-900">£${(job.price || 0).toFixed(2)}</div> | |
| </td> | |
| <td class="px-6 py-4 whitespace-nowrap"> | |
| <div class="font-medium text-gray-900">£${((job.quantity || 0) * (job.price || 0)).toFixed(2)}</div> | |
| </td> | |
| <td class="px-6 py-4 whitespace-nowrap"> | |
| <img src="${job.thumbnail_url || 'https://via.placeholder.com/100'}" alt="Thumbnail" class="w-12 h-12 object-cover rounded cursor-pointer thumbnail" data-image="${job.image_url || 'https://via.placeholder.com/800'}"> | |
| </td> | |
| <td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> | |
| <i class="fas fa-edit text-gray-400 hover:text-blue-500 cursor-pointer edit-icon" data-job-id="${job.job_id}"></i> | |
| </td> | |
| `; | |
| tableBody.appendChild(row); | |
| }); | |
| }); | |
| // Column sorting | |
| document.querySelectorAll('.sortable').forEach(header => { | |
| header.addEventListener('click', function() { | |
| const column = this.getAttribute('data-column'); | |
| // Toggle sort direction indicator | |
| document.querySelectorAll('.sortable').forEach(h => { | |
| h.classList.remove('sort-asc', 'sort-desc'); | |
| }); | |
| const isAsc = this.classList.contains('sort-asc'); | |
| this.classList.remove('sort-asc', 'sort-desc'); | |
| this.classList.add(isAsc ? 'sort-desc' : 'sort-asc'); | |
| // Sort the data | |
| jobsData.sort((a, b) => { | |
| let valA = a[column] || ''; | |
| let valB = b[column] || ''; | |
| // Handle numeric columns | |
| if (column === 'quantity' || column === 'item_price' || column === 'total_amount') { | |
| valA = parseFloat(valA) || 0; | |
| valB = parseFloat(valB) || 0; | |
| return isAsc ? valB - valA : valA - valB; | |
| } | |
| // Handle string columns | |
| valA = String(valA).toLowerCase(); | |
| valB = String(valB).toLowerCase(); | |
| return isAsc ? | |
| valB.localeCompare(valA) : | |
| valA.localeCompare(valB); | |
| }); | |
| renderJobsTable(); | |
| }); | |
| }); | |
| // Timeframe buttons for trend chart | |
| document.querySelectorAll('.timeframe-btn').forEach(btn => { | |
| btn.addEventListener('click', function() { | |
| const timeframe = this.getAttribute('data-timeframe'); | |
| // Update active state | |
| document.querySelectorAll('.timeframe-btn').forEach(b => { | |
| b.classList.remove('active'); | |
| }); | |
| this.classList.add('active'); | |
| // Update chart | |
| initTrendChart(timeframe); | |
| }); | |
| }); | |
| // Add Job button | |
| document.getElementById('add-job-btn').addEventListener('click', function() { | |
| // Reset form | |
| document.getElementById('add-job-form').reset(); | |
| document.getElementById('add-thumbnail').src = 'https://via.placeholder.com/100'; | |
| // Show modal | |
| document.getElementById('add-modal').classList.remove('opacity-0', 'pointer-events-none'); | |
| }); | |
| // Close add modal | |
| document.getElementById('close-add-modal').addEventListener('click', function() { | |
| document.getElementById('add-modal').classList.add('opacity-0', 'pointer-events-none'); | |
| }); | |
| document.getElementById('cancel-add').addEventListener('click', function() { | |
| document.getElementById('add-modal').classList.add('opacity-0', 'pointer-events-none'); | |
| }); | |
| // Calculate total for add form | |
| document.getElementById('add-quantity').addEventListener('input', calculateAddTotal); | |
| document.getElementById('add-price').addEventListener('input', calculateAddTotal); | |
| function calculateAddTotal() { | |
| const quantity = parseFloat(document.getElementById('add-quantity').value) || 0; | |
| const price = parseFloat(document.getElementById('add-price').value) || 0; | |
| const total = quantity * price; | |
| document.getElementById('add-total').value = total.toFixed(2); | |
| } | |
| // Save new job | |
| document.getElementById('save-new-job').addEventListener('click', async function() { | |
| const customer = document.getElementById('add-customer').value; | |
| const jobId = document.getElementById('add-job-id').value; | |
| const description = document.getElementById('add-description').value; | |
| const quantity = parseFloat(document.getElementById('add-quantity').value) || 0; | |
| const price = parseFloat(document.getElementById('add-price').value) || 0; | |
| const thumbnailUrl = document.getElementById('add-thumbnail').src; | |
| // Simple validation | |
| if (!customer || !jobId || !description) { | |
| showError('Please fill in all required fields'); | |
| return; | |
| } | |
| try { | |
| // Show loading overlay | |
| document.getElementById('loading-overlay').classList.remove('hidden'); | |
| // Insert new job | |
| const response = await fetch(`${API_BASE_URL}/jobs`, { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json' | |
| }, | |
| body: JSON.stringify({ | |
| job_id: jobId, | |
| customer_name: customer, | |
| description: description, | |
| quantity: quantity, | |
| price: price, | |
| end_date: document.getElementById('add-end-date').value, | |
| thumbnail_url: thumbnailUrl, | |
| image_url: thumbnailUrl.replace('100', '800'), | |
| status: 'completed' | |
| }) | |
| }); | |
| if (!response.ok) throw new Error('Failed to add job'); | |
| // Refresh data | |
| await fetchAllData(); | |
| renderJobsTable(); | |
| renderInvalidJobsTable(); | |
| renderStatsCards(); | |
| // Hide modals and loading overlay | |
| document.getElementById('add-modal').classList.add('opacity-0', 'pointer-events-none'); | |
| document.getElementById('loading-overlay').classList.add('hidden'); | |
| showError('Job added successfully!'); | |
| } catch (error) { | |
| console.error('Error adding job:', error); | |
| document.getElementById('loading-overlay').classList.add('hidden'); | |
| showError('Failed to add job. Please try again.'); | |
| } | |
| }); | |
| } | |
| </script> | |
| <p style="border-radius: 8px; text-align: center; font-size: 12px; color: #fff; margin-top: 16px;position: fixed; left: 8px; bottom: 8px; z-index: 10; background: rgba(0, 0, 0, 0.8); padding: 4px 8px;">Made with <img src="https://enzostvs-deepsite.hf.space/logo.svg" alt="DeepSite Logo" style="width: 16px; height: 16px; vertical-align: middle;display:inline-block;margin-right:3px;filter:brightness(0) invert(1);"><a href="https://enzostvs-deepsite.hf.space" style="color: #fff;text-decoration: underline;" target="_blank" >DeepSite</a> - 🧬 <a href="https://enzostvs-deepsite.hf.space?remix=TheHoodedFoot/psql-nodejs-dashboard" style="color: #fff;text-decoration: underline;" target="_blank" >Remix</a></p><p style="border-radius: 8px; text-align: center; font-size: 12px; color: #fff; margin-top: 16px;position: fixed; left: 8px; bottom: 8px; z-index: 10; background: rgba(0, 0, 0, 0.8); padding: 4px 8px;">Made with <img src="https://enzostvs-deepsite.hf.space/logo.svg" alt="DeepSite Logo" style="width: 16px; height: 16px; vertical-align: middle;display:inline-block;margin-right:3px;filter:brightness(0) invert(1);"><a href="https://enzostvs-deepsite.hf.space" style="color: #fff;text-decoration: underline;" target="_blank" >DeepSite</a> - 🧬 <a href="https://enzostvs-deepsite.hf.space?remix=TheHoodedFoot/filter-button" style="color: #fff;text-decoration: underline;" target="_blank" >Remix</a></p></body> | |
| </html> |