| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <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; |
| } |
| .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"> |
| |
| <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> |
|
|
| |
| <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"> |
| |
| <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"> |
| |
| <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> |
| |
| |
| <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> |
|
|
| |
| <div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8" id="stats-cards"> |
| |
| </div> |
|
|
| |
| <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> |
|
|
| |
| <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> |
| </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"> |
| |
| </tbody> |
| </table> |
| </div> |
| </div> |
|
|
| |
| <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"> |
| |
| </tbody> |
| </table> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <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> |
|
|
| |
| <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="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> |
|
|
| |
| <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="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> |
| |
| let jobsData = []; |
| let invalidJobsData = []; |
| let weeklyTrendData = []; |
| let monthlyTrendData = []; |
| let quarterlyTrendData = []; |
| let yearlyTrendData = []; |
| let statsData = {}; |
| |
| |
| const API_BASE_URL = 'http://localhost:3000/api'; |
| |
| |
| document.addEventListener('DOMContentLoaded', async function() { |
| |
| document.getElementById('loading-overlay').classList.remove('hidden'); |
| |
| try { |
| |
| await fetchAllData(); |
| |
| |
| initDateRangePicker(); |
| |
| |
| setCurrentWeek(); |
| |
| |
| renderJobsTable(); |
| renderInvalidJobsTable(); |
| |
| |
| renderStatsCards(); |
| |
| |
| initTrendChart('week'); |
| initPieChart(); |
| |
| |
| setupEventListeners(); |
| |
| |
| 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.'); |
| } |
| }); |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async function fetchAllData() { |
| try { |
| |
| const jobsResponse = await fetch(`${API_BASE_URL}/jobs?status=completed`); |
| if (!jobsResponse.ok) throw new Error('Failed to fetch jobs'); |
| jobsData = await jobsResponse.json(); |
| |
| |
| 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(); |
| |
| |
| 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(); |
| |
| |
| 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; |
| } |
| } |
| |
| |
| 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> |
| `; |
| } |
| |
| |
| 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 |
| } |
| }); |
| |
| $('#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 { |
| |
| document.getElementById('loading-overlay').classList.remove('hidden'); |
| |
| |
| 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(); |
| |
| |
| 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'); |
| }); |
| } |
| |
| |
| function setCurrentWeek() { |
| 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 formattedMonday = moment(monday).format('DD MMM YYYY'); |
| const formattedFriday = moment(friday).format('DD MMM YYYY'); |
| |
| |
| $('#date-range-picker').val(formattedMonday + ' - ' + formattedFriday); |
| |
| |
| updateDateRangeText(monday, friday); |
| } |
| |
| |
| 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')}`; |
| } |
| } |
| |
| |
| 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> |
| </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); |
| }); |
| } |
| |
| |
| 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> |
| </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-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); |
| }); |
| } |
| |
| |
| 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: |
| 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 |
| } |
| } |
| } |
| } |
| }); |
| } |
| |
| |
| function initPieChart() { |
| const customerCounts = {}; |
| jobsData.forEach(job => { |
| const customer = job.customer_name || 'Unknown'; |
| customerCounts[customer] = (customerCounts[customer] || 0) + 1; |
| }); |
| |
| const customers = Object.keys(customerCounts); |
| const counts = Object.values(customerCounts); |
| |
| 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 = document.getElementById('pieChart').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}%)`; |
| } |
| } |
| } |
| } |
| } |
| }); |
| } |
| |
| |
| 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); |
| } |
| |
| |
| function setupEventListeners() { |
| |
| 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'); |
| } |
| }); |
| |
| |
| document.getElementById('close-modal').addEventListener('click', function() { |
| document.getElementById('image-modal').classList.add('opacity-0', 'pointer-events-none'); |
| }); |
| |
| |
| document.getElementById('close-error').addEventListener('click', function() { |
| document.getElementById('error-alert').classList.add('hidden'); |
| }); |
| |
| |
| 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); |
| }); |
| |
| |
| 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.job_id; |
| document.getElementById('edit-job-id-display').value = job.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.price || ''; |
| document.getElementById('edit-total').value = (job.quantity || 0) * (job.price || 0); |
| document.getElementById('edit-thumbnail').src = job.thumbnail_url || 'https://via.placeholder.com/100'; |
| |
| document.getElementById('edit-modal').classList.remove('opacity-0', 'pointer-events-none'); |
| } |
| } |
| }); |
| |
| |
| 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'); |
| }); |
| |
| |
| 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); |
| } |
| |
| |
| 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 |
| }) |
| }); |
| |
| if (!response.ok) throw new Error('Failed to update job'); |
| |
| |
| 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.'); |
| } |
| }); |
| |
| |
| 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'); |
| |
| |
| 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.'); |
| } |
| }); |
| |
| |
| document.getElementById('this-week-btn').addEventListener('click', async function() { |
| setCurrentWeek(); |
| |
| try { |
| |
| document.getElementById('loading-overlay').classList.remove('hidden'); |
| |
| |
| 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(); |
| |
| |
| 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.'); |
| } |
| }); |
| |
| |
| 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.job_id && job.job_id.toLowerCase().includes(searchTerm)) || |
| (job.description && job.description.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); |
| }); |
| }); |
| |
| |
| document.querySelectorAll('.sortable').forEach(header => { |
| header.addEventListener('click', function() { |
| const column = this.getAttribute('data-column'); |
| |
| |
| 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'); |
| |
| |
| jobsData.sort((a, b) => { |
| let valA = a[column] || ''; |
| let valB = b[column] || ''; |
| |
| |
| if (column === 'quantity' || column === 'price' || column === 'total') { |
| valA = parseFloat(valA) || 0; |
| valB = parseFloat(valB) || 0; |
| return isAsc ? valB - valA : valA - valB; |
| } |
| |
| |
| valA = String(valA).toLowerCase(); |
| valB = String(valB).toLowerCase(); |
| return isAsc ? |
| valB.localeCompare(valA) : |
| valA.localeCompare(valB); |
| }); |
| |
| renderJobsTable(); |
| }); |
| }); |
| |
| |
| document.querySelectorAll('.timeframe-btn').forEach(btn => { |
| btn.addEventListener('click', function() { |
| const timeframe = this.getAttribute('data-timeframe'); |
| |
| |
| document.querySelectorAll('.timeframe-btn').forEach(b => { |
| b.classList.remove('active'); |
| }); |
| this.classList.add('active'); |
| |
| |
| initTrendChart(timeframe); |
| }); |
| }); |
| |
| |
| document.getElementById('add-job-btn').addEventListener('click', function() { |
| |
| document.getElementById('add-job-form').reset(); |
| document.getElementById('add-thumbnail').src = 'https://via.placeholder.com/100'; |
| |
| |
| document.getElementById('add-modal').classList.remove('opacity-0', 'pointer-events-none'); |
| }); |
| |
| |
| 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'); |
| }); |
| |
| |
| 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); |
| } |
| |
| |
| 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; |
| |
| |
| if (!customer || !jobId || !description) { |
| showError('Please fill in all required fields'); |
| return; |
| } |
| |
| try { |
| |
| document.getElementById('loading-overlay').classList.remove('hidden'); |
| |
| |
| 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, |
| thumbnail_url: thumbnailUrl, |
| image_url: thumbnailUrl.replace('100', '800'), |
| status: 'completed' |
| }) |
| }); |
| |
| if (!response.ok) throw new Error('Failed to add job'); |
| |
| |
| await fetchAllData(); |
| renderJobsTable(); |
| renderInvalidJobsTable(); |
| renderStatsCards(); |
| |
| |
| 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></body> |
| </html> |