diff --git a/README.md b/README.md index 91fe2ab..bc555ab 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,234 @@ -# Job-Pages +# Job Tracking System +A modern, responsive web application for tracking construction/contracting jobs, built with vanilla HTML/CSS/JavaScript and PocketBase. + +## Features + +- **Job Management**: Create, read, update, and delete job records +- **Comprehensive Job Information**: Track all job details including: + - Basic information (Job Number, Name, Address, Type, Status, Division) + - Team assignments (Estimator, Office Rep, Project Manager) + - Client information (Company, Contact Person, Phone, Email) + - Dates and scheduling (Start Date, Due Date, Submission Date) + - Links and references (QB Link, Voxer Link, Folder Link) + - Status flags (Active, Tax Exempt, Docs Uploaded, etc.) +- **Notes System**: Add rich text notes to jobs with sentiment tracking +- **Search & Filter**: Search jobs by number, name, or client; filter by status +- **Dashboard**: View job statistics and quick actions +- **Responsive Design**: Works on mobile, tablet, and desktop + +## Technology Stack + +- **Frontend**: Vanilla HTML/CSS/JavaScript +- **CSS Framework**: Tailwind CSS (via CDN) +- **Rich Text Editor**: Quill.js +- **Backend**: PocketBase +- **Design**: Modern, clean UI with Indigo/Green/Purple color scheme + +## Setup Instructions + +### 1. Install PocketBase + +Download PocketBase from [https://pocketbase.io/docs/](https://pocketbase.io/docs/) and start it: + +```bash +./pocketbase serve +``` + +By default, PocketBase runs on `http://127.0.0.1:8090` + +### 2. Configure PocketBase + +1. Open PocketBase Admin UI at `http://127.0.0.1:8090/_/` +2. Create an admin account +3. Create the following collections: + +#### Job_Info Collection + +Create a collection named `Job_Info` with the following fields: + +- `id` (Auto) - Auto-generated ID +- `Job_Number` (Text) +- `Job_Full_Name` (Text) +- `Job_Name` (Text) +- `Job_Address` (Text) +- `Job_Type` (Text) +- `Job_Status` (Text) +- `Job_Division` (Text) +- `Estimator` (Text) +- `Office_Rep` (Text) +- `Project_Manager` (Text) +- `Company_Client` (Text) +- `Contact_Person` (Text) +- `Phone_Number` (Text) +- `Email` (Text) +- `Due_Date_Source` (Text) +- `Due_Date_Counter` (Text) +- `Due_Time` (Text) +- `Schedule_Confidence` (Text) +- `Notes` (Text) +- `Job_QB_Link` (Text) +- `Voxer_Link` (Text) +- `Job_Codes` (Text) +- `Job_Folder_Link` (Text) +- `Has_Attachments` (Boolean) +- `Attachment_List` (JSON) +- `Note_Ref` (JSON - Array of relation IDs) +- `latest_note_summary` (JSON) +- `note_thread_text` (JSON) +- `note_count` (Number) +- `last_note_at` (Date) +- `Job_Number_Parent` (Text) +- `Start_Date` (Date) +- `Submission_Date` (Date) +- `Flag` (Boolean) +- `Tax_Exempt` (Boolean) +- `Active` (Boolean) +- `Due_Date` (Date) +- `Docs_Uploaded` (Boolean) +- `Tax_Exempt_Docs_Uploaded` (Boolean) +- `Estimate_Approved` (Boolean) +- `QB_Created` (Boolean) +- `Added_To_Calendar` (Boolean) +- `PSwift_Uploaded` (Boolean) +- `Voxer_Created` (Boolean) +- `Estimate_Reviewed` (Boolean) +- `Estimate_Draft` (Boolean) + +#### Notes Collection + +Create a collection named `notes` with the following fields: + +- `id` (Auto) - Auto-generated ID +- `content` (Text) - Rich text content +- `sentiment` (Text) - Positive, Negative, or Neutral +- `date` (Date) +- `job` (Relation) - Link to Job_Info collection (optional) + +### 3. Configure PocketBase URL + +Update the PocketBase URL in `js/pocketbase.js` if your instance is running on a different address: + +```javascript +const POCKETBASE_URL = 'http://127.0.0.1:8090'; +``` + +### 4. Set Up Authentication + +1. In PocketBase Admin UI, go to Settings > Users +2. Create user accounts for your team members +3. Users can log in using their email and password + +### 5. Set Collection Permissions + +For both `Job_Info` and `notes` collections: + +1. Go to the collection settings +2. Set **List/Search rule**: `@request.auth.id != ""` (authenticated users only) +3. Set **View rule**: `@request.auth.id != ""` (authenticated users only) +4. Set **Create rule**: `@request.auth.id != ""` (authenticated users only) +5. Set **Update rule**: `@request.auth.id != ""` (authenticated users only) +6. Set **Delete rule**: `@request.auth.id != ""` (authenticated users only) + +### 6. Run the Application + +1. Install dependencies (if using Bun): + +```bash +bun install +``` + +2. Start the development server: + +```bash +bun run dev +``` + +The server will start on `http://localhost:3075` + +3. Open `http://localhost:3075` in your browser +4. Log in with your PocketBase user credentials + +**Alternative:** If you prefer a different server: + +```bash +# Using Python 3 +python3 -m http.server 8000 + +# Using Node.js (http-server) +npx http-server -p 8000 + +# Using PHP +php -S localhost:8000 +``` + +## File Structure + +``` +Job-Pages/ +├── index.html # Login page +├── dashboard.html # Dashboard with stats +├── jobs.html # Jobs list and management +├── notes.html # Notes/reports page +├── js/ +│ ├── pocketbase.js # PocketBase client +│ ├── auth.js # Authentication +│ ├── jobs.js # Job CRUD operations +│ └── notes.js # Notes operations +├── css/ +│ └── custom.css # Custom styles +└── README.md # This file +``` + +## Usage + +### Adding a Job + +1. Click "Add Job" on the jobs page +2. Fill in the job information across the 8 tabs: + - Basic Info: Job number, name, address, type, status, division + - Team: Estimator, Office Rep, Project Manager + - Client: Company, contact person, phone, email + - Dates: Start date, due date, submission date, scheduling info + - Links: QB link, Voxer link, folder link, job codes + - Flags: Checkboxes for various status flags + - Notes: Add notes to the job (for existing jobs) + - Attachments: View attachments (for existing jobs) +3. Click "Save Job" + +### Adding Notes + +1. Open a job for editing +2. Go to the "Notes" tab +3. Click "+ Add Note" +4. Enter note content using the rich text editor +5. Select sentiment (Positive, Negative, Neutral) +6. Click "Save Note" + +### Searching and Filtering + +- Use the search bar to search by job number, name, or client +- Use filter buttons to show All, Active, or Inactive jobs + +## Troubleshooting + +### CORS Errors + +Make sure you're serving the files through a web server, not opening them directly as files. + +### Authentication Issues + +- Verify PocketBase is running +- Check the PocketBase URL in `js/pocketbase.js` +- Ensure user accounts exist in PocketBase +- Check collection permissions + +### Data Not Loading + +- Check browser console for errors +- Verify PocketBase collections are created correctly +- Ensure field names match exactly (case-sensitive) + +## License + +See LICENSE file for details. diff --git a/config.json b/config.json new file mode 100644 index 0000000..a222688 --- /dev/null +++ b/config.json @@ -0,0 +1,16 @@ +{ + "pocketbase": { + "collections": { + "jobs": "Job_Info", + "notes": "notes", + "users": "users" + } + }, + "pagination": { + "itemsPerPage": 20 + }, + "filters": { + "defaultFilter": "all" + } +} + diff --git a/css/custom.css b/css/custom.css new file mode 100644 index 0000000..cfb7887 --- /dev/null +++ b/css/custom.css @@ -0,0 +1,37 @@ +/* Custom styles for Job Tracking System */ + +/* Quill editor custom styles */ +.ql-editor { + min-height: 150px; +} + +.ql-container { + font-size: 14px; +} + +/* Smooth transitions */ +* { + transition-property: background-color, border-color, color, fill, stroke; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} + +/* Custom scrollbar */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: #f1f1f1; +} + +::-webkit-scrollbar-thumb { + background: #888; + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: #555; +} + diff --git a/dashboard.html b/dashboard.html new file mode 100644 index 0000000..ad388e2 --- /dev/null +++ b/dashboard.html @@ -0,0 +1,270 @@ + + + + + + Job Tracking - Dashboard + + + + + + + + +
+ +
+

