psql-nodejs-dashboard / index.html
TheHoodedFoot's picture
Remove the Invalid Jobs table - Follow Up Deployment
449eef4 verified
<!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">
<!-- 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>
<!-- 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>
</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>
</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="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="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 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
setCurrentWeek();
// Render jobs tables
renderJobsTable();
renderInvalidJobsTable();
// Render stats cards
renderStatsCards();
// 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();
// 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>
</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);
});
}
// 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>
</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);
});
}
// 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.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');
}
}
});
// 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
})
});
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.');
}
});
// 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();
// 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.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);
});
});
// 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 === 'price' || column === 'total') {
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();
});
});
// 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,
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></body>
</html>