Files
PRISM/public/jobs.html
T

1399 lines
51 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PRISM Jobs - Construction Job Management</title>
<style>
:root {
--gruvbox-bg0: #282828;
--gruvbox-bg1: #3c3836;
--gruvbox-bg2: #504945;
--gruvbox-bg3: #665c54;
--gruvbox-fg0: #fbf1c7;
--gruvbox-fg1: #ebdbb2;
--gruvbox-fg2: #d5c4a1;
--gruvbox-orange: #fe8019;
--gruvbox-green: #b8bb26;
--gruvbox-blue: #83a598;
--gruvbox-purple: #d3869b;
--gruvbox-red: #fb4934;
--gruvbox-yellow: #fabd2f;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'SF Pro Display', 'Segoe UI', system-ui, -apple-system, sans-serif;
background: linear-gradient(135deg, var(--gruvbox-bg0) 0%, var(--gruvbox-bg2) 50%, var(--gruvbox-bg1) 100%);
min-height: 100vh;
color: var(--gruvbox-fg1);
}
.main-content {
padding: 2rem;
max-width: 1400px;
margin: 0 auto;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.page-title {
font-size: 2rem;
font-weight: 700;
color: var(--gruvbox-orange);
}
.actions {
display: flex;
gap: 1rem;
}
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.btn-primary {
background: linear-gradient(135deg, var(--gruvbox-blue) 0%, var(--gruvbox-purple) 100%);
color: var(--gruvbox-bg0);
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(131, 165, 152, 0.3);
}
.btn-secondary {
background: var(--gruvbox-bg2);
color: var(--gruvbox-fg1);
border: 1px solid var(--gruvbox-bg3);
}
.btn-secondary:hover {
background: var(--gruvbox-bg3);
transform: translateY(-1px);
}
.jobs-table-container {
background: var(--gruvbox-bg1);
border-radius: 15px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
border: 1px solid var(--gruvbox-bg3);
backdrop-filter: blur(10px);
overflow: hidden;
}
.table-header {
padding: 1.5rem;
border-bottom: 1px solid var(--gruvbox-bg3);
display: flex;
justify-content: space-between;
align-items: center;
}
.table-title {
font-size: 1.25rem;
font-weight: 600;
color: var(--gruvbox-fg0);
}
.search-filter {
display: flex;
gap: 1rem;
align-items: center;
}
.search-input {
padding: 0.5rem 1rem;
border: 2px solid var(--gruvbox-bg3);
border-radius: 6px;
background: var(--gruvbox-bg2);
color: var(--gruvbox-fg1);
font-size: 0.9rem;
}
.search-input:focus {
outline: none;
border-color: var(--gruvbox-blue);
box-shadow: 0 0 0 3px rgba(131, 165, 152, 0.1);
}
.filter-select {
padding: 0.5rem;
border: 2px solid var(--gruvbox-bg3);
border-radius: 6px;
background: var(--gruvbox-bg2);
color: var(--gruvbox-fg1);
font-size: 0.9rem;
}
.jobs-table {
width: 100%;
border-collapse: collapse;
}
.jobs-table th,
.jobs-table td {
padding: 1rem;
text-align: left;
border-bottom: 1px solid var(--gruvbox-bg3);
}
.jobs-table th {
background: var(--gruvbox-bg2);
color: var(--gruvbox-fg0);
font-weight: 600;
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 0.5px;
position: sticky;
top: 0;
z-index: 10;
}
.jobs-table tbody tr {
transition: background 0.3s ease;
}
.jobs-table tbody tr:hover {
background: rgba(131, 165, 152, 0.05);
}
.job-name {
font-weight: 600;
color: var(--gruvbox-fg0);
}
.job-status {
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
}
.status-active {
background: rgba(184, 187, 38, 0.2);
color: var(--gruvbox-green);
}
.status-completed {
background: rgba(131, 165, 152, 0.2);
color: var(--gruvbox-blue);
}
.status-paused {
background: rgba(250, 173, 47, 0.2);
color: var(--gruvbox-yellow);
}
.status-cancelled {
background: rgba(251, 73, 52, 0.2);
color: var(--gruvbox-red);
}
.job-budget {
font-weight: 600;
color: var(--gruvbox-green);
}
.job-dates {
color: var(--gruvbox-fg2);
font-size: 0.9rem;
}
.job-actions {
display: flex;
gap: 0.5rem;
}
.action-btn {
padding: 0.375rem 0.75rem;
border: none;
border-radius: 4px;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
}
.btn-edit {
background: var(--gruvbox-blue);
color: var(--gruvbox-bg0);
}
.btn-edit:hover {
background: var(--gruvbox-purple);
transform: translateY(-1px);
}
.btn-delete {
background: var(--gruvbox-red);
color: var(--gruvbox-fg0);
}
.btn-delete:hover {
background: var(--gruvbox-orange);
transform: translateY(-1px);
}
.loading {
display: flex;
justify-content: center;
align-items: center;
padding: 3rem;
flex-direction: column;
gap: 1rem;
}
.loading-spinner {
width: 50px;
height: 50px;
border: 4px solid var(--gruvbox-bg3);
border-top: 4px solid var(--gruvbox-orange);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.error-message {
background: rgba(251, 73, 52, 0.1);
color: var(--gruvbox-red);
padding: 1.25rem;
border-radius: 8px;
margin: 1rem 0;
border: 1px solid rgba(251, 73, 52, 0.3);
backdrop-filter: blur(5px);
}
.empty-state {
text-align: center;
padding: 3rem;
color: var(--gruvbox-fg2);
}
.empty-state h3 {
color: var(--gruvbox-fg1);
margin-bottom: 1rem;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 1rem;
padding: 1.5rem;
border-top: 1px solid var(--gruvbox-bg3);
}
.pagination-btn {
padding: 0.5rem 1rem;
border: 1px solid var(--gruvbox-bg3);
background: var(--gruvbox-bg2);
color: var(--gruvbox-fg1);
border-radius: 6px;
cursor: pointer;
transition: all 0.3s ease;
}
.pagination-btn:hover:not(:disabled) {
background: var(--gruvbox-bg3);
}
.pagination-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pagination-info {
color: var(--gruvbox-fg2);
font-size: 0.9rem;
}
/* Modal Styles */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(40, 40, 40, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
backdrop-filter: blur(5px);
}
.modal-content {
background: var(--gruvbox-bg1);
border-radius: 15px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
max-width: 500px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
border: 1px solid var(--gruvbox-bg3);
}
.popup-content {
max-width: 400px;
width: 95%;
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
border-bottom: 1px solid var(--gruvbox-bg3);
}
.modal-header h2 {
color: var(--gruvbox-orange);
margin: 0;
font-size: 1.25rem;
font-weight: 600;
}
.modal-close {
background: none;
border: none;
color: var(--gruvbox-fg2);
font-size: 1.5rem;
cursor: pointer;
padding: 0.25rem;
border-radius: 4px;
transition: all 0.3s ease;
}
.modal-close:hover {
background: var(--gruvbox-bg3);
color: var(--gruvbox-fg0);
}
#job-form {
padding: 1.5rem;
}
#job-form .form-group {
margin-bottom: 1.25rem;
}
#job-form .form-group:last-child {
margin-bottom: 0;
}
.modal-actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
padding: 1.5rem;
border-top: 1px solid var(--gruvbox-bg3);
margin-top: 1.5rem;
}
@media (max-width: 768px) {
.main-content {
padding: 1rem;
}
.page-header {
flex-direction: column;
gap: 1rem;
align-items: stretch;
}
.actions {
justify-content: center;
}
.jobs-table {
font-size: 0.8rem;
}
.jobs-table th,
.jobs-table td {
padding: 0.5rem;
}
.search-filter {
flex-direction: column;
width: 100%;
}
.search-input,
.filter-select {
width: 100%;
}
}
</style>
</head>
<body>
<main class="main-content">
<div class="page-header">
<h1 class="page-title">Construction Jobs</h1>
<div class="actions">
<button class="btn btn-secondary" onclick="refreshJobs()">
🔄 Refresh
</button>
<button class="btn btn-primary" onclick="showAddJobModal()">
Add Job
</button>
</div>
</div>
<div class="jobs-table-container">
<div class="table-header">
<h2 class="table-title">All Jobs</h2>
<div class="search-filter">
<input type="text" id="search-input" class="search-input" placeholder="Search jobs..." onkeyup="filterJobs()">
</div>
</div>
<div id="loading" class="loading">
<div class="loading-spinner"></div>
<p>Loading jobs...</p>
</div>
<div id="error-content" class="error-message" style="display: none;">
Unable to load jobs. Please try refreshing the page.
</div>
<div id="jobs-content" style="display: none;">
<table class="jobs-table" id="jobs-table">
<thead>
<tr id="table-headers">
<!-- Headers will be generated dynamically -->
</tr>
</thead>
<tbody id="jobs-tbody">
<!-- Jobs will be loaded here -->
</tbody>
</table>
<div id="empty-state" class="empty-state" style="display: none;">
<h3>No jobs found</h3>
<p>Get started by adding your first construction job.</p>
</div>
</div>
<div class="pagination" id="pagination" style="display: none;">
<button class="pagination-btn" id="prev-btn" onclick="changePage(-1)">Previous</button>
<span class="pagination-info" id="page-info">Page 1 of 1</span>
<button class="pagination-btn" id="next-btn" onclick="changePage(1)">Next</button>
</div>
</div>
</main>
<script type="module">
import PocketBase from '/pocketbase.es.mjs';
import { initMenuBar, getCurrentPage } from '/components.js';
// Initialize PocketBase
const pb = new PocketBase('https://pocketbase.ccllc.pro');
// API key for admin operations (fetched from 'api' collection)
let API_KEY = null;
// State management
let allJobs = [];
let filteredJobs = [];
let currentPage = 1;
const itemsPerPage = 10;
let collectionSchema = null;
// Initialize page
async function initJobsPage() {
try {
// Check authentication
if (!pb.authStore.isValid) {
window.location.href = '/';
return;
}
// Initialize menu bar
const menuBar = initMenuBar(getCurrentPage());
const user = pb.authStore.model;
menuBar.updateUserName(user.name || user.email);
// Debug: Check authentication and user details
console.log('Current user:', user);
console.log('Auth store:', pb.authStore);
console.log('Is authenticated:', pb.authStore.isValid);
// Debug: Check what collections are available
await debugCollections();
// Fetch API key from 'api' collection for admin operations
await loadApiKey();
// Load collection schema first, then jobs
await loadCollectionSchema();
await loadJobs();
} catch (error) {
console.error('Jobs page initialization failed:', error.message);
showError('Failed to initialize jobs page. Please try logging in again.');
setTimeout(() => {
window.location.href = '/';
}, 3000);
}
}
/**
* Fetch API key from the 'api' collection
* The key is stored in a field called 'API_KEY'
*/
async function loadApiKey() {
// Try different collection name variations
const collectionNames = ['api', 'API', 'Api'];
for (const collectionName of collectionNames) {
try {
console.log(`Trying to load API key from collection: ${collectionName}`);
// Fetch the API key from the collection
const records = await pb.collection(collectionName).getFullList({
sort: '-created'
});
console.log(`Found ${records?.length || 0} record(s) in ${collectionName} collection`);
if (records && records.length > 0) {
const firstRecord = records[0];
console.log(`First record fields:`, Object.keys(firstRecord));
console.log(`First record data:`, firstRecord);
// Try different field name variations
// Priority: API_KEY (as shown in PocketBase admin), then other variations
API_KEY = firstRecord.API_KEY ||
firstRecord.Key ||
firstRecord.key ||
firstRecord.KEY ||
firstRecord.api_key ||
firstRecord.apiKey ||
firstRecord.ApiKey ||
null;
if (API_KEY) {
console.log(`✅ API key loaded successfully from ${collectionName} collection`);
return; // Success, exit function
} else {
console.warn(`API key field not found in ${collectionName} collection record. Available fields:`, Object.keys(firstRecord));
}
} else {
console.warn(`No records found in ${collectionName} collection`);
}
} catch (error) {
console.warn(`Could not load from ${collectionName} collection:`, error.message);
// Try next collection name
}
}
// If we get here, no API key was found
console.warn('⚠️ API key not found in any api collection. Operations will use user authentication.');
}
async function addressToCoordinates(address) {
// Check if input is already coordinates (format: "lat,lng" or "lat, lng")
const coordPattern = /^(-?\d+\.?\d*),\s*(-?\d+\.?\d*)$/;
const match = address.trim().match(coordPattern);
if (match) {
// Already coordinates, parse and return as object
const lat = parseFloat(match[1]);
const lon = parseFloat(match[2]);
return { lat, lon };
}
// Try to geocode the address using a geocoding service
// Using OpenStreetMap Nominatim (free, no API key required)
try {
const encodedAddress = encodeURIComponent(address);
const response = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodedAddress}&limit=1`);
const data = await response.json();
if (data && data.length > 0) {
const lat = parseFloat(data[0].lat);
const lon = parseFloat(data[0].lon);
return { lat, lon };
} else {
console.warn('Could not geocode address:', address);
// Return null if geocoding fails (don't include Job_Address)
return null;
}
} catch (error) {
console.error('Geocoding error:', error);
// Return null if geocoding fails (don't include Job_Address)
return null;
}
}
async function getNextJobNumber() {
try {
// Use getFirstListItem to get only the highest job number (most efficient)
// Sort by Job_Number descending to get the highest first
const highestJob = await pb.collection('Jobs').getFirstListItem('', {
sort: '-Job_Number'
});
const highestJobNumber = highestJob.Job_Number || 0;
const nextJobNumber = highestJobNumber + 1;
// Ensure it's within 1000-9999 range
if (nextJobNumber < 1000) {
return 1000;
} else if (nextJobNumber > 9999) {
throw new Error('Maximum job number (9999) reached');
}
return nextJobNumber;
} catch (error) {
// If no records exist or any other error, start with 1000
if (error.status === 404 || error.message?.includes('not found')) {
// No jobs exist, start with 1000
return 1000;
}
console.error('Error getting next job number:', error);
// Fallback: start with 1000 if we can't determine the next number
return 1000;
}
}
async function debugCollections() {
try {
console.log('=== DEBUGGING COLLECTIONS ===');
// Try different collection names (only valid ones)
const collectionNames = ['jobs', 'Jobs'];
for (const collectionName of collectionNames) {
try {
console.log(`Trying collection: ${collectionName}`);
const records = await pb.collection(collectionName).getList(1, 10);
console.log(`${collectionName} found with ${records.totalItems} items`);
if (records.totalItems > 0) {
console.log(`${collectionName} sample item:`, records.items[0]);
}
} catch (error) {
console.log(`${collectionName} error:`, error.message);
}
}
console.log('=== END DEBUG ===');
} catch (error) {
console.warn('Debug collections failed:', error.message);
}
}
async function loadCollectionSchema() {
// Try to fetch real schema from PocketBase
try {
// Use API key for schema access if available (bypasses collection rules)
let collection;
if (API_KEY) {
// Make request with API key in header
const response = await fetch(`${pb.baseUrl}/api/collections/Jobs`, {
headers: {
'Authorization': `Bearer ${API_KEY}`
}
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
collection = await response.json();
} else {
// Use standard PocketBase client (may fail with 403 if no permissions)
collection = await pb.collections.getOne('Jobs');
}
console.log('Fetched collection:', collection);
console.log('Collection schema:', collection.schema);
if (collection.schema && collection.schema.length > 0) {
// Map PocketBase schema to our format
collectionSchema = collection.schema.map(field => ({
name: field.name,
type: field.type,
required: field.required || false,
options: field.options || {}
}));
console.log('Real collection schema loaded with', collectionSchema.length, 'fields');
console.log('Schema details:', collectionSchema.map(f => ({
name: f.name,
type: f.type,
required: f.required
})));
} else {
throw new Error('Collection schema is empty');
}
} catch (error) {
console.warn('Could not fetch collection schema, using defaults:', error.message);
// Fallback to predefined schema
collectionSchema = [
{ name: 'Job_Number', type: 'number' },
{ name: 'Job_Name', type: 'text' },
{ name: 'Job_Address', type: 'text' },
{ name: 'updated', type: 'date' }
];
console.log('Using predefined collection schema with', collectionSchema.length, 'fields');
}
updateUIBasedOnSchema();
}
function updateUIBasedOnSchema() {
// Schema is now fixed with just Job_Number, Job_Full_Name, and updated
// No dynamic UI updates needed
}
async function loadJobs() {
try {
showLoading(true);
console.log('Attempting to load jobs from collection...');
const collectionUsed = 'Jobs';
// Use getFullList to fetch all records at once (better for client-side filtering)
// This provides instant filtering without server round-trips
const records = await pb.collection(collectionUsed).getFullList({
sort: '-updated'
});
allJobs = records || [];
filteredJobs = [...allJobs];
console.log(`Jobs response from collection "${collectionUsed}":`, records);
console.log('Number of jobs found:', allJobs.length);
// Debug: Log each job's structure
if (allJobs.length > 0) {
console.log('Sample job structure:', allJobs[0]);
console.log('Job fields:', Object.keys(allJobs[0]));
}
// Apply existing search filter if any
const searchInput = document.getElementById('search-input');
if (searchInput && searchInput.value.trim()) {
filterJobs();
} else {
displayJobs();
updatePagination();
}
if (allJobs.length === 0) {
console.log('No jobs found - showing empty state');
} else {
console.log(`Loaded ${allJobs.length} job(s) successfully from collection "${collectionUsed}"`);
}
} catch (error) {
console.error('Error loading jobs:', error);
console.error('Error details:', {
message: error.message,
status: error.response?.status,
data: error.response?.data,
url: error.config?.url
});
// Handle specific error cases
if (error.response?.status === 403) {
showError('Access denied to jobs collection. Please check your permissions in PocketBase.');
} else if (error.response?.status === 404) {
showError('Jobs collection not found. Please create the "jobs" collection in PocketBase. Tried: jobs, Jobs');
} else {
showError(`Failed to load jobs: ${error.message}`);
}
// Show empty state
allJobs = [];
filteredJobs = [];
displayJobs();
updatePagination();
} finally {
showLoading(false);
}
}
function displayJobs() {
const tbody = document.getElementById('jobs-tbody');
const thead = document.getElementById('table-headers');
const emptyState = document.getElementById('empty-state');
console.log('displayJobs called with filteredJobs:', filteredJobs);
console.log('filteredJobs length:', filteredJobs.length);
if (filteredJobs.length === 0) {
console.log('No jobs to display, showing empty state');
tbody.innerHTML = '';
emptyState.style.display = 'block';
return;
}
console.log('Hiding empty state and displaying jobs');
emptyState.style.display = 'none';
// Generate table headers dynamically
if (collectionSchema) {
const headerHTML = collectionSchema.map(field => {
let displayName;
// Custom display names for specific fields
switch(field.name) {
case 'Job_Number':
displayName = 'Job Number';
break;
case 'Job_Name':
displayName = 'Job Name';
break;
case 'Job_Address':
displayName = 'Address';
break;
default:
// Default formatting: replace underscores with spaces and capitalize words
displayName = field.name.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
}
return `<th>${displayName}</th>`;
}).join('') + '<th>Actions</th>';
thead.innerHTML = headerHTML;
}
// Get jobs for current page
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
const pageJobs = filteredJobs.slice(startIndex, endIndex);
console.log('Displaying pageJobs:', pageJobs);
tbody.innerHTML = pageJobs.map(job => {
console.log('Processing job:', job);
const rowHTML = collectionSchema.map(field => {
const value = job[field.name];
const formattedValue = formatFieldValue(value, field);
console.log(`Field ${field.name}: ${value} -> ${formattedValue}`);
return `<td>${formattedValue}</td>`;
}).join('') + `
<td>
<div class="job-actions">
<button class="action-btn btn-edit" onclick="editJob('${job.id}')">Edit</button>
<button class="action-btn btn-delete" onclick="deleteJob('${job.id}')">Delete</button>
</div>
</td>
`;
return `<tr>${rowHTML}</tr>`;
}).join('');
}
/**
* Builds a PocketBase filter string for server-side filtering
* Useful for large datasets where server-side filtering is more efficient
*
* Example usage:
* const filter = buildPocketBaseFilter('test');
* const records = await pb.collection('Jobs').getList(1, 50, { filter });
*
* @param {string} searchTerm - The search term to filter by
* @returns {string|null} PocketBase filter string or null if no search term
*/
function buildPocketBaseFilter(searchTerm) {
if (!searchTerm || searchTerm.trim() === '') {
return null;
}
const trimmed = searchTerm.trim();
// Try to parse as number for Job_Number search
const numberMatch = parseInt(trimmed);
if (!isNaN(numberMatch)) {
// Search by Job_Number
return `Job_Number = ${numberMatch}`;
}
// Search in Job_Name (case-insensitive)
// PocketBase filter syntax: field ~ "value" for contains (case-insensitive)
return `Job_Name ~ "${trimmed}"`;
}
function filterJobs() {
const searchTerm = document.getElementById('search-input').value.toLowerCase().trim();
// Client-side filtering for instant results
filteredJobs = allJobs.filter(job => {
if (!searchTerm) {
return true;
}
// Search across Job_Number, Job_Name, and Job_Address
const matchesSearch =
(job.Job_Number && job.Job_Number.toString().toLowerCase().includes(searchTerm)) ||
(job.Job_Name && job.Job_Name.toLowerCase().includes(searchTerm)) ||
(job.Job_Address && (
// Handle Job_Address as object {lat, lon} or string
(typeof job.Job_Address === 'object' && job.Job_Address !== null
? `${job.Job_Address.lat},${job.Job_Address.lon}`.toLowerCase()
: String(job.Job_Address).toLowerCase()
).includes(searchTerm)
));
return matchesSearch;
});
currentPage = 1;
displayJobs();
updatePagination();
}
function updatePagination() {
const totalPages = Math.ceil(filteredJobs.length / itemsPerPage);
const pagination = document.getElementById('pagination');
const pageInfo = document.getElementById('page-info');
const prevBtn = document.getElementById('prev-btn');
const nextBtn = document.getElementById('next-btn');
if (totalPages <= 1) {
pagination.style.display = 'none';
return;
}
pagination.style.display = 'flex';
pageInfo.textContent = `Page ${currentPage} of ${totalPages}`;
prevBtn.disabled = currentPage === 1;
nextBtn.disabled = currentPage === totalPages;
}
function changePage(direction) {
const totalPages = Math.ceil(filteredJobs.length / itemsPerPage);
const newPage = currentPage + direction;
if (newPage >= 1 && newPage <= totalPages) {
currentPage = newPage;
displayJobs();
updatePagination();
}
}
function formatFieldValue(value, field) {
if (value === null || value === undefined || value === '') {
return 'N/A';
}
switch (field.type) {
case 'date':
return formatDate(value);
case 'number':
// Job_Number should be displayed as plain number
return Number(value).toString();
default:
// Handle text fields - Job_Name gets special styling
if (field.name === 'Job_Name') {
return `<div class="job-name">${value}</div>`;
}
// Handle Job_Address as object {lat, lon}
if (field.name === 'Job_Address' && typeof value === 'object' && value !== null) {
if (value.lat !== undefined && value.lon !== undefined) {
return `${value.lat.toFixed(6)}, ${value.lon.toFixed(6)}`;
}
return JSON.stringify(value);
}
return value;
}
}
function formatDate(dateString) {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
}
async function refreshJobs() {
await loadJobs();
}
async function showAddJobModal() {
// Generate next job number
const nextJobNumber = await getNextJobNumber();
// Create overlay popup HTML
const modalHTML = `
<div id="job-modal" class="modal-overlay">
<div class="modal-content popup-content">
<div class="modal-header">
<h2>Add New Job</h2>
<button class="modal-close" onclick="closeJobModal()">×</button>
</div>
<form id="job-form" onsubmit="submitJobForm(event)">
<div class="form-group">
<label for="job-number">Job Number</label>
<input type="number" id="job-number" value="${nextJobNumber}" readonly required>
</div>
<div class="form-group">
<label for="job-name">Job Name</label>
<input type="text" id="job-name" placeholder="Enter job name" required>
</div>
<div class="form-group">
<label for="job-address">Address or Coordinates</label>
<input type="text" id="job-address" placeholder="Enter address (e.g., '123 Main St') or coordinates (e.g., '40.7128,-74.0060')">
<small style="color: var(--gruvbox-fg2); font-size: 0.8rem; display: block; margin-top: 0.25rem;">
Addresses will be converted to coordinates automatically
</small>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" onclick="closeJobModal()">Cancel</button>
<button type="submit" class="btn btn-primary" id="submit-btn">
<span id="submit-text">Create Job</span>
<span id="submit-loading" class="loading" style="display: none;"></span>
</button>
</div>
</form>
</div>
</div>
`;
// Add modal to page
document.body.insertAdjacentHTML('beforeend', modalHTML);
// Focus on first input
setTimeout(() => {
document.getElementById('job-name').focus();
}, 100);
}
function closeJobModal() {
const modal = document.getElementById('job-modal');
if (modal) {
modal.remove();
}
}
async function submitJobForm(event) {
event.preventDefault();
const submitBtn = document.getElementById('submit-btn');
const submitText = document.getElementById('submit-text');
const submitLoading = document.getElementById('submit-loading');
// Show loading state
submitBtn.disabled = true;
submitText.style.display = 'none';
submitLoading.style.display = 'inline-block';
try {
// Collect form data
const jobName = document.getElementById('job-name').value.trim();
const jobAddress = document.getElementById('job-address').value.trim();
// Convert address to coordinates if provided
let jobAddressCoords = null;
if (jobAddress) {
jobAddressCoords = await addressToCoordinates(jobAddress);
console.log('Address converted to coordinates:', jobAddress, '->', jobAddressCoords);
}
const jobData = {
Job_Number: parseInt(document.getElementById('job-number').value),
Job_Name: jobName
};
// Only add Job_Address if it was provided and successfully converted
// Job_Address must be an object with {lat, lon} format
if (jobAddressCoords && typeof jobAddressCoords === 'object') {
jobData.Job_Address = jobAddressCoords;
}
console.log('Sending job data:', jobData);
console.log('Job_Number type:', typeof jobData.Job_Number, 'value:', jobData.Job_Number);
console.log('Job_Name type:', typeof jobData.Job_Name, 'value:', jobData.Job_Name);
if (jobData.Job_Address) {
console.log('Job_Address:', jobData.Job_Address);
}
// Check if job number already exists (optional validation)
try {
const existingJobs = await pb.collection('Jobs').getList(1, 1, {
filter: `Job_Number = ${jobData.Job_Number}`
});
if (existingJobs.items.length > 0) {
throw new Error(`Job number ${jobData.Job_Number} already exists. Please refresh to get the next available number.`);
}
} catch (checkError) {
if (checkError.message.includes('already exists')) {
throw checkError;
}
// Ignore other errors from the check - continue with creation
console.warn('Could not check for existing job number:', checkError.message);
}
// Log the exact data structure being sent
console.log('=== CREATING JOB ===');
console.log('Collection: Jobs');
console.log('Data being sent:', JSON.stringify(jobData, null, 2));
console.log('Data types:', Object.entries(jobData).map(([k, v]) => `${k}: ${typeof v} = ${v}`));
// Try to get collection info for comparison
try {
let collection;
if (API_KEY) {
// Use API key for schema access
const response = await fetch(`${pb.baseUrl}/api/collections/Jobs`, {
headers: {
'Authorization': `Bearer ${API_KEY}`
}
});
if (response.ok) {
collection = await response.json();
} else {
throw new Error(`HTTP ${response.status}`);
}
} else {
collection = await pb.collections.getOne('Jobs');
}
console.log('Collection fields:', collection.schema?.map(f => ({
name: f.name,
type: f.type,
required: f.required
})));
} catch (e) {
console.warn('Could not fetch collection info:', e.message);
}
// Create job in PocketBase
const createdJob = await pb.collection('Jobs').create(jobData);
console.log('Job created successfully:', createdJob);
// Close modal and refresh jobs list
closeJobModal();
await loadJobs();
// Show success message (you could implement a toast notification here)
alert('Job created successfully!');
} catch (error) {
console.error('Error creating job:', error);
console.error('Full error object:', JSON.stringify(error, null, 2));
// Show detailed error information
let errorMessage = 'Failed to create job.';
// PocketBase errors have different structures - check all possible locations
if (error.response) {
console.error('Error response:', error.response);
console.error('Error response.data:', error.response.data);
console.error('Error response.status:', error.response.status);
console.error('Error response.url:', error.response.url);
const errorData = error.response.data || {};
// Check for validation errors in different possible locations
if (errorData.data) {
const validationErrors = errorData.data;
console.error('Validation errors found:', validationErrors);
const errorDetails = Object.entries(validationErrors)
.map(([field, messages]) => {
const msg = Array.isArray(messages) ? messages.join(', ') : String(messages);
return `${field}: ${msg}`;
})
.join('\n');
errorMessage += '\n\nValidation errors:\n' + errorDetails;
}
// Check if errorData itself is an object with field-level errors
if (typeof errorData === 'object' && !errorData.message && !errorData.data) {
const fieldErrors = Object.entries(errorData)
.filter(([key]) => key !== 'code' && key !== 'status')
.map(([field, messages]) => {
const msg = Array.isArray(messages) ? messages.join(', ') : String(messages);
return `${field}: ${msg}`;
});
if (fieldErrors.length > 0) {
errorMessage += '\n\nField errors:\n' + fieldErrors.join('\n');
}
}
if (errorData.message) {
errorMessage += '\n\n' + errorData.message;
}
} else if (error.message) {
errorMessage += '\n\n' + error.message;
}
// Try to fetch collection schema for debugging
try {
let collection;
if (API_KEY) {
// Use API key for schema access
const response = await fetch(`${pb.baseUrl}/api/collections/Jobs`, {
headers: {
'Authorization': `Bearer ${API_KEY}`
}
});
if (response.ok) {
collection = await response.json();
} else {
throw new Error(`HTTP ${response.status}`);
}
} else {
collection = await pb.collections.getOne('Jobs');
}
console.log('Collection schema:', collection);
console.log('Collection fields:', collection.schema?.map(f => ({
name: f.name,
type: f.type,
required: f.required,
options: f.options
})));
} catch (schemaError) {
console.warn('Could not fetch collection schema:', schemaError);
}
alert(errorMessage);
// Also suggest running the debug function
console.log('💡 Try running debugPocketBase() in the console to test permissions');
} finally {
// Reset loading state
submitBtn.disabled = false;
submitText.style.display = 'inline';
submitLoading.style.display = 'none';
}
}
function editJob(jobId) {
// TODO: Implement edit job functionality
alert(`Edit job ${jobId} - coming soon!`);
}
async function deleteJob(jobId) {
if (!confirm('Are you sure you want to delete this job?')) {
return;
}
try {
await pb.collection('Jobs').delete(jobId);
await loadJobs(); // Refresh the list
} catch (error) {
console.error('Error deleting job:', error.message);
alert('Failed to delete job. Please try again.');
}
}
function logout() {
pb.authStore.clear();
window.location.href = '/';
}
function showLoading(show) {
document.getElementById('loading').style.display = show ? 'flex' : 'none';
document.getElementById('jobs-content').style.display = show ? 'none' : 'block';
}
function showError(message) {
document.getElementById('error-content').textContent = message;
document.getElementById('error-content').style.display = 'block';
showLoading(false);
}
// Make functions globally available
window.logout = logout;
window.refreshJobs = refreshJobs;
window.showAddJobModal = showAddJobModal;
window.closeJobModal = closeJobModal;
window.submitJobForm = submitJobForm;
window.editJob = editJob;
window.deleteJob = deleteJob;
window.changePage = changePage;
window.filterJobs = filterJobs;
// Debug function available in console
window.debugPocketBase = async function() {
console.log('=== POCKETBASE DEBUG ===');
console.log('Base URL:', pb.baseUrl);
console.log('Auth valid:', pb.authStore.isValid);
console.log('Auth model:', pb.authStore.model);
try {
// Try to get collections
const collections = await pb.collections.getFullList();
console.log('Available collections:', collections.map(c => c.name));
} catch (error) {
console.error('Cannot list collections:', error.message);
}
// Test the Jobs collection specifically
console.log('Testing Jobs collection...');
// Test read access
try {
const result = await pb.collection('Jobs').getList(1, 10);
console.log(`✅ Read access: ${result.totalItems} items found`);
if (result.items.length > 0) {
console.log('Sample item:', result.items[0]);
}
} catch (error) {
console.log('❌ Read access failed:', error.message);
}
// Test write access with dummy data
try {
const testData = { Job_Number: 9999, Job_Name: 'TEST_JOB_PLEASE_DELETE' };
const testResult = await pb.collection('Jobs').create(testData);
console.log('✅ Write access successful, created test record:', testResult);
// Clean up test record
await pb.collection('Jobs').delete(testResult.id);
console.log('✅ Test record cleaned up');
} catch (error) {
console.log('❌ Write access failed:', error.message);
if (error.response) {
console.log('Error details:', error.response.data);
}
}
console.log('=== END DEBUG ===');
};
// Initialize on page load
document.addEventListener('DOMContentLoaded', initJobsPage);
</script>
</body>
</html>