Welcome

+

Manage your job records and tracking

+
+ + +
+ +
+
+
+ + + +
+
+

Job Tracking

+

View and manage job information

+ View Jobs → +
+ + +
+
+
+ + + +
+
+

Notes & Reports

+

Manage job notes and reports

+ View Notes → +
+
+ + +
+

Quick Stats

+
+ +
+
+ ... +
+
Total Jobs
+
+ + +
+
+ ... +
+
Active
+
+ + +
+
+ ... +
+
Estimating
+
+ + +
+
+ ... +
+
Est Sent
+
+ + +
+
+ ... +
+
Est Hold
+
+ + +
+
+ ... +
+
Follow Up
+
+ + +
+
+ ... +
+
Awarded
+
+ + +
+
+ ... +
+
In Progress
+
+ + +
+
+ ... +
+
Billed (IP)
+
+ + +
+
+ ... +
+
Billed (Closed)
+
+ + +
+
+ ... +
+
Not Awarded
+
+ + +
+
+ ... +
+
Not Bidding
+
+ + +
+
+ ... +
+
Archive
+
+
+
+ + +
+

Quick Actions

+ +
+
+ + + + + + + + + diff --git a/index.html b/index.html new file mode 100644 index 0000000..d94db2f --- /dev/null +++ b/index.html @@ -0,0 +1,82 @@ + + + + + + Job Tracking - Login + + + + +
+
+

Job Tracking

+

Sign in to your account

