1399 lines
51 KiB
HTML
1399 lines
51 KiB
HTML
<!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>
|
||
|