|
<!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="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> |
|
|
|
|
|
<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 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(); |
|
|
|
|
|
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); |
|
}); |
|
} |
|
|
|
|
|
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.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> |