+
+ + + +
+
+ + +
+ +
+ + +
+ + +
+
+ + + + + + + diff --git a/jobs.html b/jobs.html new file mode 100644 index 0000000..01644a7 --- /dev/null +++ b/jobs.html @@ -0,0 +1,909 @@ + + + + + + Job Tracking - Jobs + + + + + + + + + + +
+ +
+ + +
+
+ + + + +
+ +
+ +
+ +
+
+ + +
+ +
+ +
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + + + + + + + + + + + + + + + + + +
Job NumberJob NameClientEstimatorStatusTypeDivisionActions
Loading...
+
+
+ + + + + +
+
+ + + + + + + + + + + + + + diff --git a/js/auth.js b/js/auth.js new file mode 100644 index 0000000..7cc5de3 --- /dev/null +++ b/js/auth.js @@ -0,0 +1,41 @@ +// Authentication functions + +/** + * Login with email and password + */ +async function login(email, password) { + try { + const authData = await pb.collection('users').authWithPassword(email, password); + return authData; + } catch (error) { + console.error('Login error:', error); + throw new Error(error.message || 'Login failed. Please check your credentials.'); + } +} + +/** + * Logout current user + */ +function logout() { + pb.authStore.clear(); + window.location.href = 'index.html'; +} + +/** + * Check if user is authenticated, redirect to login if not + */ +function checkAuth() { + if (!pb.authStore.isValid) { + window.location.href = 'index.html'; + return false; + } + return true; +} + +/** + * Get current user + */ +function getCurrentUser() { + return pb.authStore.model; +} + diff --git a/js/config.js b/js/config.js new file mode 100644 index 0000000..73722bf --- /dev/null +++ b/js/config.js @@ -0,0 +1,63 @@ +// Configuration loader +let appConfig = null; + +/** + * Load configuration from config.json + */ +async function loadConfig() { + if (appConfig) { + return appConfig; + } + + try { + const response = await fetch('/config.json'); + if (!response.ok) { + throw new Error(`Failed to load config: ${response.statusText}`); + } + appConfig = await response.json(); + return appConfig; + } catch (error) { + console.error('Error loading config:', error); + // Return default config if file can't be loaded + return { + pocketbase: { + collections: { + jobs: "Job_Info", + notes: "notes", + users: "users" + } + }, + pagination: { + itemsPerPage: 20 + }, + filters: { + defaultFilter: "all" + } + }; + } +} + +/** + * Get the jobs collection name + */ +async function getJobsCollection() { + const config = await loadConfig(); + return config.pocketbase.collections.jobs; +} + +/** + * Get the notes collection name + */ +async function getNotesCollection() { + const config = await loadConfig(); + return config.pocketbase.collections.notes; +} + +/** + * Get items per page setting + */ +async function getItemsPerPage() { + const config = await loadConfig(); + return config.pagination.itemsPerPage || 20; +} + diff --git a/js/jobs.js b/js/jobs.js new file mode 100644 index 0000000..ca909a8 --- /dev/null +++ b/js/jobs.js @@ -0,0 +1,868 @@ +// Job CRUD operations + +let currentPage = 1; +let itemsPerPage = 20; +let allJobs = []; +let jobsCollectionName = 'Job_Info'; // Default, will be loaded from config +let configInitialized = false; +let configInitializing = false; +let currentView = localStorage.getItem('jobsView') || 'table'; // 'table' or 'card' + +/** + * Load all jobs with optional filters + */ +async function loadJobs(page = 1) { + try { + // Show loading state + // Show loading state based on current view + if (currentView === 'table') { + const tbody = document.getElementById('jobsTableBody'); + if (tbody) { + tbody.innerHTML = 'Loading...'; + } + } else { + const container = document.getElementById('jobsCardContainer'); + if (container) { + container.innerHTML = '
Loading...
'; + } + } + + currentPage = page; + const searchTerm = document.getElementById('searchInput')?.value || ''; + const estimatorFilter = window.currentEstimatorFilter || 'all'; + const statusFilter = window.currentStatusFilter || 'all'; + + // Build estimator filter string + let estimatorFilterString = ''; + if (estimatorFilter && estimatorFilter !== 'all') { + // Escape special characters for PocketBase filter + const escapedFilter = estimatorFilter.replace(/"/g, '\\"'); + estimatorFilterString = `Estimator = "${escapedFilter}"`; + } + + // Build status filter string + let statusFilterString = ''; + if (statusFilter && statusFilter !== 'all') { + // Escape special characters for PocketBase filter + const escapedStatus = statusFilter.replace(/"/g, '\\"'); + statusFilterString = `Job_Status = "${escapedStatus}"`; + } + + // Build search filter + let searchFilter = ''; + if (searchTerm) { + // Escape special characters for PocketBase filter + const escapedTerm = searchTerm.replace(/"/g, '\\"'); + searchFilter = `Job_Number ~ "${escapedTerm}" || Job_Name ~ "${escapedTerm}" || Company_Client ~ "${escapedTerm}"`; + } + + // Combine filters + const filterParts = []; + if (estimatorFilterString) filterParts.push(`(${estimatorFilterString})`); + if (statusFilterString) filterParts.push(`(${statusFilterString})`); + if (searchFilter) filterParts.push(`(${searchFilter})`); + + let finalFilter = ''; + if (filterParts.length > 0) { + finalFilter = filterParts.join(' && '); + } + + const result = await pb.collection(jobsCollectionName).getList(page, itemsPerPage, { + filter: finalFilter || undefined, + sort: '-Job_Number', + }); + + allJobs = result.items; + displayJobs(allJobs); + displayPagination(result.page, result.totalPages, result.totalItems); + + // Update view based on current view preference + updateViewDisplay(); + } catch (error) { + console.error('Error loading jobs:', error); + showMessage('Error loading jobs: ' + (error.message || 'Unknown error'), 'error'); + if (currentView === 'table') { + const tbody = document.getElementById('jobsTableBody'); + if (tbody) { + tbody.innerHTML = 'Error loading jobs. Please refresh the page.'; + } + } else { + const container = document.getElementById('jobsCardContainer'); + if (container) { + container.innerHTML = '
Error loading jobs. Please refresh the page.
'; + } + } + } +} + +/** + * Get estimator badge HTML + */ +function getEstimatorBadgeHtml(estimator) { + if (!estimator) return '-'; + + const estimatorLower = estimator.toLowerCase(); + let bgColor = 'bg-gray-100'; + let textColor = 'text-gray-800'; + + if (estimatorLower.includes('steve')) { + bgColor = 'bg-orange-100'; + textColor = 'text-orange-800'; + } else if (estimatorLower.includes('beth')) { + bgColor = 'bg-blue-100'; + textColor = 'text-blue-800'; + } else if (estimatorLower.includes('timothy')) { + bgColor = 'bg-purple-100'; + textColor = 'text-purple-800'; + } + + return `${estimator}`; +} + +/** + * Get status badge color + */ +function getStatusBadgeColor(status) { + if (!status) return 'bg-gray-200 text-gray-700'; + + const statusLower = status.toLowerCase(); + + if (statusLower.includes('billed') && (statusLower.includes('closed') || statusLower.includes('progress'))) { + return 'bg-purple-200 text-purple-800'; + } else if (statusLower.includes('est hold')) { + return 'bg-orange-200 text-orange-800'; + } else if (statusLower.includes('est sent') || statusLower.includes('follow up')) { + return 'bg-yellow-200 text-yellow-800'; + } else if (statusLower.includes('estimating')) { + return 'bg-blue-200 text-blue-800'; + } else if (statusLower.includes('in progress') || statusLower.includes('awarded')) { + return 'bg-green-200 text-green-800'; + } else if (statusLower.includes('not awarded') || statusLower.includes('not bidding')) { + return 'bg-red-200 text-red-800'; + } else if (statusLower.includes('archive')) { + return 'bg-gray-200 text-gray-700'; + } + + return 'bg-gray-200 text-gray-700'; +} + +/** + * Display jobs in table or card view + */ +function displayJobs(jobs) { + if (currentView === 'table') { + displayJobsTable(jobs); + } else { + displayJobsCards(jobs); + } +} + +/** + * Display jobs in table view + */ +function displayJobsTable(jobs) { + const tbody = document.getElementById('jobsTableBody'); + if (!tbody) return; + + if (!jobs || jobs.length === 0) { + tbody.innerHTML = 'No jobs found'; + return; + } + + tbody.innerHTML = jobs.map(job => { + const estimatorDisplay = getEstimatorBadgeHtml(job.Estimator); + + return ` + + ${job.Job_Number || '-'} + ${job.Job_Name || '-'} + ${job.Company_Client || '-'} + ${estimatorDisplay} + ${job.Job_Status || '-'} + ${job.Job_Type || '-'} + ${job.Job_Division || '-'} + + + + + `; + }).join(''); +} + +/** + * Display jobs in card view (multiple lines, single column) + */ +function displayJobsCards(jobs) { + const container = document.getElementById('jobsCardContainer'); + if (!container) return; + + if (!jobs || jobs.length === 0) { + container.innerHTML = '
No jobs found
'; + return; + } + + container.innerHTML = jobs.map(job => { + const estimatorBadge = getEstimatorBadgeHtml(job.Estimator); + const statusColor = getStatusBadgeColor(job.Job_Status); + + return ` +
+
+
+
+

