INIT
This commit is contained in:
@@ -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.
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"pocketbase": {
|
||||
"collections": {
|
||||
"jobs": "Job_Info",
|
||||
"notes": "notes",
|
||||
"users": "users"
|
||||
}
|
||||
},
|
||||
"pagination": {
|
||||
"itemsPerPage": 20
|
||||
},
|
||||
"filters": {
|
||||
"defaultFilter": "all"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+270
@@ -0,0 +1,270 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Job Tracking - Dashboard</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/pocketbase@latest/dist/pocketbase.umd.js"></script>
|
||||
</head>
|
||||
<body class="min-h-screen bg-gray-50">
|
||||
<!-- Navigation Header -->
|
||||
<nav class="bg-white shadow-sm border-b border-gray-200">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex flex-col sm:flex-row justify-between items-center h-16 sm:h-auto py-4 sm:py-0 gap-4 sm:gap-0">
|
||||
<div class="text-lg sm:text-xl font-bold text-gray-800">Job Tracking</div>
|
||||
<div class="flex flex-col sm:flex-row items-center gap-3 w-full sm:w-auto">
|
||||
<div class="text-xs sm:text-sm text-gray-600" id="userEmail"></div>
|
||||
<button
|
||||
onclick="logout()"
|
||||
class="w-full sm:w-auto bg-red-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 transition"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Page Header -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-xl sm:text-2xl font-bold text-gray-800 mb-2" id="welcomeTitle">Welcome</h1>
|
||||
<p class="text-sm sm:text-base text-gray-600">Manage your job records and tracking</p>
|
||||
</div>
|
||||
|
||||
<!-- Dashboard Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
||||
<!-- Job Tracking Card -->
|
||||
<div class="bg-white rounded-lg shadow border border-gray-200 p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="w-12 h-12 bg-indigo-100 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-2">Job Tracking</h3>
|
||||
<p class="text-sm text-gray-600 mb-4">View and manage job information</p>
|
||||
<a href="jobs.html" class="text-indigo-600 hover:text-indigo-700 font-medium text-sm">View Jobs →</a>
|
||||
</div>
|
||||
|
||||
<!-- Reports Card -->
|
||||
<div class="bg-white rounded-lg shadow border border-gray-200 p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-2">Notes & Reports</h3>
|
||||
<p class="text-sm text-gray-600 mb-4">Manage job notes and reports</p>
|
||||
<a href="notes.html" class="text-green-600 hover:text-green-700 font-medium text-sm">View Notes →</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Stats -->
|
||||
<div class="bg-white rounded-lg shadow border border-gray-200 p-4 sm:p-6 mb-6 sm:mb-8">
|
||||
<h2 class="text-base sm:text-lg font-semibold text-gray-800 mb-4">Quick Stats</h2>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-7 gap-3 sm:gap-4">
|
||||
<!-- Total Jobs -->
|
||||
<div class="text-center p-3 sm:p-4 bg-indigo-50 rounded-lg border border-indigo-100 hover:shadow-md transition-shadow">
|
||||
<div class="text-xl sm:text-2xl font-bold text-indigo-600 mb-1" id="totalJobs">
|
||||
<span class="animate-pulse">...</span>
|
||||
</div>
|
||||
<div class="text-xs font-medium text-gray-700">Total Jobs</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Jobs -->
|
||||
<div class="text-center p-3 sm:p-4 bg-green-50 rounded-lg border border-green-100 hover:shadow-md transition-shadow">
|
||||
<div class="text-xl sm:text-2xl font-bold text-green-600 mb-1" id="activeJobs">
|
||||
<span class="animate-pulse">...</span>
|
||||
</div>
|
||||
<div class="text-xs font-medium text-gray-700">Active</div>
|
||||
</div>
|
||||
|
||||
<!-- Estimating Jobs -->
|
||||
<div class="text-center p-3 sm:p-4 bg-blue-50 rounded-lg border border-blue-100 hover:shadow-md transition-shadow">
|
||||
<div class="text-xl sm:text-2xl font-bold text-blue-600 mb-1" id="estimatingJobs">
|
||||
<span class="animate-pulse">...</span>
|
||||
</div>
|
||||
<div class="text-xs font-medium text-gray-700">Estimating</div>
|
||||
</div>
|
||||
|
||||
<!-- Est Sent Jobs -->
|
||||
<div class="text-center p-3 sm:p-4 bg-yellow-50 rounded-lg border border-yellow-100 hover:shadow-md transition-shadow">
|
||||
<div class="text-xl sm:text-2xl font-bold text-yellow-600 mb-1" id="estSentJobs">
|
||||
<span class="animate-pulse">...</span>
|
||||
</div>
|
||||
<div class="text-xs font-medium text-gray-700">Est Sent</div>
|
||||
</div>
|
||||
|
||||
<!-- Est Hold Jobs -->
|
||||
<div class="text-center p-3 sm:p-4 bg-orange-50 rounded-lg border border-orange-100 hover:shadow-md transition-shadow">
|
||||
<div class="text-xl sm:text-2xl font-bold text-orange-600 mb-1" id="estHoldJobs">
|
||||
<span class="animate-pulse">...</span>
|
||||
</div>
|
||||
<div class="text-xs font-medium text-gray-700">Est Hold</div>
|
||||
</div>
|
||||
|
||||
<!-- Follow Up Jobs -->
|
||||
<div class="text-center p-3 sm:p-4 bg-yellow-50 rounded-lg border border-yellow-100 hover:shadow-md transition-shadow">
|
||||
<div class="text-xl sm:text-2xl font-bold text-yellow-600 mb-1" id="followUpJobs">
|
||||
<span class="animate-pulse">...</span>
|
||||
</div>
|
||||
<div class="text-xs font-medium text-gray-700">Follow Up</div>
|
||||
</div>
|
||||
|
||||
<!-- Awarded Jobs -->
|
||||
<div class="text-center p-3 sm:p-4 bg-green-50 rounded-lg border border-green-100 hover:shadow-md transition-shadow">
|
||||
<div class="text-xl sm:text-2xl font-bold text-green-600 mb-1" id="awardedJobs">
|
||||
<span class="animate-pulse">...</span>
|
||||
</div>
|
||||
<div class="text-xs font-medium text-gray-700">Awarded</div>
|
||||
</div>
|
||||
|
||||
<!-- In Progress Jobs -->
|
||||
<div class="text-center p-3 sm:p-4 bg-green-50 rounded-lg border border-green-100 hover:shadow-md transition-shadow">
|
||||
<div class="text-xl sm:text-2xl font-bold text-green-600 mb-1" id="inProgressJobs">
|
||||
<span class="animate-pulse">...</span>
|
||||
</div>
|
||||
<div class="text-xs font-medium text-gray-700">In Progress</div>
|
||||
</div>
|
||||
|
||||
<!-- Billed (In Progress) Jobs -->
|
||||
<div class="text-center p-3 sm:p-4 bg-purple-50 rounded-lg border border-purple-100 hover:shadow-md transition-shadow">
|
||||
<div class="text-xl sm:text-2xl font-bold text-purple-600 mb-1" id="billedInProgressJobs">
|
||||
<span class="animate-pulse">...</span>
|
||||
</div>
|
||||
<div class="text-xs font-medium text-gray-700">Billed (IP)</div>
|
||||
</div>
|
||||
|
||||
<!-- Billed (Closed) Jobs -->
|
||||
<div class="text-center p-3 sm:p-4 bg-purple-50 rounded-lg border border-purple-100 hover:shadow-md transition-shadow">
|
||||
<div class="text-xl sm:text-2xl font-bold text-purple-600 mb-1" id="billedClosedJobs">
|
||||
<span class="animate-pulse">...</span>
|
||||
</div>
|
||||
<div class="text-xs font-medium text-gray-700">Billed (Closed)</div>
|
||||
</div>
|
||||
|
||||
<!-- Not Awarded Jobs -->
|
||||
<div class="text-center p-3 sm:p-4 bg-red-50 rounded-lg border border-red-100 hover:shadow-md transition-shadow">
|
||||
<div class="text-xl sm:text-2xl font-bold text-red-600 mb-1" id="notAwardedJobs">
|
||||
<span class="animate-pulse">...</span>
|
||||
</div>
|
||||
<div class="text-xs font-medium text-gray-700">Not Awarded</div>
|
||||
</div>
|
||||
|
||||
<!-- Not Bidding Jobs -->
|
||||
<div class="text-center p-3 sm:p-4 bg-red-50 rounded-lg border border-red-100 hover:shadow-md transition-shadow">
|
||||
<div class="text-xl sm:text-2xl font-bold text-red-600 mb-1" id="notBiddingJobs">
|
||||
<span class="animate-pulse">...</span>
|
||||
</div>
|
||||
<div class="text-xs font-medium text-gray-700">Not Bidding</div>
|
||||
</div>
|
||||
|
||||
<!-- Archive Jobs -->
|
||||
<div class="text-center p-3 sm:p-4 bg-gray-50 rounded-lg border border-gray-100 hover:shadow-md transition-shadow">
|
||||
<div class="text-xl sm:text-2xl font-bold text-gray-600 mb-1" id="archiveJobs">
|
||||
<span class="animate-pulse">...</span>
|
||||
</div>
|
||||
<div class="text-xs font-medium text-gray-700">Archive</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="bg-white rounded-lg shadow border border-gray-200 p-4 sm:p-6 mb-6 sm:mb-8">
|
||||
<h2 class="text-base sm:text-lg font-semibold text-gray-800 mb-4">Quick Actions</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<!-- Add New Job -->
|
||||
<a href="jobs.html" class="flex items-center p-4 border border-gray-200 rounded-lg hover:bg-gray-50 hover:border-indigo-300 transition-colors">
|
||||
<div class="w-10 h-10 bg-indigo-100 rounded-lg flex items-center justify-center mr-4 flex-shrink-0">
|
||||
<svg class="w-5 h-5 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="font-medium text-gray-900 text-sm sm:text-base">Add New Job</div>
|
||||
<div class="text-xs sm:text-sm text-gray-500">Create a new job record</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- View All Jobs -->
|
||||
<a href="jobs.html" class="flex items-center p-4 border border-gray-200 rounded-lg hover:bg-gray-50 hover:border-green-300 transition-colors">
|
||||
<div class="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center mr-4 flex-shrink-0">
|
||||
<svg class="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="font-medium text-gray-900 text-sm sm:text-base">View All Jobs</div>
|
||||
<div class="text-xs sm:text-sm text-gray-500">Browse and manage all jobs</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="js/pocketbase.js"></script>
|
||||
<script src="js/config.js"></script>
|
||||
<script src="js/auth.js"></script>
|
||||
<script src="js/jobs.js"></script>
|
||||
<script>
|
||||
// Check authentication
|
||||
checkAuth();
|
||||
|
||||
// Load dashboard data
|
||||
async function loadDashboard() {
|
||||
try {
|
||||
// Ensure config is initialized first
|
||||
if (typeof initConfig !== 'undefined') {
|
||||
await initConfig();
|
||||
}
|
||||
const stats = await getJobStats();
|
||||
document.getElementById('totalJobs').textContent = stats.total || 0;
|
||||
document.getElementById('activeJobs').textContent = stats.active || 0;
|
||||
document.getElementById('estimatingJobs').textContent = stats.estimating || 0;
|
||||
document.getElementById('estSentJobs').textContent = stats.estSent || 0;
|
||||
document.getElementById('estHoldJobs').textContent = stats.estHold || 0;
|
||||
document.getElementById('followUpJobs').textContent = stats.followUp || 0;
|
||||
document.getElementById('awardedJobs').textContent = stats.awarded || 0;
|
||||
document.getElementById('inProgressJobs').textContent = stats.inProgress || 0;
|
||||
document.getElementById('billedInProgressJobs').textContent = stats.billedInProgress || 0;
|
||||
document.getElementById('billedClosedJobs').textContent = stats.billedClosed || 0;
|
||||
document.getElementById('notAwardedJobs').textContent = stats.notAwarded || 0;
|
||||
document.getElementById('notBiddingJobs').textContent = stats.notBidding || 0;
|
||||
document.getElementById('archiveJobs').textContent = stats.archive || 0;
|
||||
} catch (error) {
|
||||
console.error('Error loading dashboard:', error);
|
||||
// Show error state
|
||||
document.getElementById('totalJobs').textContent = '0';
|
||||
document.getElementById('activeJobs').textContent = '0';
|
||||
document.getElementById('estimatingJobs').textContent = '0';
|
||||
document.getElementById('estSentJobs').textContent = '0';
|
||||
document.getElementById('estHoldJobs').textContent = '0';
|
||||
document.getElementById('followUpJobs').textContent = '0';
|
||||
document.getElementById('awardedJobs').textContent = '0';
|
||||
document.getElementById('inProgressJobs').textContent = '0';
|
||||
document.getElementById('billedInProgressJobs').textContent = '0';
|
||||
document.getElementById('billedClosedJobs').textContent = '0';
|
||||
document.getElementById('notAwardedJobs').textContent = '0';
|
||||
document.getElementById('notBiddingJobs').textContent = '0';
|
||||
document.getElementById('archiveJobs').textContent = '0';
|
||||
}
|
||||
}
|
||||
|
||||
// Set user email
|
||||
const user = pb.authStore.model;
|
||||
if (user) {
|
||||
document.getElementById('userEmail').textContent = user.email || '';
|
||||
document.getElementById('welcomeTitle').textContent = `Welcome, ${user.email?.split('@')[0] || 'User'}`;
|
||||
}
|
||||
|
||||
loadDashboard();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Job Tracking - Login</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/pocketbase@latest/dist/pocketbase.umd.js"></script>
|
||||
</head>
|
||||
<body class="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4">
|
||||
<div class="max-w-md w-full bg-white rounded-lg shadow-xl p-8">
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-800 mb-2">Job Tracking</h1>
|
||||
<p class="text-gray-600">Sign in to your account</p>
|
||||
</div>
|
||||
|
||||
<div id="errorMessage" class="hidden bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm mb-4"></div>
|
||||
|
||||
<form id="loginForm" class="space-y-6">
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-gray-700 mb-2">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
required
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none transition text-base"
|
||||
placeholder="Enter your email"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-gray-700 mb-2">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
required
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none transition text-base"
|
||||
placeholder="Enter your password"
|
||||
>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
id="loginButton"
|
||||
class="w-full bg-indigo-600 text-white py-3 px-4 rounded-lg font-medium hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 transition disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script src="js/pocketbase.js"></script>
|
||||
<script src="js/auth.js"></script>
|
||||
<script>
|
||||
document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const email = document.getElementById('email').value;
|
||||
const password = document.getElementById('password').value;
|
||||
const errorMessage = document.getElementById('errorMessage');
|
||||
const loginButton = document.getElementById('loginButton');
|
||||
|
||||
// Disable button and show loading state
|
||||
loginButton.disabled = true;
|
||||
loginButton.textContent = 'Signing in...';
|
||||
errorMessage.classList.add('hidden');
|
||||
|
||||
try {
|
||||
await login(email, password);
|
||||
window.location.href = 'dashboard.html';
|
||||
} catch (error) {
|
||||
errorMessage.textContent = error.message || 'Login failed. Please try again.';
|
||||
errorMessage.classList.remove('hidden');
|
||||
loginButton.disabled = false;
|
||||
loginButton.textContent = 'Sign In';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,909 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Job Tracking - Jobs</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/pocketbase@latest/dist/pocketbase.umd.js"></script>
|
||||
<link href="https://cdn.quilljs.com/1.3.6/quill.snow.css" rel="stylesheet">
|
||||
<script src="https://cdn.quilljs.com/1.3.6/quill.js"></script>
|
||||
</head>
|
||||
<body class="min-h-screen bg-gray-50">
|
||||
<!-- Navigation Header -->
|
||||
<nav class="bg-white shadow-sm border-b border-gray-200">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex flex-col sm:flex-row justify-between items-center h-16 sm:h-auto py-4 sm:py-0 gap-4 sm:gap-0">
|
||||
<div class="flex items-center gap-4 w-full sm:w-auto">
|
||||
<a href="dashboard.html" class="text-indigo-600 hover:text-indigo-700 font-medium text-sm sm:text-base">← Dashboard</a>
|
||||
<div class="text-lg sm:text-xl font-bold text-gray-800">Job Tracking</div>
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row items-center gap-3 w-full sm:w-auto">
|
||||
<div class="text-xs sm:text-sm text-gray-600" id="userEmail"></div>
|
||||
<button
|
||||
onclick="logout()"
|
||||
class="w-full sm:w-auto bg-red-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 transition"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Message Container -->
|
||||
<div id="messageContainer" class="mb-4"></div>
|
||||
|
||||
<!-- Search and Filter Section -->
|
||||
<div class="bg-white rounded-lg shadow border border-gray-200 p-4 mb-6">
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- Search Bar -->
|
||||
<input
|
||||
type="text"
|
||||
id="searchInput"
|
||||
placeholder="Search by job number, name, or client..."
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none text-base"
|
||||
>
|
||||
|
||||
<!-- Filter Section -->
|
||||
<div class="space-y-3">
|
||||
<!-- Estimator Filters -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1.5">Filter by Estimator:</label>
|
||||
<div class="flex flex-wrap gap-1.5" id="filterButtons">
|
||||
<button
|
||||
onclick="setEstimatorFilter('all')"
|
||||
id="filterAll"
|
||||
class="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 bg-indigo-600 text-white hover:bg-indigo-700"
|
||||
>
|
||||
All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Job Status Filters -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1.5">Filter by Status:</label>
|
||||
<div class="flex flex-wrap gap-1.5" id="statusFilterButtons">
|
||||
<button
|
||||
onclick="setStatusFilter('all')"
|
||||
id="statusFilterAll"
|
||||
class="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 bg-indigo-600 text-white hover:bg-indigo-700"
|
||||
>
|
||||
All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- View Toggle -->
|
||||
<div class="mb-4 flex justify-end">
|
||||
<div class="inline-flex rounded-lg border border-gray-300 bg-white p-1">
|
||||
<button
|
||||
onclick="switchView('table')"
|
||||
id="tableViewBtn"
|
||||
class="px-4 py-2 rounded-md text-sm font-medium transition focus:outline-none focus:ring-2 focus:ring-indigo-500 bg-indigo-600 text-white"
|
||||
>
|
||||
<svg class="w-5 h-5 inline-block mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h18M3 14h18m-9-4v8m-7 0h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
Table
|
||||
</button>
|
||||
<button
|
||||
onclick="switchView('card')"
|
||||
id="cardViewBtn"
|
||||
class="px-4 py-2 rounded-md text-sm font-medium transition focus:outline-none focus:ring-2 focus:ring-indigo-500 text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
<svg class="w-5 h-5 inline-block mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"></path>
|
||||
</svg>
|
||||
Cards
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Jobs Table View -->
|
||||
<div id="tableView" class="bg-white rounded-lg shadow border border-gray-200 overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-3 sm:px-6 py-2 sm:py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Job Number</th>
|
||||
<th class="px-3 sm:px-6 py-2 sm:py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Job Name</th>
|
||||
<th class="px-3 sm:px-6 py-2 sm:py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Client</th>
|
||||
<th class="px-3 sm:px-6 py-2 sm:py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Estimator</th>
|
||||
<th class="px-3 sm:px-6 py-2 sm:py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||
<th class="px-3 sm:px-6 py-2 sm:py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Type</th>
|
||||
<th class="px-3 sm:px-6 py-2 sm:py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Division</th>
|
||||
<th class="px-3 sm:px-6 py-2 sm:py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="jobsTableBody" class="bg-white divide-y divide-gray-200">
|
||||
<tr id="loadingRow">
|
||||
<td colspan="8" class="px-6 py-8 text-center text-gray-500">Loading...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Jobs Card View -->
|
||||
<div id="cardView" class="hidden">
|
||||
<div id="jobsCardContainer" class="space-y-4">
|
||||
<div class="text-center py-8 text-gray-500">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div id="paginationContainer" class="mt-4 flex justify-between items-center"></div>
|
||||
</main>
|
||||
|
||||
<!-- Job Dashboard Modal -->
|
||||
<div id="jobModal" class="hidden fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto z-50">
|
||||
<div class="relative top-0 sm:top-10 mx-auto p-4 sm:p-6 border-0 sm:border w-full sm:max-w-7xl shadow-lg rounded-none sm:rounded-md bg-gray-50 mb-0 sm:mb-10 min-h-screen sm:min-h-[600px]">
|
||||
<!-- Dashboard Header -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
|
||||
<div class="flex flex-col lg:flex-row justify-between items-start gap-4 mb-4">
|
||||
<div class="flex-1">
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-2" id="dashboardJobName">Job Dashboard</h2>
|
||||
<div class="flex items-center gap-4 flex-wrap">
|
||||
<div>
|
||||
<span class="text-sm text-gray-500">Job Number:</span>
|
||||
<span class="text-sm font-medium text-gray-900 ml-2" id="dashboardJobNumber">-</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-sm text-gray-500">Status:</span>
|
||||
<span class="text-sm font-medium text-indigo-600 ml-2" id="dashboardJobStatus">-</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Info Card -->
|
||||
<div class="bg-gray-50 rounded-lg p-4 border border-gray-200 min-w-[250px]">
|
||||
<h3 class="text-sm font-semibold text-gray-900 mb-3">Quick Info</h3>
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-xs text-gray-500">Job Type:</span>
|
||||
<span class="text-xs font-medium text-gray-900" id="quickJobType">-</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-xs text-gray-500">Division:</span>
|
||||
<span class="text-xs font-medium text-gray-900" id="quickDivision">-</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-xs text-gray-500">Estimator:</span>
|
||||
<span class="text-xs font-medium text-gray-900" id="quickEstimator">-</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-xs text-gray-500">Office Rep:</span>
|
||||
<span class="text-xs font-medium text-gray-900" id="quickOfficeRep">-</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onclick="closeJobModal()"
|
||||
class="text-gray-400 hover:text-gray-500"
|
||||
>
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tabs Navigation -->
|
||||
<div class="border-b border-gray-200">
|
||||
<nav class="flex space-x-8 overflow-x-auto">
|
||||
<button onclick="switchDashboardTab('overview')" id="tab-overview" class="dashboard-tab border-b-2 border-indigo-500 text-indigo-600 font-medium text-sm py-2 px-1 whitespace-nowrap">Overview</button>
|
||||
<button onclick="switchDashboardTab('team')" id="tab-team" class="dashboard-tab border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 font-medium text-sm py-2 px-1 whitespace-nowrap">Team & Client</button>
|
||||
<button onclick="switchDashboardTab('dates')" id="tab-dates" class="dashboard-tab border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 font-medium text-sm py-2 px-1 whitespace-nowrap">Dates & Links</button>
|
||||
<button onclick="switchDashboardTab('flags')" id="tab-flags" class="dashboard-tab border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 font-medium text-sm py-2 px-1 whitespace-nowrap">Status Flags</button>
|
||||
<button onclick="switchDashboardTab('notes')" id="tab-notes" class="dashboard-tab border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 font-medium text-sm py-2 px-1 whitespace-nowrap hidden" id="notesTabButton">Notes</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dashboard Content -->
|
||||
<form id="jobForm">
|
||||
<input type="hidden" id="jobId" name="id">
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Overview Tab Content -->
|
||||
<div id="dashboard-tab-overview" class="dashboard-tab-content space-y-6 min-h-[500px]">
|
||||
<!-- Basic Information Card -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">Basic Information</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="Job_Number" class="block text-sm font-medium text-gray-700 mb-1">Job Number</label>
|
||||
<input type="text" id="Job_Number" name="Job_Number" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none text-base">
|
||||
</div>
|
||||
<div>
|
||||
<label for="Job_Full_Name" class="block text-sm font-medium text-gray-700 mb-1">Job Full Name</label>
|
||||
<input type="text" id="Job_Full_Name" name="Job_Full_Name" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none text-base">
|
||||
</div>
|
||||
<div>
|
||||
<label for="Job_Name" class="block text-sm font-medium text-gray-700 mb-1">Job Name</label>
|
||||
<input type="text" id="Job_Name" name="Job_Name" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none text-base">
|
||||
</div>
|
||||
<div>
|
||||
<label for="Job_Address" class="block text-sm font-medium text-gray-700 mb-1">Job Address</label>
|
||||
<input type="text" id="Job_Address" name="Job_Address" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none text-base">
|
||||
</div>
|
||||
<div>
|
||||
<label for="Job_Type" class="block text-sm font-medium text-gray-700 mb-1">Job Type</label>
|
||||
<select id="Job_Type" name="Job_Type" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none text-base">
|
||||
<option value="">Select type...</option>
|
||||
<option value="FX">FX</option>
|
||||
<option value="TM">TM</option>
|
||||
<option value="UP">UP</option>
|
||||
<option value="PW">PW</option>
|
||||
<option value="FXEX">FXEX</option>
|
||||
<option value="PWEX">PWEX</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="Job_Status" class="block text-sm font-medium text-gray-700 mb-1">Job Status</label>
|
||||
<select id="Job_Status" name="Job_Status" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none text-base">
|
||||
<option value="">Select status...</option>
|
||||
<option value="Estimating">Estimating</option>
|
||||
<option value="Est Sent">Est Sent</option>
|
||||
<option value="Est Hold">Est Hold</option>
|
||||
<option value="Follow Up">Follow Up</option>
|
||||
<option value="Awarded">Awarded</option>
|
||||
<option value="In Progress">In Progress</option>
|
||||
<option value="On Hold">On Hold</option>
|
||||
<option value="Billed (In Progress)">Billed (In Progress)</option>
|
||||
<option value="Billed (Closed)">Billed (Closed)</option>
|
||||
<option value="Archive">Archive</option>
|
||||
<option value="Not Awarded">Not Awarded</option>
|
||||
<option value="Not Bidding">Not Bidding</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="Job_Division" class="block text-sm font-medium text-gray-700 mb-1">Job Division</label>
|
||||
<select id="Job_Division" name="Job_Division" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none text-base">
|
||||
<option value="">Select division...</option>
|
||||
<option value="C#">C#</option>
|
||||
<option value="R#">R#</option>
|
||||
<option value="S#">S#</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Team & Client Tab Content -->
|
||||
<div id="dashboard-tab-team" class="dashboard-tab-content hidden space-y-6 min-h-[500px]">
|
||||
<!-- Team Assignment Card -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">Team Assignment</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="Estimator" class="block text-sm font-medium text-gray-700 mb-1">Estimator</label>
|
||||
<select id="Estimator" name="Estimator" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none text-base">
|
||||
<option value="">Select estimator...</option>
|
||||
<option value="Beth Cardoza">Beth Cardoza</option>
|
||||
<option value="Steve Brewer">Steve Brewer</option>
|
||||
<option value="Timothy Cardoza">Timothy Cardoza</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="Office_Rep" class="block text-sm font-medium text-gray-700 mb-1">Office Rep</label>
|
||||
<select id="Office_Rep" name="Office_Rep" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none text-base">
|
||||
<option value="">Select office rep...</option>
|
||||
<option value="Regina McClain">Regina McClain</option>
|
||||
<option value="Beth Cardoza">Beth Cardoza</option>
|
||||
<option value="Steve Brewer">Steve Brewer</option>
|
||||
<option value="Timothy Cardoza">Timothy Cardoza</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="Project_Manager" class="block text-sm font-medium text-gray-700 mb-1">Project Manager</label>
|
||||
<input type="text" id="Project_Manager" name="Project_Manager" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none text-base">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Client Information Card -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">Client Information</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="Company_Client" class="block text-sm font-medium text-gray-700 mb-1">Company Client</label>
|
||||
<input type="text" id="Company_Client" name="Company_Client" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none text-base">
|
||||
</div>
|
||||
<div>
|
||||
<label for="Contact_Person" class="block text-sm font-medium text-gray-700 mb-1">Contact Person</label>
|
||||
<input type="text" id="Contact_Person" name="Contact_Person" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none text-base">
|
||||
</div>
|
||||
<div>
|
||||
<label for="Phone_Number" class="block text-sm font-medium text-gray-700 mb-1">Phone Number</label>
|
||||
<input type="tel" id="Phone_Number" name="Phone_Number" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none text-base">
|
||||
</div>
|
||||
<div>
|
||||
<label for="Email" class="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
||||
<input type="email" id="Email" name="Email" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none text-base">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dates & Links Tab Content -->
|
||||
<div id="dashboard-tab-dates" class="dashboard-tab-content hidden space-y-6 min-h-[500px]">
|
||||
<!-- Dates & Scheduling Card -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">Dates & Scheduling</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="Start_Date" class="block text-sm font-medium text-gray-700 mb-1">Start Date</label>
|
||||
<input type="date" id="Start_Date" name="Start_Date" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none text-base">
|
||||
</div>
|
||||
<div>
|
||||
<label for="Due_Date" class="block text-sm font-medium text-gray-700 mb-1">Due Date</label>
|
||||
<input type="date" id="Due_Date" name="Due_Date" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none text-base">
|
||||
</div>
|
||||
<div>
|
||||
<label for="Submission_Date" class="block text-sm font-medium text-gray-700 mb-1">Submission Date</label>
|
||||
<input type="date" id="Submission_Date" name="Submission_Date" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none text-base">
|
||||
</div>
|
||||
<div>
|
||||
<label for="Due_Date_Source" class="block text-sm font-medium text-gray-700 mb-1">Due Date Source</label>
|
||||
<input type="text" id="Due_Date_Source" name="Due_Date_Source" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none text-base">
|
||||
</div>
|
||||
<div>
|
||||
<label for="Due_Date_Counter" class="block text-sm font-medium text-gray-700 mb-1">Due Date Counter</label>
|
||||
<input type="text" id="Due_Date_Counter" name="Due_Date_Counter" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none text-base">
|
||||
</div>
|
||||
<div>
|
||||
<label for="Due_Time" class="block text-sm font-medium text-gray-700 mb-1">Due Time</label>
|
||||
<input type="time" id="Due_Time" name="Due_Time" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none text-base">
|
||||
</div>
|
||||
<div>
|
||||
<label for="Schedule_Confidence" class="block text-sm font-medium text-gray-700 mb-1">Schedule Confidence</label>
|
||||
<input type="text" id="Schedule_Confidence" name="Schedule_Confidence" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none text-base">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Links & References Card -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">Links & References</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="Job_QB_Link" class="block text-sm font-medium text-gray-700 mb-1">Job QB Link</label>
|
||||
<input type="url" id="Job_QB_Link" name="Job_QB_Link" placeholder="https://..." class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none text-base">
|
||||
</div>
|
||||
<div>
|
||||
<label for="Voxer_Link" class="block text-sm font-medium text-gray-700 mb-1">Voxer Link</label>
|
||||
<input type="url" id="Voxer_Link" name="Voxer_Link" placeholder="https://..." class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none text-base">
|
||||
</div>
|
||||
<div>
|
||||
<label for="Job_Folder_Link" class="block text-sm font-medium text-gray-700 mb-1">Job Folder Link</label>
|
||||
<input type="url" id="Job_Folder_Link" name="Job_Folder_Link" placeholder="https://..." class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none text-base">
|
||||
</div>
|
||||
<div>
|
||||
<label for="Job_Codes" class="block text-sm font-medium text-gray-700 mb-1">Job Codes</label>
|
||||
<input type="text" id="Job_Codes" name="Job_Codes" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none text-base">
|
||||
</div>
|
||||
<div>
|
||||
<label for="Job_Number_Parent" class="block text-sm font-medium text-gray-700 mb-1">Job Number Parent</label>
|
||||
<input type="text" id="Job_Number_Parent" name="Job_Number_Parent" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none text-base">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Flags Tab Content -->
|
||||
<div id="dashboard-tab-flags" class="dashboard-tab-content hidden space-y-6 min-h-[500px]">
|
||||
<!-- Status Flags Card -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">Status Flags</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" id="Active" name="Active" class="w-4 h-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500">
|
||||
<label for="Active" class="ml-2 text-sm font-medium text-gray-700">Active</label>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" id="Flag" name="Flag" class="w-4 h-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500">
|
||||
<label for="Flag" class="ml-2 text-sm font-medium text-gray-700">Flag</label>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" id="Tax_Exempt" name="Tax_Exempt" class="w-4 h-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500">
|
||||
<label for="Tax_Exempt" class="ml-2 text-sm font-medium text-gray-700">Tax Exempt</label>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" id="Docs_Uploaded" name="Docs_Uploaded" class="w-4 h-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500">
|
||||
<label for="Docs_Uploaded" class="ml-2 text-sm font-medium text-gray-700">Docs Uploaded</label>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" id="Tax_Exempt_Docs_Uploaded" name="Tax_Exempt_Docs_Uploaded" class="w-4 h-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500">
|
||||
<label for="Tax_Exempt_Docs_Uploaded" class="ml-2 text-sm font-medium text-gray-700">Tax Exempt Docs Uploaded</label>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" id="Estimate_Approved" name="Estimate_Approved" class="w-4 h-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500">
|
||||
<label for="Estimate_Approved" class="ml-2 text-sm font-medium text-gray-700">Estimate Approved</label>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" id="Estimate_Reviewed" name="Estimate_Reviewed" class="w-4 h-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500">
|
||||
<label for="Estimate_Reviewed" class="ml-2 text-sm font-medium text-gray-700">Estimate Reviewed</label>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" id="Estimate_Draft" name="Estimate_Draft" class="w-4 h-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500">
|
||||
<label for="Estimate_Draft" class="ml-2 text-sm font-medium text-gray-700">Estimate Draft</label>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" id="QB_Created" name="QB_Created" class="w-4 h-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500">
|
||||
<label for="QB_Created" class="ml-2 text-sm font-medium text-gray-700">QB Created</label>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" id="Added_To_Calendar" name="Added_To_Calendar" class="w-4 h-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500">
|
||||
<label for="Added_To_Calendar" class="ml-2 text-sm font-medium text-gray-700">Added To Calendar</label>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" id="PSwift_Uploaded" name="PSwift_Uploaded" class="w-4 h-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500">
|
||||
<label for="PSwift_Uploaded" class="ml-2 text-sm font-medium text-gray-700">PSwift Uploaded</label>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" id="Voxer_Created" name="Voxer_Created" class="w-4 h-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500">
|
||||
<label for="Voxer_Created" class="ml-2 text-sm font-medium text-gray-700">Voxer Created</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notes Tab Content -->
|
||||
<div id="dashboard-tab-notes" class="dashboard-tab-content hidden min-h-[500px]">
|
||||
<!-- Notes Card -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900">Notes</h3>
|
||||
<button
|
||||
type="button"
|
||||
onclick="showAddNoteForm()"
|
||||
class="bg-indigo-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
>
|
||||
+ Add Note
|
||||
</button>
|
||||
</div>
|
||||
<div id="notesList" class="space-y-4 max-h-[500px] overflow-y-auto">
|
||||
<p class="text-center text-gray-500 py-8">Loading notes...</p>
|
||||
</div>
|
||||
<div id="addNoteForm" class="hidden mt-6 p-4 bg-gray-50 rounded-lg border border-gray-200">
|
||||
<h4 class="text-md font-medium text-gray-900 mb-4">Add New Note</h4>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label for="noteEditor" class="block text-sm font-medium text-gray-700 mb-1">Note Content</label>
|
||||
<div id="noteEditor" style="height: 150px; background: white;"></div>
|
||||
<textarea id="noteContent" name="noteContent" class="hidden"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label for="noteSentiment" class="block text-sm font-medium text-gray-700 mb-1">Sentiment</label>
|
||||
<select id="noteSentiment" name="noteSentiment" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
|
||||
<option value="">Select sentiment...</option>
|
||||
<option value="Positive">Positive</option>
|
||||
<option value="Negative">Negative</option>
|
||||
<option value="Neutral">Neutral</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row justify-end space-y-2 sm:space-y-0 sm:space-x-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick="cancelAddNote()"
|
||||
class="w-full sm:w-auto px-3 py-2 text-sm bg-gray-200 text-gray-700 rounded hover:bg-gray-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick="saveNoteFromJob()"
|
||||
class="w-full sm:w-auto px-3 py-2 text-sm bg-indigo-600 text-white rounded hover:bg-indigo-700"
|
||||
>
|
||||
Save Note
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form Buttons -->
|
||||
<div class="mt-6 pt-6 border-t border-gray-200 flex flex-col sm:flex-row justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick="closeJobModal()"
|
||||
class="w-full sm:w-auto px-6 py-2.5 bg-white border border-gray-300 text-gray-700 rounded-lg font-medium hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 transition shadow-sm"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full sm:w-auto px-6 py-2.5 bg-indigo-600 text-white rounded-lg font-medium hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 transition shadow-sm"
|
||||
>
|
||||
Save Job
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="js/pocketbase.js"></script>
|
||||
<script src="js/config.js"></script>
|
||||
<script src="js/auth.js"></script>
|
||||
<script src="js/jobs.js"></script>
|
||||
<script src="js/notes.js"></script>
|
||||
<script>
|
||||
let noteEditor = null;
|
||||
|
||||
// Check authentication
|
||||
checkAuth();
|
||||
|
||||
// Set user email
|
||||
const user = pb.authStore.model;
|
||||
if (user) {
|
||||
document.getElementById('userEmail').textContent = user.email || '';
|
||||
}
|
||||
|
||||
// Initialize Quill editor for notes
|
||||
function initNoteEditor() {
|
||||
if (!noteEditor) {
|
||||
noteEditor = new Quill('#noteEditor', {
|
||||
theme: 'snow',
|
||||
modules: {
|
||||
toolbar: [
|
||||
[{ 'header': [1, 2, 3, false] }],
|
||||
['bold', 'italic', 'underline'],
|
||||
['link'],
|
||||
[{ 'list': 'ordered'}, { 'list': 'bullet' }]
|
||||
]
|
||||
}
|
||||
});
|
||||
// Set global reference for notes.js
|
||||
if (typeof noteEditorInstance === 'undefined') {
|
||||
window.noteEditorInstance = noteEditor;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update dashboard header and quick info
|
||||
function updateDashboardInfo(job) {
|
||||
document.getElementById('dashboardJobName').textContent = job.Job_Name || job.Job_Full_Name || 'Job Dashboard';
|
||||
document.getElementById('dashboardJobNumber').textContent = job.Job_Number || '-';
|
||||
document.getElementById('dashboardJobStatus').textContent = job.Job_Status || '-';
|
||||
|
||||
// Update quick info
|
||||
document.getElementById('quickJobType').textContent = job.Job_Type || '-';
|
||||
document.getElementById('quickDivision').textContent = job.Job_Division || '-';
|
||||
document.getElementById('quickEstimator').textContent = job.Estimator || '-';
|
||||
document.getElementById('quickOfficeRep').textContent = job.Office_Rep || '-';
|
||||
}
|
||||
|
||||
// Dashboard tab switching
|
||||
function switchDashboardTab(tabName) {
|
||||
// Hide all tab contents
|
||||
document.querySelectorAll('.dashboard-tab-content').forEach(tab => {
|
||||
tab.classList.add('hidden');
|
||||
});
|
||||
|
||||
// Remove active state from all tabs
|
||||
document.querySelectorAll('.dashboard-tab').forEach(btn => {
|
||||
btn.classList.remove('border-indigo-500', 'text-indigo-600');
|
||||
btn.classList.add('border-transparent', 'text-gray-500');
|
||||
});
|
||||
|
||||
// Show selected tab content
|
||||
const selectedTab = document.getElementById(`dashboard-tab-${tabName}`);
|
||||
if (selectedTab) {
|
||||
selectedTab.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Activate selected tab button
|
||||
const selectedButton = document.getElementById(`tab-${tabName}`);
|
||||
if (selectedButton) {
|
||||
selectedButton.classList.add('border-indigo-500', 'text-indigo-600');
|
||||
selectedButton.classList.remove('border-transparent', 'text-gray-500');
|
||||
}
|
||||
|
||||
// Initialize note editor if notes tab
|
||||
if (tabName === 'notes') {
|
||||
initNoteEditor();
|
||||
setTimeout(() => {
|
||||
loadJobNotes();
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
// Modal functions
|
||||
function openJobModal(jobId = null) {
|
||||
document.getElementById('jobModal').classList.remove('hidden');
|
||||
if (jobId) {
|
||||
document.getElementById('jobId').value = jobId;
|
||||
loadJobForEdit(jobId).then(() => {
|
||||
// Show notes tab for existing jobs
|
||||
document.getElementById('notesTabButton').classList.remove('hidden');
|
||||
initNoteEditor();
|
||||
});
|
||||
} else {
|
||||
document.getElementById('jobForm').reset();
|
||||
document.getElementById('jobId').value = '';
|
||||
document.getElementById('dashboardJobName').textContent = 'Job Dashboard';
|
||||
document.getElementById('dashboardJobNumber').textContent = '-';
|
||||
document.getElementById('dashboardJobStatus').textContent = '-';
|
||||
// Hide notes tab for new jobs
|
||||
document.getElementById('notesTabButton').classList.add('hidden');
|
||||
}
|
||||
// Start with overview tab
|
||||
switchDashboardTab('overview');
|
||||
}
|
||||
|
||||
function closeJobModal() {
|
||||
document.getElementById('jobModal').classList.add('hidden');
|
||||
document.getElementById('jobForm').reset();
|
||||
document.getElementById('jobId').value = '';
|
||||
}
|
||||
|
||||
// Filter functions
|
||||
window.currentEstimatorFilter = 'all';
|
||||
window.currentStatusFilter = 'all';
|
||||
|
||||
window.setEstimatorFilter = function(filter) {
|
||||
window.currentEstimatorFilter = filter;
|
||||
|
||||
// Update all estimator filter buttons
|
||||
const container = document.getElementById('filterButtons');
|
||||
if (container) {
|
||||
const buttons = container.querySelectorAll('button');
|
||||
buttons.forEach(btn => {
|
||||
const isActive = (filter === 'all' && btn.id === 'filterAll') ||
|
||||
(filter !== 'all' && btn.textContent === filter);
|
||||
|
||||
if (isActive) {
|
||||
// Remove all possible background/text colors
|
||||
btn.classList.remove('bg-gray-200', 'text-gray-700', 'hover:bg-gray-300',
|
||||
'bg-orange-200', 'text-orange-800', 'hover:bg-orange-300',
|
||||
'bg-blue-200', 'text-blue-800', 'hover:bg-blue-300',
|
||||
'bg-purple-200', 'text-purple-800', 'hover:bg-purple-300',
|
||||
'bg-indigo-600', 'text-white', 'hover:bg-indigo-700');
|
||||
// Add active state
|
||||
btn.classList.add('bg-indigo-600', 'text-white', 'hover:bg-indigo-700');
|
||||
} else {
|
||||
// Remove active state
|
||||
btn.classList.remove('bg-indigo-600', 'text-white', 'hover:bg-indigo-700');
|
||||
|
||||
// Restore original pastel colors based on estimator name
|
||||
const estimatorLower = btn.textContent.toLowerCase();
|
||||
if (estimatorLower.includes('steve')) {
|
||||
btn.classList.remove('bg-gray-200', 'text-gray-700', 'hover:bg-gray-300',
|
||||
'bg-blue-200', 'text-blue-800', 'hover:bg-blue-300',
|
||||
'bg-purple-200', 'text-purple-800', 'hover:bg-purple-300');
|
||||
btn.classList.add('bg-orange-200', 'text-orange-800', 'hover:bg-orange-300', 'px-3', 'py-1.5', 'rounded-md', 'text-xs');
|
||||
} else if (estimatorLower.includes('beth')) {
|
||||
btn.classList.remove('bg-gray-200', 'text-gray-700', 'hover:bg-gray-300',
|
||||
'bg-orange-200', 'text-orange-800', 'hover:bg-orange-300',
|
||||
'bg-purple-200', 'text-purple-800', 'hover:bg-purple-300');
|
||||
btn.classList.add('bg-blue-200', 'text-blue-800', 'hover:bg-blue-300', 'px-3', 'py-1.5', 'rounded-md', 'text-xs');
|
||||
} else if (estimatorLower.includes('timothy')) {
|
||||
btn.classList.remove('bg-gray-200', 'text-gray-700', 'hover:bg-gray-300',
|
||||
'bg-orange-200', 'text-orange-800', 'hover:bg-orange-300',
|
||||
'bg-blue-200', 'text-blue-800', 'hover:bg-blue-300');
|
||||
btn.classList.add('bg-purple-200', 'text-purple-800', 'hover:bg-purple-300', 'px-3', 'py-1.5', 'rounded-md', 'text-xs');
|
||||
} else {
|
||||
// Default gray for other estimators
|
||||
btn.classList.remove('bg-orange-200', 'text-orange-800', 'hover:bg-orange-300',
|
||||
'bg-blue-200', 'text-blue-800', 'hover:bg-blue-300',
|
||||
'bg-purple-200', 'text-purple-800', 'hover:bg-purple-300');
|
||||
btn.classList.add('bg-gray-200', 'text-gray-700', 'hover:bg-gray-300', 'px-3', 'py-1.5', 'rounded-md', 'text-xs');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
loadJobs();
|
||||
};
|
||||
|
||||
window.setStatusFilter = function(filter) {
|
||||
window.currentStatusFilter = filter;
|
||||
|
||||
// Update all status filter buttons
|
||||
const container = document.getElementById('statusFilterButtons');
|
||||
if (container) {
|
||||
const buttons = container.querySelectorAll('button');
|
||||
buttons.forEach(btn => {
|
||||
const isActive = (filter === 'all' && btn.id === 'statusFilterAll') ||
|
||||
(filter !== 'all' && btn.textContent === filter);
|
||||
|
||||
if (isActive) {
|
||||
// Remove all possible background/text colors
|
||||
btn.classList.remove('bg-gray-200', 'text-gray-700', 'hover:bg-gray-300',
|
||||
'bg-purple-200', 'text-purple-800', 'hover:bg-purple-300',
|
||||
'bg-orange-200', 'text-orange-800', 'hover:bg-orange-300',
|
||||
'bg-yellow-200', 'text-yellow-800', 'hover:bg-yellow-300',
|
||||
'bg-blue-200', 'text-blue-800', 'hover:bg-blue-300',
|
||||
'bg-green-200', 'text-green-800', 'hover:bg-green-300',
|
||||
'bg-red-200', 'text-red-800', 'hover:bg-red-300',
|
||||
'bg-indigo-600', 'text-white', 'hover:bg-indigo-700',
|
||||
'px-4', 'py-2', 'rounded-lg', 'text-sm', 'sm:text-base');
|
||||
// Add active state and smaller size
|
||||
btn.classList.add('bg-indigo-600', 'text-white', 'hover:bg-indigo-700', 'px-3', 'py-1.5', 'rounded-md', 'text-xs');
|
||||
} else {
|
||||
// Remove active state and size classes
|
||||
btn.classList.remove('bg-indigo-600', 'text-white', 'hover:bg-indigo-700',
|
||||
'px-4', 'py-2', 'rounded-lg', 'text-sm', 'sm:text-base');
|
||||
// Add smaller size classes
|
||||
btn.classList.add('px-3', 'py-1.5', 'rounded-md', 'text-xs');
|
||||
|
||||
// Restore original colors based on status
|
||||
const statusLower = btn.textContent.toLowerCase();
|
||||
let bgColor = 'bg-gray-200';
|
||||
let textColor = 'text-gray-700';
|
||||
let hoverColor = 'hover:bg-gray-300';
|
||||
|
||||
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';
|
||||
} else if (statusLower.includes('est hold') || statusLower === 'est hold') {
|
||||
bgColor = 'bg-orange-200';
|
||||
textColor = 'text-orange-800';
|
||||
hoverColor = 'hover:bg-orange-300';
|
||||
} 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';
|
||||
} else if (statusLower.includes('estimating') || statusLower === 'estimating') {
|
||||
bgColor = 'bg-blue-200';
|
||||
textColor = 'text-blue-800';
|
||||
hoverColor = 'hover:bg-blue-300';
|
||||
} 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';
|
||||
} 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';
|
||||
} else if (statusLower.includes('archive') || statusLower === 'archive') {
|
||||
bgColor = 'bg-gray-200';
|
||||
textColor = 'text-gray-700';
|
||||
hoverColor = 'hover:bg-gray-300';
|
||||
}
|
||||
|
||||
// Remove all color and size classes
|
||||
btn.classList.remove('bg-gray-200', 'text-gray-700', 'hover:bg-gray-300',
|
||||
'bg-purple-200', 'text-purple-800', 'hover:bg-purple-300',
|
||||
'bg-orange-200', 'text-orange-800', 'hover:bg-orange-300',
|
||||
'bg-yellow-200', 'text-yellow-800', 'hover:bg-yellow-300',
|
||||
'bg-blue-200', 'text-blue-800', 'hover:bg-blue-300',
|
||||
'bg-green-200', 'text-green-800', 'hover:bg-green-300',
|
||||
'bg-red-200', 'text-red-800', 'hover:bg-red-300',
|
||||
'px-4', 'py-2', 'rounded-lg', 'text-sm', 'sm:text-base', 'px-3', 'py-1.5', 'rounded-md', 'text-xs');
|
||||
// Add restored colors and smaller size
|
||||
btn.classList.add(bgColor, textColor, hoverColor, 'px-3', 'py-1.5', 'rounded-md', 'text-xs');
|
||||
}
|
||||
});
|
||||
}
|
||||
loadJobs();
|
||||
};
|
||||
|
||||
window.setEstimatorFilter = function(filter) {
|
||||
window.currentEstimatorFilter = filter;
|
||||
|
||||
// Update all estimator filter buttons
|
||||
const container = document.getElementById('filterButtons');
|
||||
if (container) {
|
||||
const buttons = container.querySelectorAll('button');
|
||||
buttons.forEach(btn => {
|
||||
const isActive = (filter === 'all' && btn.id === 'filterAll') ||
|
||||
(filter !== 'all' && btn.textContent === filter);
|
||||
|
||||
if (isActive) {
|
||||
// Remove all possible background/text colors
|
||||
btn.classList.remove('bg-gray-200', 'text-gray-700', 'hover:bg-gray-300',
|
||||
'bg-orange-200', 'text-orange-800', 'hover:bg-orange-300',
|
||||
'bg-blue-200', 'text-blue-800', 'hover:bg-blue-300',
|
||||
'bg-purple-200', 'text-purple-800', 'hover:bg-purple-300',
|
||||
'bg-indigo-600', 'text-white', 'hover:bg-indigo-700');
|
||||
// Add active state
|
||||
btn.classList.add('bg-indigo-600', 'text-white', 'hover:bg-indigo-700');
|
||||
} else {
|
||||
// Remove active state
|
||||
btn.classList.remove('bg-indigo-600', 'text-white', 'hover:bg-indigo-700');
|
||||
|
||||
// Restore original pastel colors based on estimator name
|
||||
const estimatorLower = btn.textContent.toLowerCase();
|
||||
if (estimatorLower.includes('steve')) {
|
||||
btn.classList.remove('bg-gray-200', 'text-gray-700', 'hover:bg-gray-300',
|
||||
'bg-blue-200', 'text-blue-800', 'hover:bg-blue-300',
|
||||
'bg-purple-200', 'text-purple-800', 'hover:bg-purple-300');
|
||||
btn.classList.add('bg-orange-200', 'text-orange-800', 'hover:bg-orange-300', 'px-3', 'py-1.5', 'rounded-md', 'text-xs');
|
||||
} else if (estimatorLower.includes('beth')) {
|
||||
btn.classList.remove('bg-gray-200', 'text-gray-700', 'hover:bg-gray-300',
|
||||
'bg-orange-200', 'text-orange-800', 'hover:bg-orange-300',
|
||||
'bg-purple-200', 'text-purple-800', 'hover:bg-purple-300');
|
||||
btn.classList.add('bg-blue-200', 'text-blue-800', 'hover:bg-blue-300', 'px-3', 'py-1.5', 'rounded-md', 'text-xs');
|
||||
} else if (estimatorLower.includes('timothy')) {
|
||||
btn.classList.remove('bg-gray-200', 'text-gray-700', 'hover:bg-gray-300',
|
||||
'bg-orange-200', 'text-orange-800', 'hover:bg-orange-300',
|
||||
'bg-blue-200', 'text-blue-800', 'hover:bg-blue-300');
|
||||
btn.classList.add('bg-purple-200', 'text-purple-800', 'hover:bg-purple-300', 'px-3', 'py-1.5', 'rounded-md', 'text-xs');
|
||||
} else {
|
||||
// Default gray for other estimators
|
||||
btn.classList.remove('bg-orange-200', 'text-orange-800', 'hover:bg-orange-300',
|
||||
'bg-blue-200', 'text-blue-800', 'hover:bg-blue-300',
|
||||
'bg-purple-200', 'text-purple-800', 'hover:bg-purple-300');
|
||||
btn.classList.add('bg-gray-200', 'text-gray-700', 'hover:bg-gray-300', 'px-3', 'py-1.5', 'rounded-md', 'text-xs');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
loadJobs();
|
||||
};
|
||||
|
||||
// Search
|
||||
document.getElementById('searchInput').addEventListener('input', (e) => {
|
||||
clearTimeout(window.searchTimeout);
|
||||
window.searchTimeout = setTimeout(() => {
|
||||
loadJobs();
|
||||
}, 300);
|
||||
});
|
||||
|
||||
// Form submission
|
||||
document.getElementById('jobForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
await saveJob();
|
||||
});
|
||||
|
||||
// Update quick info when fields change
|
||||
['Job_Type', 'Job_Division', 'Estimator', 'Office_Rep'].forEach(fieldId => {
|
||||
const field = document.getElementById(fieldId);
|
||||
if (field) {
|
||||
field.addEventListener('change', () => {
|
||||
const quickField = document.getElementById(`quick${fieldId.replace('_', '')}`);
|
||||
if (quickField) {
|
||||
quickField.textContent = field.value || '-';
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Note form submission (in job modal) - make it global
|
||||
window.saveNoteFromJob = async function() {
|
||||
if (noteEditor) {
|
||||
const content = noteEditor.root.innerHTML;
|
||||
document.getElementById('noteContent').value = content;
|
||||
}
|
||||
// Call the saveNote function from notes.js
|
||||
if (typeof saveNote === 'function') {
|
||||
await saveNote();
|
||||
}
|
||||
};
|
||||
|
||||
// Load filters and jobs on page load
|
||||
// Small delay to ensure DOM is ready
|
||||
setTimeout(async () => {
|
||||
// Wait for config to load
|
||||
await initConfig();
|
||||
await loadEstimatorFilters();
|
||||
await loadStatusFilters();
|
||||
// Initialize view display
|
||||
if (typeof updateViewDisplay === 'function') {
|
||||
updateViewDisplay();
|
||||
}
|
||||
loadJobs();
|
||||
}, 100);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+41
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+868
@@ -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 = '<tr id="loadingRow"><td colspan="8" class="px-6 py-8 text-center text-gray-500">Loading...</td></tr>';
|
||||
}
|
||||
} else {
|
||||
const container = document.getElementById('jobsCardContainer');
|
||||
if (container) {
|
||||
container.innerHTML = '<div class="col-span-full text-center py-8 text-gray-500">Loading...</div>';
|
||||
}
|
||||
}
|
||||
|
||||
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 = '<tr><td colspan="8" class="px-6 py-8 text-center text-red-500">Error loading jobs. Please refresh the page.</td></tr>';
|
||||
}
|
||||
} else {
|
||||
const container = document.getElementById('jobsCardContainer');
|
||||
if (container) {
|
||||
container.innerHTML = '<div class="col-span-full text-center py-8 text-red-500">Error loading jobs. Please refresh the page.</div>';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 `<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${bgColor} ${textColor}">${estimator}</span>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = '<tr><td colspan="8" class="px-6 py-8 text-center text-gray-500">No jobs found</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = jobs.map(job => {
|
||||
const estimatorDisplay = getEstimatorBadgeHtml(job.Estimator);
|
||||
|
||||
return `
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-3 sm:px-6 py-4 whitespace-nowrap text-sm text-gray-900">${job.Job_Number || '-'}</td>
|
||||
<td class="px-3 sm:px-6 py-4 whitespace-nowrap text-sm text-gray-900">${job.Job_Name || '-'}</td>
|
||||
<td class="px-3 sm:px-6 py-4 whitespace-nowrap text-sm text-gray-900">${job.Company_Client || '-'}</td>
|
||||
<td class="px-3 sm:px-6 py-4 whitespace-nowrap text-sm">${estimatorDisplay}</td>
|
||||
<td class="px-3 sm:px-6 py-4 whitespace-nowrap text-sm text-gray-900">${job.Job_Status || '-'}</td>
|
||||
<td class="px-3 sm:px-6 py-4 whitespace-nowrap text-sm text-gray-900">${job.Job_Type || '-'}</td>
|
||||
<td class="px-3 sm:px-6 py-4 whitespace-nowrap text-sm text-gray-900">${job.Job_Division || '-'}</td>
|
||||
<td class="px-3 sm:px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<button onclick="openJobModal('${job.id}')" class="bg-green-600 text-white px-4 py-2 rounded-lg font-medium hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 transition">Open</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).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 = '<div class="text-center py-8 text-gray-500">No jobs found</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = jobs.map(job => {
|
||||
const estimatorBadge = getEstimatorBadgeHtml(job.Estimator);
|
||||
const statusColor = getStatusBadgeColor(job.Job_Status);
|
||||
|
||||
return `
|
||||
<div
|
||||
onclick="openJobModal('${job.id}')"
|
||||
class="bg-white rounded-lg shadow border border-gray-200 p-6 hover:shadow-lg hover:border-indigo-300 cursor-pointer transition-all duration-200 hover:bg-indigo-50/30"
|
||||
>
|
||||
<div class="mb-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-xl font-semibold text-gray-900">${job.Job_Number || 'N/A'}</h3>
|
||||
<p class="text-base text-gray-600 mt-1">${job.Job_Name || '-'}</p>
|
||||
</div>
|
||||
<svg class="w-5 h-5 text-gray-400 flex-shrink-0 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<span class="text-xs text-gray-500 uppercase tracking-wide">Client</span>
|
||||
<p class="text-sm font-medium text-gray-900 mt-1">${job.Company_Client || '-'}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="text-xs text-gray-500 uppercase tracking-wide">Estimator</span>
|
||||
<div class="mt-1">${estimatorBadge}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="text-xs text-gray-500 uppercase tracking-wide">Status</span>
|
||||
<div class="mt-1">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${statusColor}">
|
||||
${job.Job_Status || '-'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 sm:col-span-2 lg:col-span-1">
|
||||
<div>
|
||||
<span class="text-xs text-gray-500 uppercase tracking-wide">Type</span>
|
||||
<p class="text-sm font-medium text-gray-900 mt-1">${job.Job_Type || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-xs text-gray-500 uppercase tracking-wide">Division</span>
|
||||
<p class="text-sm font-medium text-gray-900 mt-1">${job.Job_Division || '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).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 = '<div class="flex items-center gap-2">';
|
||||
|
||||
// Previous button
|
||||
if (currentPage > 1) {
|
||||
html += `<button onclick="loadJobs(${currentPage - 1})" class="px-3 py-2 border border-gray-300 rounded-lg hover:bg-gray-50">Previous</button>`;
|
||||
}
|
||||
|
||||
// Page info
|
||||
html += `<span class="text-sm text-gray-700">Page ${currentPage} of ${totalPages} (${totalItems} total)</span>`;
|
||||
|
||||
// Next button
|
||||
if (currentPage < totalPages) {
|
||||
html += `<button onclick="loadJobs(${currentPage + 1})" class="px-3 py-2 border border-gray-300 rounded-lg hover:bg-gray-50">Next</button>`;
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
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 = '<option value="">Select a job...</option>';
|
||||
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 = `
|
||||
<div class="${bgColor} border px-4 py-3 rounded-lg text-sm">
|
||||
${message}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 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));
|
||||
}
|
||||
|
||||
+328
@@ -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 = '<p class="text-center text-gray-500 py-8">No job selected</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const job = await pb.collection(jobsCollectionName).getOne(jobId);
|
||||
const noteRefs = job.Note_Ref || [];
|
||||
|
||||
if (noteRefs.length === 0) {
|
||||
document.getElementById('notesList').innerHTML = '<p class="text-center text-gray-500 py-8">No notes for this job</p>';
|
||||
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 = '<p class="text-center text-gray-500 py-8">No notes found</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
displayNotes(validNotes, true);
|
||||
} catch (error) {
|
||||
console.error('Error loading job notes:', error);
|
||||
document.getElementById('notesList').innerHTML = '<p class="text-center text-red-500 py-8">Error loading notes</p>';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = '<p class="text-center text-gray-500 py-8">No notes</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = notes.map(note => {
|
||||
const date = note.created ? new Date(note.created).toLocaleString() : '-';
|
||||
const content = note.content || '';
|
||||
const sentiment = note.sentiment || '-';
|
||||
|
||||
return `
|
||||
<div class="p-4 bg-white border border-gray-200 rounded-lg">
|
||||
<div class="flex justify-between items-start mb-2">
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-900">${sentiment}</div>
|
||||
<div class="text-xs text-gray-500">${date}</div>
|
||||
</div>
|
||||
${showDelete ? `<button onclick="deleteNoteFromJob('${note.id}')" class="text-red-600 hover:text-red-700 text-sm">Delete</button>` : ''}
|
||||
</div>
|
||||
<div class="text-sm text-gray-700 mt-2">${content}</div>
|
||||
</div>
|
||||
`;
|
||||
}).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 `
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-3 sm:px-6 py-4 whitespace-nowrap text-sm text-gray-900">${jobName}</td>
|
||||
<td class="px-3 sm:px-6 py-4 text-sm text-gray-900">${content}</td>
|
||||
<td class="px-3 sm:px-6 py-4 whitespace-nowrap text-sm text-gray-900">${sentiment}</td>
|
||||
<td class="px-3 sm:px-6 py-4 whitespace-nowrap text-sm text-gray-900">${date}</td>
|
||||
<td class="px-3 sm:px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<button onclick="openNoteModal('${note.id}')" class="text-indigo-600 hover:text-indigo-700 mr-4">Edit</button>
|
||||
<button onclick="deleteNote('${note.id}')" class="text-red-600 hover:text-red-700">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).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([]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+233
@@ -0,0 +1,233 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Job Tracking - Notes</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/pocketbase@latest/dist/pocketbase.umd.js"></script>
|
||||
<link href="https://cdn.quilljs.com/1.3.6/quill.snow.css" rel="stylesheet">
|
||||
<script src="https://cdn.quilljs.com/1.3.6/quill.js"></script>
|
||||
</head>
|
||||
<body class="min-h-screen bg-gray-50">
|
||||
<!-- Navigation Header -->
|
||||
<nav class="bg-white shadow-sm border-b border-gray-200">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex flex-col sm:flex-row justify-between items-center h-16 sm:h-auto py-4 sm:py-0 gap-4 sm:gap-0">
|
||||
<div class="flex items-center gap-4 w-full sm:w-auto">
|
||||
<a href="dashboard.html" class="text-indigo-600 hover:text-indigo-700 font-medium text-sm sm:text-base">← Dashboard</a>
|
||||
<div class="text-lg sm:text-xl font-bold text-gray-800">Employee Reports</div>
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row items-center gap-3 w-full sm:w-auto">
|
||||
<div class="text-xs sm:text-sm text-gray-600" id="userEmail"></div>
|
||||
<button
|
||||
onclick="logout()"
|
||||
class="w-full sm:w-auto bg-red-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 transition"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Page Header -->
|
||||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
|
||||
<div>
|
||||
<h1 class="text-xl sm:text-2xl font-bold text-gray-800 mb-2">Job Notes</h1>
|
||||
<p class="text-sm sm:text-base text-gray-600">Manage job notes and reports</p>
|
||||
</div>
|
||||
<button
|
||||
onclick="openNoteModal()"
|
||||
class="w-full sm:w-auto bg-indigo-600 text-white px-4 sm:px-6 py-2 sm:py-3 rounded-lg text-sm sm:text-base font-medium hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 transition"
|
||||
>
|
||||
+ Add Note
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Message Container -->
|
||||
<div id="messageContainer" class="mb-4"></div>
|
||||
|
||||
<!-- Loading Indicator -->
|
||||
<div id="loadingIndicator" class="text-center py-8">
|
||||
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600"></div>
|
||||
<p class="mt-2 text-gray-600">Loading notes...</p>
|
||||
</div>
|
||||
|
||||
<!-- Reports Table Container -->
|
||||
<div id="reportsTableContainer" class="hidden bg-white rounded-lg shadow border border-gray-200 overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-3 sm:px-6 py-2 sm:py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Job</th>
|
||||
<th class="px-3 sm:px-6 py-2 sm:py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Note</th>
|
||||
<th class="px-3 sm:px-6 py-2 sm:py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Sentiment</th>
|
||||
<th class="px-3 sm:px-6 py-2 sm:py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
|
||||
<th class="px-3 sm:px-6 py-2 sm:py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="notesTableBody" class="bg-white divide-y divide-gray-200">
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div id="emptyState" class="hidden text-center py-12">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||
</svg>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900">No notes</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">Get started by creating a new note.</p>
|
||||
<div class="mt-6">
|
||||
<button
|
||||
onclick="openNoteModal()"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
+ Add Note
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Add/Edit Note Modal -->
|
||||
<div id="noteModal" class="hidden fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto z-50">
|
||||
<div class="relative top-0 sm:top-10 mx-auto p-4 sm:p-5 border-0 sm:border w-full sm:max-w-2xl shadow-lg rounded-none sm:rounded-md bg-white mb-0 sm:mb-10 min-h-screen sm:min-h-0">
|
||||
<!-- Modal Header -->
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-lg font-medium text-gray-900" id="noteModalTitle">Add Note</h2>
|
||||
<button
|
||||
onclick="closeNoteModal()"
|
||||
class="text-gray-400 hover:text-gray-500"
|
||||
>
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Note Form -->
|
||||
<form id="noteForm" class="space-y-4 max-h-[calc(100vh-200px)] overflow-y-auto pr-2">
|
||||
<input type="hidden" id="noteId" name="id">
|
||||
|
||||
<div>
|
||||
<label for="noteJob" class="block text-sm font-medium text-gray-700 mb-1">Job *</label>
|
||||
<select id="noteJob" name="job" required class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none text-base">
|
||||
<option value="">Select a job...</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="noteEditor" class="block text-sm font-medium text-gray-700 mb-1">Note</label>
|
||||
<div id="noteEditor" style="height: 200px;"></div>
|
||||
<textarea id="noteContent" name="content" class="hidden"></textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="noteSentiment" class="block text-sm font-medium text-gray-700 mb-1">Sentiment</label>
|
||||
<select id="noteSentiment" name="sentiment" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none text-base">
|
||||
<option value="">Select sentiment...</option>
|
||||
<option value="Positive">Positive</option>
|
||||
<option value="Negative">Negative</option>
|
||||
<option value="Neutral">Neutral</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="noteDate" class="block text-sm font-medium text-gray-700 mb-1">Date & Time</label>
|
||||
<input type="datetime-local" id="noteDate" name="date" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none text-base">
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row justify-end space-y-2 sm:space-y-0 sm:space-x-3 pt-4 border-t">
|
||||
<button
|
||||
type="button"
|
||||
onclick="closeNoteModal()"
|
||||
class="w-full sm:w-auto px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 text-base"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full sm:w-auto px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 text-base"
|
||||
>
|
||||
Save Note
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="js/pocketbase.js"></script>
|
||||
<script src="js/config.js"></script>
|
||||
<script src="js/auth.js"></script>
|
||||
<script src="js/jobs.js"></script>
|
||||
<script src="js/notes.js"></script>
|
||||
<script>
|
||||
let noteEditor = null;
|
||||
|
||||
// Check authentication
|
||||
checkAuth();
|
||||
|
||||
// Set user email
|
||||
const user = pb.authStore.model;
|
||||
if (user) {
|
||||
document.getElementById('userEmail').textContent = user.email || '';
|
||||
}
|
||||
|
||||
// Initialize Quill editor
|
||||
noteEditor = new Quill('#noteEditor', {
|
||||
theme: 'snow',
|
||||
modules: {
|
||||
toolbar: [
|
||||
[{ 'header': [1, 2, 3, false] }],
|
||||
['bold', 'italic', 'underline'],
|
||||
['link'],
|
||||
[{ 'list': 'ordered'}, { 'list': 'bullet' }]
|
||||
]
|
||||
}
|
||||
});
|
||||
// Set global reference
|
||||
window.noteEditorInstance = noteEditor;
|
||||
|
||||
// Modal functions
|
||||
function openNoteModal(noteId = null) {
|
||||
document.getElementById('noteModal').classList.remove('hidden');
|
||||
if (noteId) {
|
||||
document.getElementById('noteModalTitle').textContent = 'Edit Note';
|
||||
document.getElementById('noteId').value = noteId;
|
||||
loadNoteForEdit(noteId);
|
||||
} else {
|
||||
document.getElementById('noteModalTitle').textContent = 'Add Note';
|
||||
document.getElementById('noteForm').reset();
|
||||
document.getElementById('noteId').value = '';
|
||||
noteEditor.setContents([]);
|
||||
loadJobsForSelect();
|
||||
}
|
||||
}
|
||||
|
||||
function closeNoteModal() {
|
||||
document.getElementById('noteModal').classList.add('hidden');
|
||||
document.getElementById('noteForm').reset();
|
||||
document.getElementById('noteId').value = '';
|
||||
noteEditor.setContents([]);
|
||||
}
|
||||
|
||||
// Form submission
|
||||
document.getElementById('noteForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
if (noteEditor) {
|
||||
const content = noteEditor.root.innerHTML;
|
||||
document.getElementById('noteContent').value = content;
|
||||
}
|
||||
await saveNote();
|
||||
});
|
||||
|
||||
// Load notes on page load
|
||||
loadAllNotes();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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()}`);
|
||||
|
||||
Reference in New Issue
Block a user