${job.Job_Number || 'N/A'}

+

${job.Job_Name || '-'}

+
+ + + +
+
+ +
+
+ Client +

${job.Company_Client || '-'}

+
+ +
+ Estimator +
${estimatorBadge}
+
+ +
+ Status +
+ + ${job.Job_Status || '-'} + +
+
+ +
+
+ Type +

${job.Job_Type || '-'}

+
+
+ Division +

${job.Job_Division || '-'}

+
+
+
+
+ `; + }).join(''); +} + +/** + * Switch between table and card view + */ +function switchView(view) { + currentView = view; + localStorage.setItem('jobsView', view); + updateViewDisplay(); + displayJobs(allJobs); +} + +/** + * Update view display based on current view preference + */ +function updateViewDisplay() { + const tableView = document.getElementById('tableView'); + const cardView = document.getElementById('cardView'); + const tableViewBtn = document.getElementById('tableViewBtn'); + const cardViewBtn = document.getElementById('cardViewBtn'); + + if (currentView === 'table') { + if (tableView) tableView.classList.remove('hidden'); + if (cardView) cardView.classList.add('hidden'); + if (tableViewBtn) { + tableViewBtn.classList.add('bg-indigo-600', 'text-white'); + tableViewBtn.classList.remove('text-gray-700', 'hover:bg-gray-50'); + } + if (cardViewBtn) { + cardViewBtn.classList.remove('bg-indigo-600', 'text-white'); + cardViewBtn.classList.add('text-gray-700', 'hover:bg-gray-50'); + } + } else { + if (tableView) tableView.classList.add('hidden'); + if (cardView) cardView.classList.remove('hidden'); + if (tableViewBtn) { + tableViewBtn.classList.remove('bg-indigo-600', 'text-white'); + tableViewBtn.classList.add('text-gray-700', 'hover:bg-gray-50'); + } + if (cardViewBtn) { + cardViewBtn.classList.add('bg-indigo-600', 'text-white'); + cardViewBtn.classList.remove('text-gray-700', 'hover:bg-gray-50'); + } + } +} + +// Make functions globally accessible +window.switchView = switchView; +window.updateViewDisplay = updateViewDisplay; + +/** + * Display pagination controls + */ +function displayPagination(currentPage, totalPages, totalItems) { + const container = document.getElementById('paginationContainer'); + if (!container) return; + + if (totalPages <= 1) { + container.innerHTML = ''; + return; + } + + let html = '
'; + + // Previous button + if (currentPage > 1) { + html += ``; + } + + // Page info + html += `Page ${currentPage} of ${totalPages} (${totalItems} total)`; + + // Next button + if (currentPage < totalPages) { + html += ``; + } + + html += '
'; + container.innerHTML = html; +} + +/** + * Load job for editing + */ +async function loadJobForEdit(jobId) { + try { + if (!jobId) { + showMessage('No job ID provided', 'error'); + return null; + } + + const job = await pb.collection(jobsCollectionName).getOne(jobId); + + // Update dashboard header and quick info + if (typeof updateDashboardInfo === 'function') { + updateDashboardInfo(job); + } + + // Populate form fields + Object.keys(job).forEach(key => { + const field = document.getElementById(key); + if (field) { + if (field.type === 'checkbox') { + field.checked = job[key] === true; + } else if (field.type === 'date') { + if (job[key]) { + try { + const date = new Date(job[key]); + if (!isNaN(date.getTime())) { + field.value = date.toISOString().split('T')[0]; + } + } catch (e) { + console.warn('Invalid date for field', key, job[key]); + } + } + } else if (field.type === 'time') { + if (job[key]) { + field.value = job[key]; + } + } else { + field.value = job[key] || ''; + } + } + }); + + return job; + } catch (error) { + console.error('Error loading job:', error); + showMessage('Error loading job: ' + (error.message || 'Unknown error'), 'error'); + return null; + } +} + +/** + * Save job (create or update) + */ +async function saveJob() { + try { + const form = document.getElementById('jobForm'); + if (!form) { + showMessage('Form not found', 'error'); + return; + } + + const formData = new FormData(form); + const jobId = document.getElementById('jobId')?.value; + + // Build job object + const jobData = {}; + const booleanFields = ['Active', 'Flag', 'Tax_Exempt', 'Docs_Uploaded', 'Tax_Exempt_Docs_Uploaded', + 'Estimate_Approved', 'Estimate_Reviewed', 'Estimate_Draft', 'QB_Created', + 'Added_To_Calendar', 'PSwift_Uploaded', 'Voxer_Created']; + + // Get all form fields + for (const [key, value] of formData.entries()) { + if (key === 'id') continue; + + if (booleanFields.includes(key)) { + const checkbox = document.getElementById(key); + jobData[key] = checkbox ? checkbox.checked : false; + } else if (value && value.trim() !== '') { + jobData[key] = value.trim(); + } + } + + if (jobId) { + // Update existing job + await pb.collection(jobsCollectionName).update(jobId, jobData); + showMessage('Job updated successfully', 'success'); + } else { + // Create new job + await pb.collection(jobsCollectionName).create(jobData); + showMessage('Job created successfully', 'success'); + } + + if (typeof closeJobModal === 'function') { + closeJobModal(); + } + loadJobs(currentPage); + } catch (error) { + console.error('Error saving job:', error); + const errorMsg = error.message || 'Unknown error'; + showMessage('Error saving job: ' + errorMsg, 'error'); + } +} + +/** + * Delete job + */ +async function deleteJob(jobId) { + if (!confirm('Are you sure you want to delete this job?')) { + return; + } + + try { + await pb.collection(jobsCollectionName).delete(jobId); + showMessage('Job deleted successfully', 'success'); + loadJobs(currentPage); + } catch (error) { + console.error('Error deleting job:', error); + showMessage('Error deleting job: ' + (error.message || 'Unknown error'), 'error'); + } +} + +/** + * Get job statistics for dashboard + */ +async function getJobStats() { + try { + // Ensure config is loaded before getting stats (only once) + if (typeof initConfig !== 'undefined') { + await initConfig(); + } + + const collectionName = jobsCollectionName || 'Job_Info'; // Fallback to default + + // Make requests sequentially to avoid auto-cancellation + const totalResult = await pb.collection(collectionName).getList(1, 1); + const activeResult = await pb.collection(collectionName).getList(1, 1, { + filter: 'Active = true' + }); + const estimatingResult = await pb.collection(collectionName).getList(1, 1, { + filter: 'Job_Status = "Estimating"' + }); + const estSentResult = await pb.collection(collectionName).getList(1, 1, { + filter: 'Job_Status = "Est Sent"' + }); + const estHoldResult = await pb.collection(collectionName).getList(1, 1, { + filter: 'Job_Status = "Est Hold"' + }); + const followUpResult = await pb.collection(collectionName).getList(1, 1, { + filter: 'Job_Status = "Follow Up"' + }); + const awardedResult = await pb.collection(collectionName).getList(1, 1, { + filter: 'Job_Status = "Awarded"' + }); + const inProgressResult = await pb.collection(collectionName).getList(1, 1, { + filter: 'Job_Status = "In Progress"' + }); + const billedInProgressResult = await pb.collection(collectionName).getList(1, 1, { + filter: 'Job_Status = "Billed (In Progress)"' + }); + const billedClosedResult = await pb.collection(collectionName).getList(1, 1, { + filter: 'Job_Status = "Billed (Closed)"' + }); + const notAwardedResult = await pb.collection(collectionName).getList(1, 1, { + filter: 'Job_Status = "Not Awarded"' + }); + const notBiddingResult = await pb.collection(collectionName).getList(1, 1, { + filter: 'Job_Status = "Not Bidding"' + }); + const archiveResult = await pb.collection(collectionName).getList(1, 1, { + filter: 'Job_Status = "Archive"' + }); + + const stats = { + total: totalResult.totalItems || 0, + active: activeResult.totalItems || 0, + estimating: estimatingResult.totalItems || 0, + estSent: estSentResult.totalItems || 0, + estHold: estHoldResult.totalItems || 0, + followUp: followUpResult.totalItems || 0, + awarded: awardedResult.totalItems || 0, + inProgress: inProgressResult.totalItems || 0, + billedInProgress: billedInProgressResult.totalItems || 0, + billedClosed: billedClosedResult.totalItems || 0, + notAwarded: notAwardedResult.totalItems || 0, + notBidding: notBiddingResult.totalItems || 0, + archive: archiveResult.totalItems || 0 + }; + + return stats; + } catch (error) { + console.error('Error getting stats:', error); + return { + total: 0, + active: 0, + estimating: 0, + estSent: 0, + estHold: 0, + followUp: 0, + awarded: 0, + inProgress: 0, + billedInProgress: 0, + billedClosed: 0, + notAwarded: 0, + notBidding: 0, + archive: 0 + }; + } +} + +/** + * Load jobs for select dropdown + */ +async function loadJobsForSelect() { + try { + const result = await pb.collection(jobsCollectionName).getList(1, 1000, { + sort: 'Job_Number' + }); + + const select = document.getElementById('noteJob'); + if (!select) return; + + select.innerHTML = ''; + result.items.forEach(job => { + const option = document.createElement('option'); + option.value = job.id; + option.textContent = `${job.Job_Number || job.id} - ${job.Job_Name || 'Unnamed'}`; + select.appendChild(option); + }); + } catch (error) { + console.error('Error loading jobs for select:', error); + } +} + +/** + * Load unique estimators and create filter buttons + */ +async function loadEstimatorFilters() { + try { + // Ensure config is loaded + await initConfig(); + + // Get all jobs to extract unique estimators + const result = await pb.collection(jobsCollectionName).getList(1, 1000, { + fields: 'Estimator' + }); + + // Extract unique estimators + const estimators = new Set(); + result.items.forEach(job => { + if (job.Estimator && job.Estimator.trim() !== '') { + estimators.add(job.Estimator.trim()); + } + }); + + // Sort estimators alphabetically + const sortedEstimators = Array.from(estimators).sort(); + + // Get filter buttons container + const container = document.getElementById('filterButtons'); + if (!container) return; + + // Keep the "All" button and add estimator buttons + const allButton = container.querySelector('#filterAll'); + container.innerHTML = ''; + if (allButton) { + container.appendChild(allButton); + } + + // Add estimator filter buttons with pastel colors + sortedEstimators.forEach(estimator => { + const button = document.createElement('button'); + button.onclick = () => { + if (typeof window.setEstimatorFilter === 'function') { + window.setEstimatorFilter(estimator); + } + }; + button.id = `filter-${estimator.replace(/\s+/g, '-')}`; + + // Set pastel colors for specific estimators + let bgColor = 'bg-gray-200'; + let textColor = 'text-gray-700'; + let hoverColor = 'hover:bg-gray-300'; + + const estimatorLower = estimator.toLowerCase(); + if (estimatorLower.includes('steve')) { + bgColor = 'bg-orange-200'; + textColor = 'text-orange-800'; + hoverColor = 'hover:bg-orange-300'; + } else if (estimatorLower.includes('beth')) { + bgColor = 'bg-blue-200'; + textColor = 'text-blue-800'; + hoverColor = 'hover:bg-blue-300'; + } else if (estimatorLower.includes('timothy')) { + bgColor = 'bg-purple-200'; + textColor = 'text-purple-800'; + hoverColor = 'hover:bg-purple-300'; + } + + button.className = `flex-1 sm:flex-none px-3 py-1.5 rounded-md text-xs font-medium transition focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1 ${bgColor} ${textColor} ${hoverColor}`; + button.textContent = estimator; + container.appendChild(button); + }); + } catch (error) { + console.error('Error loading estimator filters:', error); + } +} + +/** + * Load unique job statuses and create filter buttons + */ +async function loadStatusFilters() { + try { + // Ensure config is loaded + await initConfig(); + + // Get all jobs to extract unique statuses + const result = await pb.collection(jobsCollectionName).getList(1, 1000, { + fields: 'Job_Status' + }); + + // Extract unique statuses + const statuses = new Set(); + result.items.forEach(job => { + if (job.Job_Status && job.Job_Status.trim() !== '') { + statuses.add(job.Job_Status.trim()); + } + }); + + // Define the desired order + const statusOrder = [ + 'Estimating', + 'Est Sent', + 'Est Hold', + 'Follow Up', + 'Awarded', + 'In Progress', + 'Billed (In Progress)', + 'Billed (Closed)', + 'Not Awarded', + 'Not Bidding', + 'Archive' + ]; + + // Sort statuses by the defined order, then alphabetically for any not in the list + const sortedStatuses = Array.from(statuses).sort((a, b) => { + const indexA = statusOrder.findIndex(order => + a.toLowerCase() === order.toLowerCase() || + a.toLowerCase().includes(order.toLowerCase()) || + order.toLowerCase().includes(a.toLowerCase()) + ); + const indexB = statusOrder.findIndex(order => + b.toLowerCase() === order.toLowerCase() || + b.toLowerCase().includes(order.toLowerCase()) || + order.toLowerCase().includes(b.toLowerCase()) + ); + + // If both are in the order list, sort by their position + if (indexA !== -1 && indexB !== -1) { + return indexA - indexB; + } + // If only A is in the list, A comes first + if (indexA !== -1) return -1; + // If only B is in the list, B comes first + if (indexB !== -1) return 1; + // If neither is in the list, sort alphabetically + return a.localeCompare(b); + }); + + // Get status filter buttons container + const container = document.getElementById('statusFilterButtons'); + if (!container) return; + + // Keep the "All" button and add status buttons + const allButton = container.querySelector('#statusFilterAll'); + container.innerHTML = ''; + if (allButton) { + container.appendChild(allButton); + } + + // Create buttons for each status with specific colors + sortedStatuses.forEach(status => { + const button = document.createElement('button'); + button.textContent = status; + button.onclick = () => { + if (typeof window.setStatusFilter === 'function') { + window.setStatusFilter(status); + } + }; + + // Set colors based on status + let bgColor = 'bg-gray-200'; + let textColor = 'text-gray-700'; + let hoverColor = 'hover:bg-gray-300'; + + const statusLower = status.toLowerCase(); + + // Purple for Billed statuses + if (statusLower.includes('billed') && statusLower.includes('closed')) { + bgColor = 'bg-purple-200'; + textColor = 'text-purple-800'; + hoverColor = 'hover:bg-purple-300'; + } else if (statusLower.includes('billed') && statusLower.includes('progress')) { + bgColor = 'bg-purple-200'; + textColor = 'text-purple-800'; + hoverColor = 'hover:bg-purple-300'; + } + // Orange for Est Hold + else if (statusLower.includes('est hold') || statusLower === 'est hold') { + bgColor = 'bg-orange-200'; + textColor = 'text-orange-800'; + hoverColor = 'hover:bg-orange-300'; + } + // Yellow for Est Sent and Follow Up + else if (statusLower.includes('est sent') || statusLower === 'est sent' || + statusLower.includes('follow up') || statusLower === 'follow up') { + bgColor = 'bg-yellow-200'; + textColor = 'text-yellow-800'; + hoverColor = 'hover:bg-yellow-300'; + } + // Blue for Estimating + else if (statusLower.includes('estimating') || statusLower === 'estimating') { + bgColor = 'bg-blue-200'; + textColor = 'text-blue-800'; + hoverColor = 'hover:bg-blue-300'; + } + // Green for In Progress and Awarded + else if (statusLower.includes('in progress') || statusLower === 'in progress' || + statusLower.includes('awarded') || statusLower === 'awarded') { + bgColor = 'bg-green-200'; + textColor = 'text-green-800'; + hoverColor = 'hover:bg-green-300'; + } + // Red for Not Awarded and Not Bidding + else if (statusLower.includes('not awarded') || statusLower === 'not awarded' || + statusLower.includes('not bidding') || statusLower === 'not bidding') { + bgColor = 'bg-red-200'; + textColor = 'text-red-800'; + hoverColor = 'hover:bg-red-300'; + } + // Gray for Archive + else if (statusLower.includes('archive') || statusLower === 'archive') { + bgColor = 'bg-gray-200'; + textColor = 'text-gray-700'; + hoverColor = 'hover:bg-gray-300'; + } + + button.className = `flex-1 sm:flex-none px-3 py-1.5 rounded-md text-xs font-medium transition focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1 ${bgColor} ${textColor} ${hoverColor}`; + container.appendChild(button); + }); + } catch (error) { + console.error('Error loading status filters:', error); + } +} + +/** + * Show message to user + */ +function showMessage(message, type = 'success') { + const container = document.getElementById('messageContainer'); + if (!container) return; + + const bgColor = type === 'success' ? 'bg-green-50 border-green-200 text-green-700' : 'bg-red-50 border-red-200 text-red-700'; + + container.innerHTML = ` +
+ ${message} +
+ `; + + // Auto-hide after 5 seconds + setTimeout(() => { + container.innerHTML = ''; + }, 5000); +} + +/** + * Initialize configuration (idempotent - only runs once) + */ +async function initConfig() { + // If already initialized, return immediately + if (configInitialized) { + return; + } + + // If currently initializing, wait for it to complete + if (configInitializing) { + while (configInitializing) { + await new Promise(resolve => setTimeout(resolve, 50)); + } + return; + } + + // Mark as initializing + configInitializing = true; + + try { + if (typeof getJobsCollection === 'undefined') { + console.warn('getJobsCollection not available, using default collection name'); + jobsCollectionName = 'Job_Info'; + configInitialized = true; + configInitializing = false; + return; + } + const collectionName = await getJobsCollection(); + jobsCollectionName = collectionName; + console.log('Config initialized, jobs collection:', jobsCollectionName); + if (typeof getItemsPerPage !== 'undefined') { + const itemsPerPageConfig = await getItemsPerPage(); + itemsPerPage = itemsPerPageConfig; + } + configInitialized = true; + } catch (error) { + console.error('Error initializing config:', error); + // Use defaults if config fails to load + jobsCollectionName = jobsCollectionName || 'Job_Info'; + configInitialized = true; + } finally { + configInitializing = false; + } +} + +// Initialize config when script loads (but don't block) +// Note: This will be called, but subsequent calls to initConfig() will be idempotent +if (typeof getJobsCollection !== 'undefined') { + initConfig().catch(err => console.error('Config initialization error:', err)); +} + diff --git a/js/notes.js b/js/notes.js new file mode 100644 index 0000000..457fa4c --- /dev/null +++ b/js/notes.js @@ -0,0 +1,328 @@ +// Notes/reports operations + +let notesCollectionName = 'notes'; // Default, will be loaded from config +let jobsCollectionName = 'Job_Info'; // Default, will be loaded from config + +/** + * Initialize configuration + */ +async function initNotesConfig() { + try { + if (typeof getNotesCollection !== 'undefined') { + notesCollectionName = await getNotesCollection(); + } + if (typeof getJobsCollection !== 'undefined') { + jobsCollectionName = await getJobsCollection(); + } + } catch (error) { + console.error('Error initializing notes config:', error); + // Use defaults if config fails to load + } +} + +// Initialize config when script loads +if (typeof getNotesCollection !== 'undefined' || typeof getJobsCollection !== 'undefined') { + initNotesConfig(); +} + +// Get note editor instance from global scope or window +function getNoteEditor() { + return window.noteEditorInstance || noteEditorInstance || null; +} + +/** + * Load notes for a specific job + */ +async function loadJobNotes() { + try { + const jobId = document.getElementById('jobId').value; + if (!jobId) { + document.getElementById('notesList').innerHTML = '

No job selected

'; + return; + } + + const job = await pb.collection(jobsCollectionName).getOne(jobId); + const noteRefs = job.Note_Ref || []; + + if (noteRefs.length === 0) { + document.getElementById('notesList').innerHTML = '

No notes for this job

'; + return; + } + + // Fetch all notes + const notes = await Promise.all( + noteRefs.map(noteId => + pb.collection(notesCollectionName).getOne(noteId).catch(() => null) + ) + ); + + const validNotes = notes.filter(note => note !== null); + + if (validNotes.length === 0) { + document.getElementById('notesList').innerHTML = '

No notes found

'; + return; + } + + displayNotes(validNotes, true); + } catch (error) { + console.error('Error loading job notes:', error); + document.getElementById('notesList').innerHTML = '

Error loading notes

'; + } +} + +/** + * Load all notes + */ +async function loadAllNotes() { + try { + const result = await pb.collection(notesCollectionName).getList(1, 100, { + sort: '-created', + expand: 'job' + }); + + if (result.items.length === 0) { + document.getElementById('loadingIndicator').classList.add('hidden'); + document.getElementById('emptyState').classList.remove('hidden'); + return; + } + + document.getElementById('loadingIndicator').classList.add('hidden'); + document.getElementById('emptyState').classList.add('hidden'); + document.getElementById('reportsTableContainer').classList.remove('hidden'); + + displayNotesTable(result.items); + } catch (error) { + console.error('Error loading notes:', error); + document.getElementById('loadingIndicator').classList.add('hidden'); + document.getElementById('emptyState').classList.remove('hidden'); + } +} + +/** + * Display notes in list format (for job modal) + */ +function displayNotes(notes, showDelete = false) { + const container = document.getElementById('notesList'); + + if (!notes || notes.length === 0) { + container.innerHTML = '

No notes

'; + return; + } + + container.innerHTML = notes.map(note => { + const date = note.created ? new Date(note.created).toLocaleString() : '-'; + const content = note.content || ''; + const sentiment = note.sentiment || '-'; + + return ` +
+
+
+
${sentiment}
+
${date}
+
+ ${showDelete ? `` : ''} +
+
${content}
+
+ `; + }).join(''); +} + +/** + * Display notes in table format (for notes page) + */ +function displayNotesTable(notes) { + const tbody = document.getElementById('notesTableBody'); + + tbody.innerHTML = notes.map(note => { + const date = note.created ? new Date(note.created).toLocaleString() : '-'; + const content = note.content ? note.content.substring(0, 100) + '...' : '-'; + const jobName = note.job ? (note.job.Job_Number || note.job.Job_Name || '-') : '-'; + const sentiment = note.sentiment || '-'; + + return ` + + ${jobName} + ${content} + ${sentiment} + ${date} + + + + + + `; + }).join(''); +} + +/** + * Load note for editing + */ +async function loadNoteForEdit(noteId) { + try { + const note = await pb.collection(notesCollectionName).getOne(noteId); + + document.getElementById('noteId').value = note.id; + document.getElementById('noteSentiment').value = note.sentiment || ''; + document.getElementById('noteDate').value = note.date ? new Date(note.date).toISOString().slice(0, 16) : ''; + + if (note.job) { + document.getElementById('noteJob').value = note.job; + } + + // Set Quill editor content + const editor = getNoteEditor(); + if (editor && note.content) { + editor.root.innerHTML = note.content; + } + + // Load jobs for select if not already loaded + await loadJobsForSelect(); + } catch (error) { + console.error('Error loading note:', error); + showMessage('Error loading note: ' + (error.message || 'Unknown error'), 'error'); + } +} + +/** + * Save note (create or update) + */ +async function saveNote() { + try { + const noteId = document.getElementById('noteId')?.value; + const jobId = document.getElementById('noteJob')?.value || document.getElementById('jobId')?.value; + const editor = getNoteEditor(); + const content = document.getElementById('noteContent')?.value || editor?.root.innerHTML || ''; + const sentiment = document.getElementById('noteSentiment')?.value || ''; + const date = document.getElementById('noteDate')?.value || new Date().toISOString(); + + if (!jobId && !noteId) { + showMessage('Please select a job', 'error'); + return; + } + + const noteData = { + content: content, + sentiment: sentiment, + date: date + }; + + let savedNote; + + if (noteId) { + // Update existing note + savedNote = await pb.collection(notesCollectionName).update(noteId, noteData); + showMessage('Note updated successfully', 'success'); + } else { + // Create new note + savedNote = await pb.collection(notesCollectionName).create(noteData); + showMessage('Note created successfully', 'success'); + + // Link note to job + if (jobId) { + await linkNoteToJob(savedNote.id, jobId); + } + } + + // Close modal and reload + if (document.getElementById('noteModal')) { + closeNoteModal(); + loadAllNotes(); + } else { + // In job modal + cancelAddNote(); + loadJobNotes(); + } + } catch (error) { + console.error('Error saving note:', error); + showMessage('Error saving note: ' + (error.message || 'Unknown error'), 'error'); + } +} + +/** + * Link note to job + */ +async function linkNoteToJob(noteId, jobId) { + try { + const job = await pb.collection(jobsCollectionName).getOne(jobId); + const noteRefs = job.Note_Ref || []; + + if (!noteRefs.includes(noteId)) { + noteRefs.push(noteId); + await pb.collection(jobsCollectionName).update(jobId, { + Note_Ref: noteRefs + }); + } + } catch (error) { + console.error('Error linking note to job:', error); + } +} + +/** + * Delete note + */ +async function deleteNote(noteId) { + if (!confirm('Are you sure you want to delete this note?')) { + return; + } + + try { + await pb.collection(notesCollectionName).delete(noteId); + showMessage('Note deleted successfully', 'success'); + loadAllNotes(); + } catch (error) { + console.error('Error deleting note:', error); + showMessage('Error deleting note: ' + (error.message || 'Unknown error'), 'error'); + } +} + +/** + * Delete note from job (unlink) + */ +async function deleteNoteFromJob(noteId) { + if (!confirm('Are you sure you want to delete this note?')) { + return; + } + + try { + const jobId = document.getElementById('jobId').value; + if (jobId) { + const job = await pb.collection(jobsCollectionName).getOne(jobId); + const noteRefs = (job.Note_Ref || []).filter(id => id !== noteId); + await pb.collection(jobsCollectionName).update(jobId, { + Note_Ref: noteRefs + }); + } + + await pb.collection(notesCollectionName).delete(noteId); + showMessage('Note deleted successfully', 'success'); + loadJobNotes(); + } catch (error) { + console.error('Error deleting note:', error); + showMessage('Error deleting note: ' + (error.message || 'Unknown error'), 'error'); + } +} + +/** + * Show add note form (in job modal) + */ +function showAddNoteForm() { + document.getElementById('addNoteForm').classList.remove('hidden'); + const editor = getNoteEditor(); + if (editor) { + editor.setContents([]); + } +} + +/** + * Cancel add note form + */ +function cancelAddNote() { + document.getElementById('addNoteForm').classList.add('hidden'); + const editor = getNoteEditor(); + if (editor) { + editor.setContents([]); + } +} + diff --git a/js/pocketbase.js b/js/pocketbase.js new file mode 100644 index 0000000..90f1dfb --- /dev/null +++ b/js/pocketbase.js @@ -0,0 +1,23 @@ +// PocketBase client initialization +// Update this URL to match your PocketBase instance +const POCKETBASE_URL = 'https://pocketbase.ccllc.pro'; + +// Initialize PocketBase client +const pb = new PocketBase(POCKETBASE_URL); + +// Handle connection errors +pb.beforeSend = function (url, options) { + console.log('PocketBase request:', url, options); + return { url, options }; +}; + +// Handle auth state changes +pb.authStore.onChange((token, model) => { + console.log('Auth state changed:', token ? 'authenticated' : 'not authenticated'); +}); + +// Export for use in other modules +if (typeof module !== 'undefined' && module.exports) { + module.exports = pb; +} + diff --git a/notes.html b/notes.html new file mode 100644 index 0000000..a88f385 --- /dev/null +++ b/notes.html @@ -0,0 +1,233 @@ + + + + + + Job Tracking - Notes + + + + + + + + + + +
+ +
+
+

Job Notes

+

Manage job notes and reports

+
+ +
+ + +
+ + +
+
+

Loading notes...

+
+ + + + + + +
+ + + + + + + + + + + + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..7e18627 --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "job-tracking-system", + "version": "1.0.0", + "description": "Job Tracking System - A web application for managing construction/contracting jobs", + "type": "module", + "scripts": { + "dev": "bun run server.js" + }, + "keywords": [ + "job-tracking", + "pocketbase", + "construction" + ], + "author": "", + "license": "MIT" +} + diff --git a/server.js b/server.js new file mode 100644 index 0000000..47f1158 --- /dev/null +++ b/server.js @@ -0,0 +1,61 @@ +// Simple Bun server for development +const server = Bun.serve({ + port: 3075, + async fetch(request) { + const url = new URL(request.url); + let pathname = url.pathname; + + // Default to index.html for root + if (pathname === '/') { + pathname = '/index.html'; + } + + // Try to serve the file + const file = Bun.file('.' + pathname); + + // If file doesn't exist, try with .html extension + if (!(await file.exists())) { + const htmlFile = Bun.file('.' + pathname + '.html'); + if (await htmlFile.exists()) { + return new Response(htmlFile, { + headers: { + 'Content-Type': getContentType(pathname + '.html'), + }, + }); + } + } + + // Serve the file if it exists + if (await file.exists()) { + return new Response(file, { + headers: { + 'Content-Type': getContentType(pathname), + }, + }); + } + + // 404 for not found + return new Response('Not Found', { status: 404 }); + }, +}); + +function getContentType(pathname) { + const ext = pathname.split('.').pop()?.toLowerCase(); + const types = { + 'html': 'text/html', + 'css': 'text/css', + 'js': 'application/javascript', + 'json': 'application/json', + 'png': 'image/png', + 'jpg': 'image/jpeg', + 'jpeg': 'image/jpeg', + 'gif': 'image/gif', + 'svg': 'image/svg+xml', + 'ico': 'image/x-icon', + }; + return types[ext] || 'text/plain'; +} + +console.log(`🚀 Server running at http://localhost:${server.port}`); +console.log(`📁 Serving files from: ${process.cwd()}`); +