Compare commits
5 Commits
b51963f5d7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| fb527aadd6 | |||
| db3d8df6cc | |||
| 85a935f3a5 | |||
| baa3ed0a1f | |||
| 2da1619fd1 |
+131
@@ -0,0 +1,131 @@
|
|||||||
|
# Project Rules for AI Assistants
|
||||||
|
|
||||||
|
This file contains project-specific guidance for AI assistants working on the Job-List application.
|
||||||
|
|
||||||
|
## Field Handling Guidelines
|
||||||
|
|
||||||
|
### Date/Time Handling
|
||||||
|
|
||||||
|
**Storage Format (PocketBase Database):**
|
||||||
|
- All date/time fields must be stored as **ISO 8601 UTC format**
|
||||||
|
- Example: `2025-12-21T18:30:00.000Z`
|
||||||
|
- **Null dates**: Use sentinel value `1900-01-01T00:00:00.000Z` to represent null/empty dates
|
||||||
|
|
||||||
|
**Display Format (Frontend/UI):**
|
||||||
|
- Timezone: **US Central Standard Time (CST/CDT)**
|
||||||
|
- Time: **12-hour format** with am/pm (e.g., `6:30 pm`)
|
||||||
|
- Date: **Short US format** MM/dd/yyyy (e.g., `12/21/2025`)
|
||||||
|
|
||||||
|
**Null/Empty Date Display:**
|
||||||
|
- Sentinel date (1900-01-01) with **no related note field** → display as **empty/blank** field
|
||||||
|
- Sentinel date (1900-01-01) with **related note field populated** → display **"See Notes"**
|
||||||
|
- Example: If `Due_Date` is sentinel and `Due_Date_Notes` has content, show "See Notes"
|
||||||
|
- Empty frontend field → store as sentinel date (1900-01-01) in PocketBase
|
||||||
|
|
||||||
|
**Date Entry and Normalization:**
|
||||||
|
- Dates can be entered via **datepicker** (preferred) or **manual text entry**
|
||||||
|
- Manual entries must be normalized to full MM/dd/yyyy format:
|
||||||
|
- `1/1` → `01/01/2026` (add year based on logical context)
|
||||||
|
- `12/1` → `12/01/2025` (use current year if month hasn't passed)
|
||||||
|
- **Confirmation required** for manual entries: Show prompt "Did you mean MM/dd/yyyy?" before saving
|
||||||
|
- **Reject invalid dates** with error message explaining:
|
||||||
|
- Invalid format or date that doesn't fit normalization logic
|
||||||
|
- Suggest using related note field for explanations (e.g., `Due_Date_Notes` for `Due_Date`)
|
||||||
|
- **Validation**: Only allow valid dates OR completely empty - no partial/invalid entries
|
||||||
|
|
||||||
|
**Conversion Rules (Bidirectional):**
|
||||||
|
- **Frontend → Backend**: Convert user input from CST → ISO UTC before saving to PocketBase
|
||||||
|
- **Backend → Frontend**: Convert PocketBase UTC data → CST for display (or "See Notes" for sentinel with notes)
|
||||||
|
- **Updates**: Any date/time change from either frontend or backend must trigger conversion and sync to maintain consistency across the application
|
||||||
|
|
||||||
|
### Boolean Field Handling
|
||||||
|
|
||||||
|
**Storage Format (PocketBase Database):**
|
||||||
|
- Stored as boolean values: `true` or `false`
|
||||||
|
- `null` values must be converted to `false`
|
||||||
|
|
||||||
|
**Display Format (Frontend/UI):**
|
||||||
|
- Rendered as checkboxes
|
||||||
|
- Checked = `true`
|
||||||
|
- Unchecked = `false`
|
||||||
|
|
||||||
|
**Conversion Rules (Bidirectional):**
|
||||||
|
- **Frontend → Backend**: Checkbox state (checked/unchecked) → boolean (`true`/`false`) → stored in PocketBase
|
||||||
|
- **Backend → Frontend**: PocketBase boolean value → checkbox state (checked/unchecked)
|
||||||
|
- **Updates**: Any change to checkbox state or boolean value must sync bidirectionally to maintain consistency
|
||||||
|
|
||||||
|
### Calculated Fields
|
||||||
|
|
||||||
|
#### Active Field (Boolean - Read-Only)
|
||||||
|
|
||||||
|
**Trigger:**
|
||||||
|
- Automatically calculated when `Job_Status` field changes on the frontend
|
||||||
|
|
||||||
|
**Calculation Logic:**
|
||||||
|
- `Active` = `true` if `Job_Status` equals any of:
|
||||||
|
- "Estimating"
|
||||||
|
- "Est Sent"
|
||||||
|
- "Est Hold"
|
||||||
|
- "Follow Up"
|
||||||
|
- "Awarded"
|
||||||
|
- "In Progress"
|
||||||
|
- "Billed In Progress"
|
||||||
|
- `Active` = `false` for all other `Job_Status` values
|
||||||
|
|
||||||
|
**Frontend Behavior:**
|
||||||
|
- Display as **read-only** (checkbox or indicator)
|
||||||
|
- Automatically recalculate whenever `Job_Status` changes
|
||||||
|
- Sync calculated value to PocketBase after update
|
||||||
|
|
||||||
|
#### Due_Date_Counter Field (Text - Read-Only)
|
||||||
|
|
||||||
|
**Trigger:**
|
||||||
|
- Automatically calculated when `Due_Date` or `Due_Date_ASAP` field changes on the frontend
|
||||||
|
- Does NOT recalculate on `Job_Status` changes
|
||||||
|
|
||||||
|
**Calculation Logic:**
|
||||||
|
1. If `Job_Status` ≠ "Estimating" → **"N/A"**
|
||||||
|
2. If `Due_Date_ASAP` = true → **"ASAP"**
|
||||||
|
3. If `Due_Date` is blank/sentinel (1900-01-01) → **blank/empty**
|
||||||
|
4. If `Due_Date` < Today → **"Passed due"**
|
||||||
|
5. If `Due_Date` = Today → **"Due today"**
|
||||||
|
6. Else → Calculate days remaining: **"[X] day"** (singular) or **"[X] days"** (plural)
|
||||||
|
|
||||||
|
**Frontend Behavior:**
|
||||||
|
- Display as **read-only text field**
|
||||||
|
- Automatically recalculate whenever `Due_Date` or `Due_Date_ASAP` changes
|
||||||
|
- Sync calculated value to PocketBase after update
|
||||||
|
|
||||||
|
#### Due_Date_ASAP Field (Boolean)
|
||||||
|
|
||||||
|
**Purpose:**
|
||||||
|
- Mark jobs as urgent/ASAP with timestamp tracking
|
||||||
|
|
||||||
|
**Behavior When Checked:**
|
||||||
|
- System automatically sets `Due_Date` to **TODAY's date** (system-generated timestamp)
|
||||||
|
- `Due_Date` field displays **"ASAP"**
|
||||||
|
- `Due_Date_Counter` displays **"ASAP"**
|
||||||
|
- Enables tracking: Days since ASAP was marked using the captured date
|
||||||
|
|
||||||
|
**Behavior When Unchecked:**
|
||||||
|
- `Due_Date` reverts to blank/sentinel (1900-01-01) or allows manual date entry
|
||||||
|
- Normal date display and counter calculation resume
|
||||||
|
|
||||||
|
**Design Rationale:**
|
||||||
|
- **ASAP scenario**: Captures specific timestamp for urgent items requiring immediate attention
|
||||||
|
- **Blank/No Due Date scenario**: Uses sentinel (1900-01-01) + `created_date` for flexible/TBD timing
|
||||||
|
- Key difference: ASAP provides explicit urgency tracking vs. general aging for non-urgent items
|
||||||
|
|
||||||
|
## Pending Schema Updates
|
||||||
|
|
||||||
|
**Fields to Add:**
|
||||||
|
- `Due_Date_Notes` (text field) - Explanatory notes for Due_Date
|
||||||
|
- `Start_Date_Notes` (text field) - Explanatory notes for Start_Date
|
||||||
|
- `Due_Date_ASAP` (boolean) - Flag for marking jobs as urgent/ASAP with timestamp tracking
|
||||||
|
|
||||||
|
**Action Items:**
|
||||||
|
- Review current schema against planned fields
|
||||||
|
- Identify any other date fields that need corresponding note fields
|
||||||
|
- Implement field additions in PocketBase
|
||||||
|
- Update frontend forms and display logic
|
||||||
|
- Update Due_Date_Counter calculation logic to incorporate Due_Date_ASAP field
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
+1
-1
@@ -6,5 +6,5 @@ REDIRECT_URI=https://pocketbase.ccllc.pro/api/oauth2-redirect
|
|||||||
|
|
||||||
# PocketBase Configuration
|
# PocketBase Configuration
|
||||||
PB_DB=https://pocketbase.ccllc.pro
|
PB_DB=https://pocketbase.ccllc.pro
|
||||||
PB_COLLECTION=Job_Info_Prod
|
PB_COLLECTION=Job_Info_TestEnv
|
||||||
PB_AUTH_COLLECTION=Users
|
PB_AUTH_COLLECTION=Users
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
{
|
||||||
|
"collection": "Job_Info_TestEnv",
|
||||||
|
"exportedAt": "2025-12-19T06:58:06.369Z",
|
||||||
|
"totalHeaders": 47,
|
||||||
|
"headers": [
|
||||||
|
"Active",
|
||||||
|
"Added_To_Calendar",
|
||||||
|
"Attachment_List",
|
||||||
|
"Company_Client",
|
||||||
|
"Contact_Person",
|
||||||
|
"Docs_Uploaded",
|
||||||
|
"Due_Date",
|
||||||
|
"Due_Date_Counter",
|
||||||
|
"Due_Date_Source",
|
||||||
|
"Due_Time",
|
||||||
|
"Email",
|
||||||
|
"Estimate_Approved",
|
||||||
|
"Estimate_Draft",
|
||||||
|
"Estimate_Reviewed",
|
||||||
|
"Estimator",
|
||||||
|
"Flag",
|
||||||
|
"Has_Attachments",
|
||||||
|
"Job_Address",
|
||||||
|
"Job_Codes",
|
||||||
|
"Job_Division",
|
||||||
|
"Job_Folder_Link",
|
||||||
|
"Job_Full_Name",
|
||||||
|
"Job_Name",
|
||||||
|
"Job_Number",
|
||||||
|
"Job_Number_Parent",
|
||||||
|
"Job_QB_Link",
|
||||||
|
"Job_Status",
|
||||||
|
"Job_Type",
|
||||||
|
"Note_Ref",
|
||||||
|
"Notes",
|
||||||
|
"Office_Rep",
|
||||||
|
"PSwift_Uploaded",
|
||||||
|
"Phone_Number",
|
||||||
|
"Project_Manager",
|
||||||
|
"QB_Created",
|
||||||
|
"Schedule_Confidence",
|
||||||
|
"Start_Date",
|
||||||
|
"Submission_Date",
|
||||||
|
"Tax_Exempt",
|
||||||
|
"Tax_Exempt_Docs_Uploaded",
|
||||||
|
"Voxer_Created",
|
||||||
|
"Voxer_Link",
|
||||||
|
"last_note_at",
|
||||||
|
"latest_note_summary",
|
||||||
|
"note_count",
|
||||||
|
"note_thread_text"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
{
|
||||||
|
"collection": "Job_Info_TestEnv",
|
||||||
|
"exportedAt": "2025-12-19T06:58:06.369Z",
|
||||||
|
"collectionMeta": {
|
||||||
|
"id": "pbc_3765329501",
|
||||||
|
"name": "Job_Info_TestEnv",
|
||||||
|
"type": "base",
|
||||||
|
"listRule": "",
|
||||||
|
"viewRule": "",
|
||||||
|
"createRule": "",
|
||||||
|
"updateRule": "",
|
||||||
|
"deleteRule": ""
|
||||||
|
},
|
||||||
|
"schemaType": "generated",
|
||||||
|
"totalFields": 46,
|
||||||
|
"sampleSize": 100,
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"name": "Active",
|
||||||
|
"type": "boolean",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Added_To_Calendar",
|
||||||
|
"type": "boolean",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Attachment_List",
|
||||||
|
"type": "null",
|
||||||
|
"required": false,
|
||||||
|
"occurrences": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Company_Client",
|
||||||
|
"type": "string",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Contact_Person",
|
||||||
|
"type": "string",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Docs_Uploaded",
|
||||||
|
"type": "boolean",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Due_Date",
|
||||||
|
"type": "string",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Due_Date_Counter",
|
||||||
|
"type": "string",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Due_Date_Source",
|
||||||
|
"type": "string",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Due_Time",
|
||||||
|
"type": "string",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Email",
|
||||||
|
"type": "string",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Estimate_Approved",
|
||||||
|
"type": "boolean",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Estimate_Draft",
|
||||||
|
"type": "boolean",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Estimate_Reviewed",
|
||||||
|
"type": "boolean",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Estimator",
|
||||||
|
"type": "string",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Flag",
|
||||||
|
"type": "boolean",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Has_Attachments",
|
||||||
|
"type": "boolean",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Job_Address",
|
||||||
|
"type": "string",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Job_Codes",
|
||||||
|
"type": "string",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Job_Division",
|
||||||
|
"type": "string",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Job_Folder_Link",
|
||||||
|
"type": "string",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Job_Full_Name",
|
||||||
|
"type": "string",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Job_Name",
|
||||||
|
"type": "string",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Job_Number",
|
||||||
|
"type": "string",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Job_Number_Parent",
|
||||||
|
"type": "string",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Job_QB_Link",
|
||||||
|
"type": "string",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Job_Status",
|
||||||
|
"type": "string",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Job_Type",
|
||||||
|
"type": "string",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Note_Ref",
|
||||||
|
"type": "array",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Notes",
|
||||||
|
"type": "string",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Office_Rep",
|
||||||
|
"type": "string",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "PSwift_Uploaded",
|
||||||
|
"type": "boolean",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Phone_Number",
|
||||||
|
"type": "string",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Project_Manager",
|
||||||
|
"type": "string",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "QB_Created",
|
||||||
|
"type": "boolean",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Schedule_Confidence",
|
||||||
|
"type": "string",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Start_Date",
|
||||||
|
"type": "string",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Submission_Date",
|
||||||
|
"type": "string",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Tax_Exempt",
|
||||||
|
"type": "boolean",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Tax_Exempt_Docs_Uploaded",
|
||||||
|
"type": "boolean",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Voxer_Created",
|
||||||
|
"type": "boolean",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Voxer_Link",
|
||||||
|
"type": "string",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "last_note_at",
|
||||||
|
"type": "string",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "latest_note_summary",
|
||||||
|
"type": "null",
|
||||||
|
"required": false,
|
||||||
|
"occurrences": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "note_count",
|
||||||
|
"type": "number",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "note_thread_text",
|
||||||
|
"type": "null",
|
||||||
|
"required": false,
|
||||||
|
"occurrences": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+399
@@ -0,0 +1,399 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Admin - Schema Viewer</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script>
|
||||||
|
tailwind.config = {
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
primary: '#4c51bf',
|
||||||
|
secondary: '#5b21b6',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen bg-gradient-to-br from-primary to-secondary p-4 sm:p-8 font-sans">
|
||||||
|
<!-- Admin Login -->
|
||||||
|
<div id="loginContainer" class="max-w-md mx-auto bg-white rounded-2xl shadow-2xl p-8">
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-800">Admin Login</h1>
|
||||||
|
<p class="text-gray-600 text-sm mt-1">Superuser access</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="loginForm" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label for="email" class="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
||||||
|
<input type="email" id="email" required
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||||
|
placeholder="admin@example.com">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="password" class="block text-sm font-medium text-gray-700 mb-1">Password</label>
|
||||||
|
<input type="password" id="password" required
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||||
|
placeholder="••••••••">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit"
|
||||||
|
class="w-full py-2 px-4 bg-gradient-to-br from-primary to-secondary text-white font-semibold rounded-lg hover:opacity-95 transition-opacity">
|
||||||
|
Login
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div id="loginError" class="hidden mt-4 p-3 bg-red-50 border border-red-200 text-red-700 rounded-lg text-sm"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Schema Viewer -->
|
||||||
|
<div id="schemaContainer" class="hidden max-w-4xl mx-auto bg-white rounded-2xl shadow-2xl p-8 mt-8">
|
||||||
|
<div class="flex flex-wrap gap-3 justify-between items-center mb-6">
|
||||||
|
<h2 id="schemaTitle" class="text-2xl font-bold text-gray-800">Collection Schema</h2>
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<select id="collectionSelect" class="px-3 py-2 border rounded-lg text-sm">
|
||||||
|
<option value="">Select a collection…</option>
|
||||||
|
</select>
|
||||||
|
<button id="refreshSchemaBtn" type="button" class="px-3 py-2 bg-blue-600 text-white rounded-lg text-sm" disabled>Load Schema</button>
|
||||||
|
<input id="fieldName" class="px-3 py-2 border rounded-lg text-sm" placeholder="Job_Number" value="Job_Number" />
|
||||||
|
<button id="loadFieldBtn" type="button" class="px-3 py-2 bg-green-600 text-white rounded-lg text-sm" disabled>Load Field</button>
|
||||||
|
<button id="loadRecordsBtn" type="button" class="px-3 py-2 bg-indigo-600 text-white rounded-lg text-sm" disabled>Load Records</button>
|
||||||
|
<button id="exportSchemaBtn" type="button" class="px-3 py-2 bg-orange-600 text-white rounded-lg text-sm" disabled>Export Schema</button>
|
||||||
|
<button id="logoutBtn" class="px-3 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition text-sm">
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div id="metaPanel" class="text-xs text-gray-600 bg-gray-50 border rounded p-3 hidden"></div>
|
||||||
|
<table class="w-full border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b-2 border-gray-300">
|
||||||
|
<th class="text-left py-2 px-4 font-semibold text-gray-700">Field Name</th>
|
||||||
|
<th class="text-left py-2 px-4 font-semibold text-gray-700">Type</th>
|
||||||
|
<th class="text-left py-2 px-4 font-semibold text-gray-700">Required</th>
|
||||||
|
<th class="text-left py-2 px-4 font-semibold text-gray-700">Details</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="schemaTable">
|
||||||
|
<!-- Filled by JS -->
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="schemaError" class="hidden mt-4 p-3 bg-red-50 border border-red-200 text-red-700 rounded-lg text-sm"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const loginContainer = document.getElementById('loginContainer');
|
||||||
|
const schemaContainer = document.getElementById('schemaContainer');
|
||||||
|
const loginError = document.getElementById('loginError');
|
||||||
|
const schemaError = document.getElementById('schemaError');
|
||||||
|
const schemaTable = document.getElementById('schemaTable');
|
||||||
|
|
||||||
|
let adminToken = null;
|
||||||
|
let collections = [];
|
||||||
|
|
||||||
|
document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const email = document.getElementById('email').value;
|
||||||
|
const password = document.getElementById('password').value;
|
||||||
|
loginError.classList.add('hidden');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/admin/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email, password })
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(result.message || 'Login failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
adminToken = result.token;
|
||||||
|
console.log('✓ Admin logged in');
|
||||||
|
await fetchCollections();
|
||||||
|
loginContainer.classList.add('hidden');
|
||||||
|
schemaContainer.classList.remove('hidden');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
loginError.textContent = error.message;
|
||||||
|
loginError.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const refreshBtn = document.getElementById('refreshSchemaBtn');
|
||||||
|
const fieldNameInput = document.getElementById('fieldName');
|
||||||
|
const loadFieldBtn = document.getElementById('loadFieldBtn');
|
||||||
|
const loadRecordsBtn = document.getElementById('loadRecordsBtn');
|
||||||
|
const exportSchemaBtn = document.getElementById('exportSchemaBtn');
|
||||||
|
|
||||||
|
refreshBtn.addEventListener('click', async () => {
|
||||||
|
console.log('Load Schema clicked');
|
||||||
|
refreshBtn.disabled = true;
|
||||||
|
const original = refreshBtn.textContent;
|
||||||
|
refreshBtn.textContent = 'Loading…';
|
||||||
|
try {
|
||||||
|
await fetchSchema();
|
||||||
|
} finally {
|
||||||
|
refreshBtn.textContent = original;
|
||||||
|
refreshBtn.disabled = !document.getElementById('collectionSelect').value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('collectionSelect').addEventListener('change', async (e) => {
|
||||||
|
const hasSelection = !!e.target.value;
|
||||||
|
refreshBtn.disabled = !hasSelection;
|
||||||
|
loadFieldBtn.disabled = !hasSelection;
|
||||||
|
loadRecordsBtn.disabled = !hasSelection;
|
||||||
|
exportSchemaBtn.disabled = !hasSelection;
|
||||||
|
if (hasSelection) {
|
||||||
|
console.log('Collection selected:', e.target.value);
|
||||||
|
await fetchSchema();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('logoutBtn').addEventListener('click', () => {
|
||||||
|
adminToken = null;
|
||||||
|
schemaContainer.classList.add('hidden');
|
||||||
|
loginContainer.classList.remove('hidden');
|
||||||
|
document.getElementById('loginForm').reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function fetchCollections() {
|
||||||
|
try {
|
||||||
|
console.log('Fetching collections…');
|
||||||
|
const response = await fetch('/api/collections', {
|
||||||
|
headers: { 'Authorization': `Bearer ${adminToken}` }
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
console.log('Collections response:', result);
|
||||||
|
if (!response.ok) throw new Error(result.message || 'Failed to fetch collections');
|
||||||
|
collections = result.items || [];
|
||||||
|
const select = document.getElementById('collectionSelect');
|
||||||
|
select.innerHTML = '<option value="">Select a collection…</option>' +
|
||||||
|
collections.map(c => `<option value="${c.name}">${c.name} (${c.type})</option>`).join('');
|
||||||
|
// Auto-select env default if present in list
|
||||||
|
try {
|
||||||
|
const envDefault = 'Job_Info_TestEnv';
|
||||||
|
const found = collections.find(c => c.name === envDefault);
|
||||||
|
if (found) {
|
||||||
|
select.value = envDefault;
|
||||||
|
refreshBtn.disabled = false;
|
||||||
|
loadFieldBtn.disabled = false;
|
||||||
|
loadRecordsBtn.disabled = false;
|
||||||
|
exportSchemaBtn.disabled = false;
|
||||||
|
await fetchSchema();
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
schemaError.textContent = error.message;
|
||||||
|
schemaError.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadRecordsBtn.addEventListener('click', async () => {
|
||||||
|
const selected = document.getElementById('collectionSelect').value || '';
|
||||||
|
if (!selected) {
|
||||||
|
schemaError.textContent = 'Please select a collection';
|
||||||
|
schemaError.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
schemaError.classList.add('hidden');
|
||||||
|
schemaTable.innerHTML = '';
|
||||||
|
try {
|
||||||
|
console.log('Fetching records from collection:', selected);
|
||||||
|
const resp = await fetch(`/api/records?name=${encodeURIComponent(selected)}&perPage=10`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${adminToken}` }
|
||||||
|
});
|
||||||
|
const result = await resp.json();
|
||||||
|
console.log('Records response:', result);
|
||||||
|
if (!resp.ok) throw new Error(result.message || 'Failed to fetch records');
|
||||||
|
const items = result.items || [];
|
||||||
|
document.getElementById('schemaTitle').textContent = `Records: ${result.collection} (total: ${result.totalItems})`;
|
||||||
|
const metaPanel = document.getElementById('metaPanel');
|
||||||
|
metaPanel.textContent = JSON.stringify({ totalItems: result.totalItems }, null, 2);
|
||||||
|
metaPanel.classList.remove('hidden');
|
||||||
|
if (!items.length) {
|
||||||
|
schemaTable.innerHTML = '<tr><td colspan="4" class="py-4 text-center text-gray-500">No records found</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
schemaTable.innerHTML = items.map((rec) => `
|
||||||
|
<tr class="border-b border-gray-200 hover:bg-gray-50">
|
||||||
|
<td class="py-3 px-4 font-mono text-xs text-gray-800">${rec.id}</td>
|
||||||
|
<td class="py-3 px-4"><span class="bg-indigo-100 text-indigo-800 text-xs px-2 py-1 rounded">record</span></td>
|
||||||
|
<td class="py-3 px-4 text-xs">${rec.job_number ?? ''}</td>
|
||||||
|
<td class="py-3 px-4 text-xs text-gray-600">${new Date(rec.created).toLocaleString()}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
schemaError.textContent = error.message;
|
||||||
|
schemaError.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
loadFieldBtn.addEventListener('click', async () => {
|
||||||
|
const selected = document.getElementById('collectionSelect').value || '';
|
||||||
|
const field = fieldNameInput.value || 'job_number';
|
||||||
|
if (!selected) {
|
||||||
|
schemaError.textContent = 'Please select a collection';
|
||||||
|
schemaError.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
schemaError.classList.add('hidden');
|
||||||
|
schemaTable.innerHTML = '';
|
||||||
|
try {
|
||||||
|
console.log('Fetching field:', field, 'from collection:', selected);
|
||||||
|
const resp = await fetch(`/api/field?name=${encodeURIComponent(selected)}&field=${encodeURIComponent(field)}`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${adminToken}` }
|
||||||
|
});
|
||||||
|
const result = await resp.json();
|
||||||
|
console.log('Field response:', result);
|
||||||
|
if (!resp.ok) throw new Error(result.message || 'Failed to fetch field');
|
||||||
|
const values = result.values || [];
|
||||||
|
const def = result.definition || null;
|
||||||
|
const derived = result.derived || false;
|
||||||
|
const resolvedField = result.resolvedField || result.field;
|
||||||
|
document.getElementById('schemaTitle').textContent = `Field: ${resolvedField} (collection: ${result.collection})`;
|
||||||
|
const metaPanel = document.getElementById('metaPanel');
|
||||||
|
metaPanel.textContent = JSON.stringify({ definition: def, derived, requested: result.field, resolved: resolvedField, totalItems: result.totalItems, nonNullCount: result.count }, null, 2);
|
||||||
|
metaPanel.classList.remove('hidden');
|
||||||
|
if (!values.length) {
|
||||||
|
schemaTable.innerHTML = '<tr><td colspan="4" class="py-4 text-center text-gray-500">No values found for this field</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
schemaTable.innerHTML = values.map(v => `
|
||||||
|
<tr class="border-b border-gray-200 hover:bg-gray-50">
|
||||||
|
<td class="py-3 px-4 font-mono text-sm text-gray-800">${result.field}</td>
|
||||||
|
<td class="py-3 px-4"><span class="bg-green-100 text-green-800 text-xs px-2 py-1 rounded">value</span></td>
|
||||||
|
<td class="py-3 px-4 text-sm"></td>
|
||||||
|
<td class="py-3 px-4 text-sm text-gray-600">${String(v)}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
schemaError.textContent = error.message;
|
||||||
|
schemaError.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function fetchSchema() {
|
||||||
|
schemaError.classList.add('hidden');
|
||||||
|
schemaTable.innerHTML = '';
|
||||||
|
document.getElementById('metaPanel').classList.add('hidden');
|
||||||
|
const selected = document.getElementById('collectionSelect').value || '';
|
||||||
|
if (!selected) {
|
||||||
|
schemaError.textContent = 'Please select a collection';
|
||||||
|
schemaError.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.getElementById('schemaTitle').textContent = `Collection Schema: ${selected}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('Fetching schema for:', selected);
|
||||||
|
const response = await fetch(`/api/schema?name=${encodeURIComponent(selected)}`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${adminToken}` }
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
console.log('Schema response:', result);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(result.message || 'Failed to fetch schema');
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields = result.schema || [];
|
||||||
|
const meta = result.meta || {};
|
||||||
|
const metaPanel = document.getElementById('metaPanel');
|
||||||
|
metaPanel.textContent = JSON.stringify(meta, null, 2);
|
||||||
|
metaPanel.classList.remove('hidden');
|
||||||
|
if (fields.length === 0) {
|
||||||
|
const derived = result.derived || [];
|
||||||
|
const generated = result.generatedSchema || [];
|
||||||
|
if (generated.length) {
|
||||||
|
schemaTable.innerHTML = generated.map(field => `
|
||||||
|
<tr class="border-b border-gray-200 hover:bg-gray-50">
|
||||||
|
<td class="py-3 px-4 font-mono text-sm text-gray-800">${field.name}</td>
|
||||||
|
<td class="py-3 px-4"><span class="bg-purple-100 text-purple-800 text-xs px-2 py-1 rounded">${field.type}</span></td>
|
||||||
|
<td class="py-3 px-4 text-sm">${field.required ? '✓' : ''}</td>
|
||||||
|
<td class="py-3 px-4 text-sm text-gray-600">generated</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (derived.length) {
|
||||||
|
schemaTable.innerHTML = derived.map(name => `
|
||||||
|
<tr class="border-b border-gray-200 hover:bg-gray-50">
|
||||||
|
<td class="py-3 px-4 font-mono text-sm text-gray-800">${name}</td>
|
||||||
|
<td class="py-3 px-4"><span class="bg-yellow-100 text-yellow-800 text-xs px-2 py-1 rounded">derived</span></td>
|
||||||
|
<td class="py-3 px-4 text-sm"></td>
|
||||||
|
<td class="py-3 px-4 text-sm text-gray-600">From sample record</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
schemaTable.innerHTML = '<tr><td colspan="4" class="py-4 text-center text-gray-500">No fields found</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fields.forEach(field => {
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
row.className = 'border-b border-gray-200 hover:bg-gray-50';
|
||||||
|
row.innerHTML = `
|
||||||
|
<td class="py-3 px-4 font-mono text-sm text-gray-800">${field.name}</td>
|
||||||
|
<td class="py-3 px-4"><span class="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded">${field.type}</span></td>
|
||||||
|
<td class="py-3 px-4 text-sm">${field.required ? '✓' : ''}</td>
|
||||||
|
<td class="py-3 px-4 text-sm text-gray-600">${field.options ? JSON.stringify(field.options).substring(0, 60) + '...' : ''}</td>
|
||||||
|
`;
|
||||||
|
schemaTable.appendChild(row);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
schemaError.textContent = error.message;
|
||||||
|
schemaError.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exportSchemaBtn.addEventListener('click', async () => {
|
||||||
|
const selected = document.getElementById('collectionSelect').value || '';
|
||||||
|
if (!selected) {
|
||||||
|
schemaError.textContent = 'Please select a collection';
|
||||||
|
schemaError.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
schemaError.classList.add('hidden');
|
||||||
|
try {
|
||||||
|
console.log('Exporting schema for collection:', selected);
|
||||||
|
const resp = await fetch(`/api/export/schema?name=${encodeURIComponent(selected)}`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${adminToken}` }
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json();
|
||||||
|
throw new Error(err.message || 'Export failed');
|
||||||
|
}
|
||||||
|
const blob = await resp.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${selected}-schema-${new Date().toISOString().split('T')[0]}.json`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
console.log('✓ Schema exported');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
schemaError.textContent = error.message;
|
||||||
|
schemaError.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,482 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Estimator Job List - Settings</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script>
|
||||||
|
tailwind.config = {
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
primary: '#4c51bf',
|
||||||
|
secondary: '#5b21b6',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-container {
|
||||||
|
max-width: 1400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-preview {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border: 2px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-item {
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #374151;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 2px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen bg-gradient-to-br from-primary to-secondary p-6 font-sans">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="bg-white rounded-lg shadow-2xl p-6 mb-6 settings-container mx-auto">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-gray-800">Estimator Job List Settings</h1>
|
||||||
|
<p class="text-gray-600 mt-1">Configure table appearance and behavior</p>
|
||||||
|
</div>
|
||||||
|
<button id="closeBtn" class="text-gray-500 hover:text-gray-700 text-3xl font-bold">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading Indicator -->
|
||||||
|
<div id="loadingIndicator" class="text-center text-white">
|
||||||
|
<div class="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-white"></div>
|
||||||
|
<p class="mt-4">Loading settings...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Settings Container -->
|
||||||
|
<div id="settingsContainer" class="hidden settings-container mx-auto">
|
||||||
|
|
||||||
|
<!-- Global Settings -->
|
||||||
|
<div class="bg-white rounded-lg shadow-2xl p-6 mb-6">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-800 mb-4">Global Settings</h2>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Row Height (px)</label>
|
||||||
|
<input type="number" id="rowHeight" min="30" max="100"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Font Size (px)</label>
|
||||||
|
<input type="number" id="fontSize" min="10" max="24"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Striped Rows</label>
|
||||||
|
<label class="flex items-center space-x-2 mt-2">
|
||||||
|
<input type="checkbox" id="stripedRows" class="w-5 h-5 text-primary rounded">
|
||||||
|
<span class="text-sm text-gray-700">Enable alternating row colors</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Column Settings -->
|
||||||
|
<div class="bg-white rounded-lg shadow-2xl p-6 mb-6">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-800 mb-4">Column Settings</h2>
|
||||||
|
<p class="text-gray-600 mb-4">Configure individual column properties, widths, and dropdown choices</p>
|
||||||
|
|
||||||
|
<div id="columnsList" class="space-y-4">
|
||||||
|
<!-- Column settings will be dynamically generated here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="bg-white rounded-lg shadow-2xl p-6 flex justify-between items-center">
|
||||||
|
<button id="resetBtn" class="px-6 py-3 bg-gray-500 text-white font-semibold rounded-lg hover:bg-gray-600 transition-colors">
|
||||||
|
Reset to Defaults
|
||||||
|
</button>
|
||||||
|
<div class="space-x-4">
|
||||||
|
<button id="cancelBtn" class="px-6 py-3 bg-gray-300 text-gray-800 font-semibold rounded-lg hover:bg-gray-400 transition-colors">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button id="saveBtn" class="px-6 py-3 bg-gradient-to-br from-primary to-secondary text-white font-semibold rounded-lg hover:opacity-95 transition-opacity shadow-lg">
|
||||||
|
Save Settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/pocketbase/dist/pocketbase.umd.js"></script>
|
||||||
|
<script>
|
||||||
|
const pb = new PocketBase('https://pocketbase.ccllc.pro');
|
||||||
|
|
||||||
|
const APP_NAME = 'Estimator Job List';
|
||||||
|
let settingsRecordId = null;
|
||||||
|
let currentSettings = null;
|
||||||
|
|
||||||
|
// Column names from schema
|
||||||
|
const COLUMNS = [
|
||||||
|
'Job_Number', 'Job_Full_Name', 'Active', 'Added_To_Calendar', 'Attachment_List',
|
||||||
|
'Company_Client', 'Contact_Person', 'Docs_Uploaded', 'Due_Date', 'Due_Date_Counter',
|
||||||
|
'Due_Date_Source', 'Due_Time', 'Email', 'Estimate_Approved', 'Estimate_Draft',
|
||||||
|
'Estimate_Reviewed', 'Estimator', 'Flag', 'Has_Attachments', 'Job_Address',
|
||||||
|
'Job_Codes', 'Job_Division', 'Job_Folder_Link', 'Job_Name', 'Job_Number_Parent',
|
||||||
|
'Job_QB_Link', 'Job_Status', 'Job_Type', 'Note_Ref', 'Notes', 'Office_Rep',
|
||||||
|
'PSwift_Uploaded', 'Phone_Number', 'Project_Manager', 'QB_Created',
|
||||||
|
'Schedule_Confidence', 'Start_Date', 'Submission_Date', 'Tax_Exempt',
|
||||||
|
'Tax_Exempt_Docs_Uploaded', 'Voxer_Created', 'Voxer_Link', 'last_note_at',
|
||||||
|
'latest_note_summary', 'note_count', 'note_thread_text'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Default settings
|
||||||
|
const DEFAULT_SETTINGS = {
|
||||||
|
rowHeight: 40,
|
||||||
|
fontSize: 14,
|
||||||
|
stripedRows: true,
|
||||||
|
columns: COLUMNS.map(col => ({
|
||||||
|
name: col,
|
||||||
|
readOnly: col === 'Active' || col === 'Due_Date_Counter' || col === 'Job_Number',
|
||||||
|
width: col === 'Job_Number' ? 120 : col === 'Job_Full_Name' ? 250 : 150,
|
||||||
|
visible: true,
|
||||||
|
choices: col === 'Job_Status' ? [
|
||||||
|
{ value: 'Estimating', bgColor: '#fef3c7', bgColorAlt: '#fde68a', fontStyle: 'normal' },
|
||||||
|
{ value: 'Est Sent', bgColor: '#dbeafe', bgColorAlt: '#bfdbfe', fontStyle: 'normal' },
|
||||||
|
{ value: 'Est Hold', bgColor: '#fee2e2', bgColorAlt: '#fecaca', fontStyle: 'normal' },
|
||||||
|
{ value: 'Follow Up', bgColor: '#e0e7ff', bgColorAlt: '#c7d2fe', fontStyle: 'normal' },
|
||||||
|
{ value: 'Awarded', bgColor: '#dcfce7', bgColorAlt: '#bbf7d0', fontStyle: 'bold' },
|
||||||
|
{ value: 'In Progress', bgColor: '#d1fae5', bgColorAlt: '#a7f3d0', fontStyle: 'normal' },
|
||||||
|
{ value: 'Billed In Progress', bgColor: '#cffafe', bgColorAlt: '#a5f3fc', fontStyle: 'normal' }
|
||||||
|
] : []
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load settings from PocketBase
|
||||||
|
async function loadSettings() {
|
||||||
|
try {
|
||||||
|
document.getElementById('loadingIndicator').classList.remove('hidden');
|
||||||
|
|
||||||
|
console.log('Loading settings for app:', APP_NAME);
|
||||||
|
|
||||||
|
// Try to find existing settings record
|
||||||
|
const records = await pb.collection('app_preferences_settings').getFullList({
|
||||||
|
filter: `app_name = "${APP_NAME}"`
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Found records:', records.length);
|
||||||
|
|
||||||
|
if (records.length > 0) {
|
||||||
|
settingsRecordId = records[0].id;
|
||||||
|
console.log('Settings record ID:', settingsRecordId);
|
||||||
|
console.log('Raw app_json_prefs:', records[0].app_json_prefs);
|
||||||
|
|
||||||
|
// Try to parse the JSON
|
||||||
|
try {
|
||||||
|
if (records[0].app_json_prefs && typeof records[0].app_json_prefs === 'string') {
|
||||||
|
currentSettings = JSON.parse(records[0].app_json_prefs);
|
||||||
|
console.log('Parsed settings successfully');
|
||||||
|
} else if (records[0].app_json_prefs && typeof records[0].app_json_prefs === 'object') {
|
||||||
|
// Already an object, no need to parse
|
||||||
|
currentSettings = records[0].app_json_prefs;
|
||||||
|
console.log('Settings already an object');
|
||||||
|
} else {
|
||||||
|
console.log('No settings found, using defaults');
|
||||||
|
currentSettings = JSON.parse(JSON.stringify(DEFAULT_SETTINGS));
|
||||||
|
}
|
||||||
|
} catch (parseError) {
|
||||||
|
console.error('Error parsing settings JSON:', parseError);
|
||||||
|
currentSettings = JSON.parse(JSON.stringify(DEFAULT_SETTINGS));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('No existing record, creating new one');
|
||||||
|
// Create new record with defaults
|
||||||
|
const newRecord = await pb.collection('app_preferences_settings').create({
|
||||||
|
app_name: APP_NAME,
|
||||||
|
app_json_prefs: JSON.stringify(DEFAULT_SETTINGS),
|
||||||
|
app_txt_prefs: 'Default settings'
|
||||||
|
});
|
||||||
|
settingsRecordId = newRecord.id;
|
||||||
|
currentSettings = JSON.parse(JSON.stringify(DEFAULT_SETTINGS));
|
||||||
|
console.log('Created new record:', settingsRecordId);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderSettings();
|
||||||
|
document.getElementById('loadingIndicator').classList.add('hidden');
|
||||||
|
document.getElementById('settingsContainer').classList.remove('hidden');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading settings:', error);
|
||||||
|
console.error('Error details:', error.message, error.stack);
|
||||||
|
alert('Error loading settings: ' + error.message + '. Using defaults.');
|
||||||
|
currentSettings = JSON.parse(JSON.stringify(DEFAULT_SETTINGS));
|
||||||
|
renderSettings();
|
||||||
|
document.getElementById('loadingIndicator').classList.add('hidden');
|
||||||
|
document.getElementById('settingsContainer').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render settings UI
|
||||||
|
function renderSettings() {
|
||||||
|
// Global settings
|
||||||
|
document.getElementById('rowHeight').value = currentSettings.rowHeight || 40;
|
||||||
|
document.getElementById('fontSize').value = currentSettings.fontSize || 14;
|
||||||
|
document.getElementById('stripedRows').checked = currentSettings.stripedRows !== false;
|
||||||
|
|
||||||
|
// Column settings
|
||||||
|
const columnsList = document.getElementById('columnsList');
|
||||||
|
columnsList.innerHTML = '';
|
||||||
|
|
||||||
|
currentSettings.columns.forEach((col, index) => {
|
||||||
|
const colDiv = document.createElement('div');
|
||||||
|
colDiv.className = 'border border-gray-200 rounded-lg p-4 bg-gray-50';
|
||||||
|
colDiv.innerHTML = `
|
||||||
|
<div class="flex justify-between items-center mb-3">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-800">${col.name}</h3>
|
||||||
|
<label class="flex items-center space-x-2">
|
||||||
|
<input type="checkbox" ${col.visible ? 'checked' : ''} data-col-index="${index}" data-field="visible" class="w-4 h-4">
|
||||||
|
<span class="text-sm text-gray-700">Visible</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Column Width (px)</label>
|
||||||
|
<input type="number" value="${col.width}" min="50" max="500"
|
||||||
|
data-col-index="${index}" data-field="width"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded focus:ring-2 focus:ring-primary text-sm">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="flex items-center space-x-2 mt-6">
|
||||||
|
<input type="checkbox" ${col.readOnly ? 'checked' : ''} data-col-index="${index}" data-field="readOnly" class="w-4 h-4">
|
||||||
|
<span class="text-sm text-gray-700">Read-only</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<div class="flex justify-between items-center mb-2">
|
||||||
|
<label class="block text-sm font-medium text-gray-700">Dropdown Choices</label>
|
||||||
|
<button class="text-sm text-primary hover:text-secondary font-medium" data-col-index="${index}" data-action="add-choice">
|
||||||
|
+ Add Choice
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="choices-${index}" class="space-y-2">
|
||||||
|
${renderChoices(col.choices || [], index)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
columnsList.appendChild(colDiv);
|
||||||
|
});
|
||||||
|
|
||||||
|
attachEventListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render choices for a column
|
||||||
|
function renderChoices(choices, colIndex) {
|
||||||
|
if (!choices || choices.length === 0) {
|
||||||
|
return '<p class="text-sm text-gray-500 italic">No choices defined</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return choices.map((choice, choiceIndex) => `
|
||||||
|
<div class="choice-item">
|
||||||
|
<div class="grid grid-cols-12 gap-3 items-center">
|
||||||
|
<div class="col-span-3">
|
||||||
|
<input type="text" value="${choice.value}"
|
||||||
|
data-col-index="${colIndex}" data-choice-index="${choiceIndex}" data-field="choice-value"
|
||||||
|
class="w-full px-2 py-1 border border-gray-300 rounded text-sm" placeholder="Value">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-span-2">
|
||||||
|
<label class="text-xs text-gray-600 block mb-1">Main Color</label>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<input type="color" value="${choice.bgColor}"
|
||||||
|
data-col-index="${colIndex}" data-choice-index="${choiceIndex}" data-field="choice-bgColor"
|
||||||
|
class="w-8 h-8 rounded cursor-pointer">
|
||||||
|
<span class="text-xs text-gray-500">${choice.bgColor}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-span-2">
|
||||||
|
<label class="text-xs text-gray-600 block mb-1">Alt Color</label>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<input type="color" value="${choice.bgColorAlt}"
|
||||||
|
data-col-index="${colIndex}" data-choice-index="${choiceIndex}" data-field="choice-bgColorAlt"
|
||||||
|
class="w-8 h-8 rounded cursor-pointer">
|
||||||
|
<span class="text-xs text-gray-500">${choice.bgColorAlt}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-span-2">
|
||||||
|
<label class="text-xs text-gray-600 block mb-1">Font Style</label>
|
||||||
|
<select data-col-index="${colIndex}" data-choice-index="${choiceIndex}" data-field="choice-fontStyle"
|
||||||
|
class="w-full px-2 py-1 border border-gray-300 rounded text-sm">
|
||||||
|
<option value="normal" ${choice.fontStyle === 'normal' ? 'selected' : ''}>Normal</option>
|
||||||
|
<option value="bold" ${choice.fontStyle === 'bold' ? 'selected' : ''}>Bold</option>
|
||||||
|
<option value="italic" ${choice.fontStyle === 'italic' ? 'selected' : ''}>Italic</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-span-2">
|
||||||
|
<div class="flex items-center space-x-2 h-full">
|
||||||
|
<div class="color-preview" style="background-color: ${choice.bgColor}; font-style: ${choice.fontStyle}; font-weight: ${choice.fontStyle === 'bold' ? 'bold' : 'normal'};">
|
||||||
|
<span class="text-xs flex items-center justify-center h-full">A</span>
|
||||||
|
</div>
|
||||||
|
<div class="color-preview" style="background-color: ${choice.bgColorAlt}; font-style: ${choice.fontStyle}; font-weight: ${choice.fontStyle === 'bold' ? 'bold' : 'normal'};">
|
||||||
|
<span class="text-xs flex items-center justify-center h-full">A</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-span-1 text-right">
|
||||||
|
<button class="text-red-500 hover:text-red-700 font-bold text-lg"
|
||||||
|
data-col-index="${colIndex}" data-choice-index="${choiceIndex}" data-action="remove-choice">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach event listeners
|
||||||
|
function attachEventListeners() {
|
||||||
|
// Column field changes
|
||||||
|
document.querySelectorAll('[data-col-index][data-field]').forEach(el => {
|
||||||
|
const handler = (e) => {
|
||||||
|
const colIndex = parseInt(e.target.dataset.colIndex);
|
||||||
|
const field = e.target.dataset.field;
|
||||||
|
const value = e.target.type === 'checkbox' ? e.target.checked : e.target.value;
|
||||||
|
|
||||||
|
console.log('Field changed:', field, 'value:', value, 'colIndex:', colIndex);
|
||||||
|
|
||||||
|
if (field === 'choice-value' || field === 'choice-bgColor' || field === 'choice-bgColorAlt' || field === 'choice-fontStyle') {
|
||||||
|
const choiceIndex = parseInt(e.target.dataset.choiceIndex);
|
||||||
|
const choiceField = field.replace('choice-', '');
|
||||||
|
if (!currentSettings.columns[colIndex].choices) {
|
||||||
|
currentSettings.columns[colIndex].choices = [];
|
||||||
|
}
|
||||||
|
currentSettings.columns[colIndex].choices[choiceIndex][choiceField] = value;
|
||||||
|
console.log('Updated choice:', currentSettings.columns[colIndex].choices[choiceIndex]);
|
||||||
|
} else {
|
||||||
|
currentSettings.columns[colIndex][field] = field === 'width' ? parseInt(value) : value;
|
||||||
|
console.log('Updated column field:', field, currentSettings.columns[colIndex][field]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
el.removeEventListener('change', handler);
|
||||||
|
el.removeEventListener('input', handler);
|
||||||
|
el.addEventListener('change', handler);
|
||||||
|
el.addEventListener('input', handler);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add/Remove choice buttons
|
||||||
|
document.querySelectorAll('[data-action]').forEach(btn => {
|
||||||
|
const handler = (e) => {
|
||||||
|
const action = e.target.dataset.action;
|
||||||
|
const colIndex = parseInt(e.target.dataset.colIndex);
|
||||||
|
|
||||||
|
console.log('Action:', action, 'colIndex:', colIndex);
|
||||||
|
|
||||||
|
if (action === 'add-choice') {
|
||||||
|
if (!currentSettings.columns[colIndex].choices) {
|
||||||
|
currentSettings.columns[colIndex].choices = [];
|
||||||
|
}
|
||||||
|
currentSettings.columns[colIndex].choices.push({
|
||||||
|
value: 'New Choice',
|
||||||
|
bgColor: '#ffffff',
|
||||||
|
bgColorAlt: '#f3f4f6',
|
||||||
|
fontStyle: 'normal'
|
||||||
|
});
|
||||||
|
console.log('Added choice, total:', currentSettings.columns[colIndex].choices.length);
|
||||||
|
renderSettings();
|
||||||
|
} else if (action === 'remove-choice') {
|
||||||
|
const choiceIndex = parseInt(e.target.dataset.choiceIndex);
|
||||||
|
currentSettings.columns[colIndex].choices.splice(choiceIndex, 1);
|
||||||
|
console.log('Removed choice at index:', choiceIndex);
|
||||||
|
renderSettings();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
btn.removeEventListener('click', handler);
|
||||||
|
btn.addEventListener('click', handler);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save settings
|
||||||
|
document.getElementById('saveBtn').addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
// Update global settings
|
||||||
|
currentSettings.rowHeight = parseInt(document.getElementById('rowHeight').value);
|
||||||
|
currentSettings.fontSize = parseInt(document.getElementById('fontSize').value);
|
||||||
|
currentSettings.stripedRows = document.getElementById('stripedRows').checked;
|
||||||
|
|
||||||
|
console.log('Saving settings:', currentSettings);
|
||||||
|
console.log('Settings record ID:', settingsRecordId);
|
||||||
|
|
||||||
|
const jsonString = JSON.stringify(currentSettings);
|
||||||
|
console.log('JSON string length:', jsonString.length);
|
||||||
|
|
||||||
|
// Save to PocketBase
|
||||||
|
const updated = await pb.collection('app_preferences_settings').update(settingsRecordId, {
|
||||||
|
app_json_prefs: jsonString,
|
||||||
|
app_txt_prefs: `Updated on ${new Date().toISOString()}`
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Settings saved successfully:', updated);
|
||||||
|
alert('Settings saved successfully!');
|
||||||
|
window.location.href = '/estimatortable.html';
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving settings:', error);
|
||||||
|
console.error('Error details:', error.message, error.stack);
|
||||||
|
alert('Error saving settings: ' + error.message + '. Please try again.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset to defaults
|
||||||
|
document.getElementById('resetBtn').addEventListener('click', () => {
|
||||||
|
if (confirm('Are you sure you want to reset all settings to defaults?')) {
|
||||||
|
currentSettings = JSON.parse(JSON.stringify(DEFAULT_SETTINGS));
|
||||||
|
renderSettings();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cancel/Close buttons
|
||||||
|
document.getElementById('cancelBtn').addEventListener('click', () => {
|
||||||
|
window.location.href = '/estimatortable.html';
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('closeBtn').addEventListener('click', () => {
|
||||||
|
window.location.href = '/estimatortable.html';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
if (pb.authStore.isValid) {
|
||||||
|
console.log('User is authenticated:', pb.authStore.model?.email);
|
||||||
|
loadSettings();
|
||||||
|
} else {
|
||||||
|
console.log('User not authenticated');
|
||||||
|
alert('Please login first');
|
||||||
|
window.location.href = '/estimatortable.html';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,755 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Estimator Table</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script>
|
||||||
|
tailwind.config = {
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
primary: '#4c51bf',
|
||||||
|
secondary: '#5b21b6',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
/* Fixed layout - no outer scroll */
|
||||||
|
body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Container layout - keep header stationary */
|
||||||
|
#tableContainer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
padding: 1rem;
|
||||||
|
padding-top: 3.5rem; /* Space for user display */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fixed header section */
|
||||||
|
.header-section {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollable table area with always-visible scrollbars */
|
||||||
|
.table-container {
|
||||||
|
position: relative;
|
||||||
|
overflow: scroll; /* Always show scrollbars */
|
||||||
|
flex: 1;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
background: white;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
min-height: 0; /* Critical for flex child scrolling */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Force scrollbars to always be visible */
|
||||||
|
.table-container::-webkit-scrollbar {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container::-webkit-scrollbar-track {
|
||||||
|
background: #f1f1f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container::-webkit-scrollbar-thumb {
|
||||||
|
background: #888;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table {
|
||||||
|
border-collapse: separate;
|
||||||
|
border-spacing: 0;
|
||||||
|
table-layout: fixed; /* Fixed table layout for consistent column widths */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fixed column widths */
|
||||||
|
.data-table th,
|
||||||
|
.data-table td {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
border-right: 1px solid #e5e7eb;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Freeze header row */
|
||||||
|
.data-table thead th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 20;
|
||||||
|
background: linear-gradient(to bottom right, #4c51bf, #5b21b6);
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
||||||
|
border-right: 1px solid rgba(255,255,255,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Freeze first column (Job_Number) - fixed 120px width */
|
||||||
|
.data-table th:nth-child(1),
|
||||||
|
.data-table td:nth-child(1) {
|
||||||
|
position: sticky;
|
||||||
|
left: 0;
|
||||||
|
z-index: 15;
|
||||||
|
background: white;
|
||||||
|
border-right: 2px solid #4c51bf !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* First column header - highest z-index */
|
||||||
|
.data-table thead th:nth-child(1) {
|
||||||
|
z-index: 35;
|
||||||
|
background: linear-gradient(to bottom right, #4c51bf, #5b21b6);
|
||||||
|
box-shadow: 2px 2px 4px rgba(0,0,0,0.2);
|
||||||
|
border-right: 2px solid rgba(255,255,255,0.3) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Freeze second column (Job_Full_Name) - fixed 250px width */
|
||||||
|
.data-table th:nth-child(2),
|
||||||
|
.data-table td:nth-child(2) {
|
||||||
|
position: sticky;
|
||||||
|
left: 120px;
|
||||||
|
z-index: 15;
|
||||||
|
background: white;
|
||||||
|
border-right: 2px solid #4c51bf !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Second column header - highest z-index */
|
||||||
|
.data-table thead th:nth-child(2) {
|
||||||
|
z-index: 35;
|
||||||
|
background: linear-gradient(to bottom right, #4c51bf, #5b21b6);
|
||||||
|
box-shadow: 2px 2px 4px rgba(0,0,0,0.2);
|
||||||
|
border-right: 2px solid rgba(255,255,255,0.3) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Alternating row colors - maintain on frozen columns */
|
||||||
|
.data-table tbody tr:nth-child(even) td {
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table tbody tr:nth-child(even) td:nth-child(1),
|
||||||
|
.data-table tbody tr:nth-child(even) td:nth-child(2) {
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table tbody tr:hover td {
|
||||||
|
background: #fef3c7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table tbody tr:hover td:nth-child(1),
|
||||||
|
.data-table tbody tr:hover td:nth-child(2) {
|
||||||
|
background: #fef3c7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prominent checkbox styling */
|
||||||
|
.checkbox-cell input[type="checkbox"] {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
cursor: default;
|
||||||
|
accent-color: #000;
|
||||||
|
border: 2px solid #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Override disabled appearance - keep checkboxes looking active */
|
||||||
|
.checkbox-cell input[type="checkbox"]:disabled {
|
||||||
|
opacity: 1;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="h-screen overflow-hidden bg-gradient-to-br from-primary to-secondary font-sans">
|
||||||
|
|
||||||
|
<!-- Top-right display name and settings -->
|
||||||
|
<div id="userDisplay" class="hidden absolute top-4 right-4 flex items-center space-x-3">
|
||||||
|
<span id="userDisplayName" class="text-white text-sm font-semibold drop-shadow"></span>
|
||||||
|
<button id="settingsBtn" class="text-white hover:text-gray-200 transition-colors" title="Settings">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Login Container -->
|
||||||
|
<div id="loginContainer" class="hidden max-w-md mx-auto bg-white rounded-2xl shadow-2xl p-8 mt-20">
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<h1 class="text-4xl font-bold text-gray-800 mb-2">Estimator Table</h1>
|
||||||
|
<p class="text-gray-600">Job Information</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<p class="text-gray-700 text-center">Sign in with your Microsoft account to access the table</p>
|
||||||
|
<button type="button" id="loginBtn" class="w-full py-3 px-4 bg-gradient-to-br from-primary to-secondary text-white font-semibold rounded-lg hover:opacity-95 transition-opacity shadow-lg hover:shadow-xl">
|
||||||
|
Login with Microsoft
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading Indicator -->
|
||||||
|
<div id="loadingIndicator" class="hidden text-center text-white">
|
||||||
|
<div class="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-white"></div>
|
||||||
|
<p class="mt-4">Loading data...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table Container -->
|
||||||
|
<div id="tableContainer" class="hidden">
|
||||||
|
<div class="header-section bg-white rounded-lg shadow-2xl p-6">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-800">Estimator Job Table</h1>
|
||||||
|
<p class="text-gray-600 mt-1">Scroll horizontally and vertically • First two columns are frozen</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-lg shadow-2xl p-4" style="display: flex; flex-direction: column; flex: 1; min-height: 0;">
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="data-table w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Job Number</th>
|
||||||
|
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Job Full Name</th>
|
||||||
|
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Active</th>
|
||||||
|
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Added To Calendar</th>
|
||||||
|
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Attachment List</th>
|
||||||
|
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Company Client</th>
|
||||||
|
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Contact Person</th>
|
||||||
|
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Docs Uploaded</th>
|
||||||
|
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Due Date</th>
|
||||||
|
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Due Date Counter</th>
|
||||||
|
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Due Date Source</th>
|
||||||
|
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Due Time</th>
|
||||||
|
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Email</th>
|
||||||
|
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Estimate Approved</th>
|
||||||
|
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Estimate Draft</th>
|
||||||
|
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Estimate Reviewed</th>
|
||||||
|
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Estimator</th>
|
||||||
|
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Flag</th>
|
||||||
|
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Has Attachments</th>
|
||||||
|
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Job Address</th>
|
||||||
|
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Job Codes</th>
|
||||||
|
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Job Division</th>
|
||||||
|
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Job Folder Link</th>
|
||||||
|
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Job Name</th>
|
||||||
|
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Job Number Parent</th>
|
||||||
|
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Job QB Link</th>
|
||||||
|
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Job Status</th>
|
||||||
|
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Job Type</th>
|
||||||
|
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Note Ref</th>
|
||||||
|
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Notes</th>
|
||||||
|
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Office Rep</th>
|
||||||
|
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">PSwift Uploaded</th>
|
||||||
|
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Phone Number</th>
|
||||||
|
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Project Manager</th>
|
||||||
|
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">QB Created</th>
|
||||||
|
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Schedule Confidence</th>
|
||||||
|
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Start Date</th>
|
||||||
|
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Submission Date</th>
|
||||||
|
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Tax Exempt</th>
|
||||||
|
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Tax Exempt Docs Uploaded</th>
|
||||||
|
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Voxer Created</th>
|
||||||
|
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Voxer Link</th>
|
||||||
|
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Last Note At</th>
|
||||||
|
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Latest Note Summary</th>
|
||||||
|
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Note Count</th>
|
||||||
|
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Note Thread Text</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="tableBody">
|
||||||
|
<!-- Data will be inserted here -->
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/pocketbase/dist/pocketbase.umd.js"></script>
|
||||||
|
<script>
|
||||||
|
// Initialize PocketBase client
|
||||||
|
const pb = new PocketBase('https://pocketbase.ccllc.pro');
|
||||||
|
|
||||||
|
// Column name to position mapping (based on actual table order)
|
||||||
|
const COLUMN_ORDER = [
|
||||||
|
'Job_Number', 'Job_Full_Name', 'Active', 'Added_To_Calendar', 'Attachment_List',
|
||||||
|
'Company_Client', 'Contact_Person', 'Docs_Uploaded', 'Due_Date', 'Due_Date_Counter',
|
||||||
|
'Due_Date_Source', 'Due_Time', 'Email', 'Estimate_Approved', 'Estimate_Draft',
|
||||||
|
'Estimate_Reviewed', 'Estimator', 'Flag', 'Has_Attachments', 'Job_Address',
|
||||||
|
'Job_Codes', 'Job_Division', 'Job_Folder_Link', 'Job_Name', 'Job_Number_Parent',
|
||||||
|
'Job_QB_Link', 'Job_Status', 'Job_Type', 'Note_Ref', 'Notes',
|
||||||
|
'Office_Rep', 'PSwift_Uploaded', 'Phone_Number', 'Project_Manager', 'QB_Created',
|
||||||
|
'Schedule_Confidence', 'Start_Date', 'Submission_Date', 'Tax_Exempt', 'Tax_Exempt_Docs_Uploaded',
|
||||||
|
'Voxer_Created', 'Voxer_Link', 'last_note_at', 'latest_note_summary', 'note_count',
|
||||||
|
'note_thread_text', 'created'
|
||||||
|
];
|
||||||
|
|
||||||
|
const loginContainer = document.getElementById('loginContainer');
|
||||||
|
const tableContainer = document.getElementById('tableContainer');
|
||||||
|
const loadingIndicator = document.getElementById('loadingIndicator');
|
||||||
|
const userDisplay = document.getElementById('userDisplay');
|
||||||
|
const userDisplayName = document.getElementById('userDisplayName');
|
||||||
|
const loginBtn = document.getElementById('loginBtn');
|
||||||
|
const tableBody = document.getElementById('tableBody');
|
||||||
|
|
||||||
|
const APP_NAME = 'Estimator Job List';
|
||||||
|
let appSettings = null;
|
||||||
|
|
||||||
|
// Load app settings from PocketBase
|
||||||
|
async function loadAppSettings() {
|
||||||
|
try {
|
||||||
|
const records = await pb.collection('app_preferences_settings').getFullList({
|
||||||
|
filter: `app_name = "${APP_NAME}"`
|
||||||
|
});
|
||||||
|
|
||||||
|
if (records.length > 0) {
|
||||||
|
const settingsData = records[0].app_json_prefs;
|
||||||
|
if (settingsData) {
|
||||||
|
appSettings = typeof settingsData === 'string' ? JSON.parse(settingsData) : settingsData;
|
||||||
|
console.log('Loaded app settings:', appSettings);
|
||||||
|
applyGlobalSettings();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading app settings:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply global settings to table
|
||||||
|
function applyGlobalSettings() {
|
||||||
|
if (!appSettings) return;
|
||||||
|
|
||||||
|
// Remove any existing dynamic styles first
|
||||||
|
const existingStyle = document.getElementById('dynamic-column-styles');
|
||||||
|
if (existingStyle) {
|
||||||
|
existingStyle.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
let styleContent = '';
|
||||||
|
|
||||||
|
// Apply row height
|
||||||
|
if (appSettings.rowHeight) {
|
||||||
|
styleContent += `.data-table tbody tr { height: ${appSettings.rowHeight}px; }\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply font size
|
||||||
|
if (appSettings.fontSize) {
|
||||||
|
styleContent += `.data-table { font-size: ${appSettings.fontSize}px; }\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply striped rows setting
|
||||||
|
if (appSettings.stripedRows === false) {
|
||||||
|
styleContent += `.data-table tbody tr:nth-child(even) td { background: white !important; }\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply column widths and visibility dynamically
|
||||||
|
if (appSettings.columns) {
|
||||||
|
const col1Settings = getColumnSettings('Job_Number');
|
||||||
|
const col2Settings = getColumnSettings('Job_Full_Name');
|
||||||
|
const col1Width = col1Settings?.width || 120;
|
||||||
|
const col2Width = col2Settings?.width || 250;
|
||||||
|
|
||||||
|
console.log('Applying settings:', { col1Width, col2Width, columns: appSettings.columns.length });
|
||||||
|
|
||||||
|
// Update sticky positions and widths for first two columns
|
||||||
|
styleContent += `
|
||||||
|
.data-table th:nth-child(1), .data-table td:nth-child(1) {
|
||||||
|
width: ${col1Width}px !important;
|
||||||
|
min-width: ${col1Width}px !important;
|
||||||
|
max-width: ${col1Width}px !important;
|
||||||
|
}
|
||||||
|
.data-table th:nth-child(2), .data-table td:nth-child(2) {
|
||||||
|
left: ${col1Width}px !important;
|
||||||
|
width: ${col2Width}px !important;
|
||||||
|
min-width: ${col2Width}px !important;
|
||||||
|
max-width: ${col2Width}px !important;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Calculate total width and apply settings to all columns
|
||||||
|
let totalWidth = 0;
|
||||||
|
|
||||||
|
appSettings.columns.forEach((col) => {
|
||||||
|
// Find the actual position in the table
|
||||||
|
const colPosition = COLUMN_ORDER.indexOf(col.name) + 1;
|
||||||
|
|
||||||
|
if (colPosition === 0) {
|
||||||
|
console.warn(`Column ${col.name} not found in COLUMN_ORDER`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply width for all columns
|
||||||
|
if (col.width) {
|
||||||
|
styleContent += `
|
||||||
|
.data-table th:nth-child(${colPosition}),
|
||||||
|
.data-table td:nth-child(${colPosition}) {
|
||||||
|
width: ${col.width}px !important;
|
||||||
|
min-width: ${col.width}px !important;
|
||||||
|
max-width: ${col.width}px !important;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide columns marked as not visible
|
||||||
|
if (col.visible === false) {
|
||||||
|
console.log(`Hiding column ${col.name} at position ${colPosition}`);
|
||||||
|
styleContent += `
|
||||||
|
.data-table th:nth-child(${colPosition}),
|
||||||
|
.data-table td:nth-child(${colPosition}) {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
// Add to total width for visible columns
|
||||||
|
totalWidth += col.width || 150;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ensure table is wide enough to trigger horizontal scroll
|
||||||
|
console.log(`Total table width: ${totalWidth}px`);
|
||||||
|
styleContent += `
|
||||||
|
.data-table {
|
||||||
|
width: ${totalWidth}px !important;
|
||||||
|
min-width: ${totalWidth}px !important;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.id = 'dynamic-column-styles';
|
||||||
|
style.textContent = styleContent;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get column settings
|
||||||
|
function getColumnSettings(columnName) {
|
||||||
|
if (!appSettings || !appSettings.columns) return null;
|
||||||
|
return appSettings.columns.find(col => col.name === columnName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get choice styling for a value
|
||||||
|
function getChoiceStyling(columnName, value) {
|
||||||
|
const colSettings = getColumnSettings(columnName);
|
||||||
|
if (!colSettings || !colSettings.choices) return null;
|
||||||
|
|
||||||
|
const choice = colSettings.choices.find(c => c.value === value);
|
||||||
|
return choice || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is already authenticated
|
||||||
|
async function checkAuth() {
|
||||||
|
if (pb.authStore.isValid) {
|
||||||
|
// Already logged in
|
||||||
|
await loadAppSettings();
|
||||||
|
showTable();
|
||||||
|
await loadTableData();
|
||||||
|
} else {
|
||||||
|
// Need to login
|
||||||
|
loginContainer.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login with Microsoft
|
||||||
|
loginBtn.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
loginBtn.disabled = true;
|
||||||
|
loginBtn.textContent = 'Logging in...';
|
||||||
|
|
||||||
|
const authData = await pb.collection('users').authWithOAuth2({ provider: 'microsoft' });
|
||||||
|
|
||||||
|
await loadAppSettings();
|
||||||
|
showTable();
|
||||||
|
await loadTableData();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Login failed:', error);
|
||||||
|
alert('Login failed. Please try again.');
|
||||||
|
loginBtn.disabled = false;
|
||||||
|
loginBtn.textContent = 'Login with Microsoft';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function showTable() {
|
||||||
|
loginContainer.classList.add('hidden');
|
||||||
|
tableContainer.classList.remove('hidden');
|
||||||
|
|
||||||
|
if (pb.authStore.model) {
|
||||||
|
userDisplay.classList.remove('hidden');
|
||||||
|
userDisplayName.textContent = pb.authStore.model.name || pb.authStore.model.email || 'User';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings button handler
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (e.target.closest('#settingsBtn')) {
|
||||||
|
window.location.href = '/appsettings.html';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Format date from UTC to Central Time (MM/dd/yyyy)
|
||||||
|
function formatDate(dateString, notesField = null) {
|
||||||
|
// Check for sentinel date (null date representation)
|
||||||
|
if (!dateString || dateString === '1900-01-01T00:00:00.000Z' || dateString.startsWith('1900-01-01')) {
|
||||||
|
// If there's a related notes field with content, show "See Notes"
|
||||||
|
if (notesField && notesField.trim()) {
|
||||||
|
return 'See Notes';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
// Format in Central Time
|
||||||
|
const formatter = new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone: 'America/Chicago',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
year: 'numeric'
|
||||||
|
});
|
||||||
|
return formatter.format(date);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Date format error:', error, dateString);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format datetime with time (MM/dd/yyyy h:mm am/pm)
|
||||||
|
function formatDateTime(dateString) {
|
||||||
|
if (!dateString || dateString === '1900-01-01T00:00:00.000Z' || dateString.startsWith('1900-01-01')) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const dateFormatter = new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone: 'America/Chicago',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
year: 'numeric'
|
||||||
|
});
|
||||||
|
const timeFormatter = new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone: 'America/Chicago',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: true
|
||||||
|
});
|
||||||
|
return `${dateFormatter.format(date)} ${timeFormatter.format(date)}`;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('DateTime format error:', error, dateString);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate Due_Date_Counter based on .cursorrules logic
|
||||||
|
function calculateDueDateCounter(record) {
|
||||||
|
const jobStatus = record.Job_Status;
|
||||||
|
const dueDate = record.Due_Date;
|
||||||
|
const dueDateASAP = record.Due_Date_ASAP; // Will be undefined until schema updated
|
||||||
|
|
||||||
|
// 1. If Job_Status ≠ "Estimating" → "N/A"
|
||||||
|
if (jobStatus !== 'Estimating') {
|
||||||
|
return 'N/A';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. If Due_Date_ASAP = true → "ASAP"
|
||||||
|
if (dueDateASAP === true) {
|
||||||
|
return 'ASAP';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. If Due_Date is blank/sentinel → blank/empty
|
||||||
|
if (!dueDate || dueDate === '1900-01-01T00:00:00.000Z' || dueDate.startsWith('1900-01-01')) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const due = new Date(dueDate);
|
||||||
|
const today = new Date();
|
||||||
|
|
||||||
|
// Convert both to Central Time midnight for comparison
|
||||||
|
const dueMidnight = new Date(due.toLocaleString('en-US', { timeZone: 'America/Chicago' }));
|
||||||
|
dueMidnight.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const todayMidnight = new Date(today.toLocaleString('en-US', { timeZone: 'America/Chicago' }));
|
||||||
|
todayMidnight.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const diffTime = dueMidnight - todayMidnight;
|
||||||
|
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
// 4. If Due_Date < Today → "Passed due"
|
||||||
|
if (diffDays < 0) {
|
||||||
|
return 'Passed due';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. If Due_Date = Today → "Due today"
|
||||||
|
if (diffDays === 0) {
|
||||||
|
return 'Due today';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Else → "[X] day" or "[X] days"
|
||||||
|
return `${diffDays} ${diffDays === 1 ? 'day' : 'days'}`;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Due date counter calculation error:', error);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format boolean values as prominent checkboxes (read-only)
|
||||||
|
function formatBoolean(value) {
|
||||||
|
return value ? '✓' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format Active field as read-only checkbox with alert
|
||||||
|
function formatActiveCheckbox(value, recordId) {
|
||||||
|
return `<div class="checkbox-cell"><input type="checkbox" ${value ? 'checked' : ''} data-field="Active" data-readonly="true" data-record-id="${recordId}"></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format editable boolean as checkbox element
|
||||||
|
function formatEditableCheckbox(value, fieldName, recordId) {
|
||||||
|
return `<div class="checkbox-cell"><input type="checkbox" ${value ? 'checked' : ''} data-field="${fieldName}" data-record-id="${recordId}"></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load table data from PocketBase
|
||||||
|
async function loadTableData() {
|
||||||
|
try {
|
||||||
|
loadingIndicator.classList.remove('hidden');
|
||||||
|
|
||||||
|
// Fetch all records from Job_Info_TestEnv collection
|
||||||
|
const records = await pb.collection('Job_Info_TestEnv').getFullList({
|
||||||
|
sort: 'Job_Number',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear existing rows
|
||||||
|
tableBody.innerHTML = '';
|
||||||
|
|
||||||
|
// Populate table
|
||||||
|
records.forEach(record => {
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
row.className = 'border-b border-gray-200';
|
||||||
|
|
||||||
|
// Helper to get cell style based on column settings and value
|
||||||
|
const getCellStyle = (columnName, value) => {
|
||||||
|
let style = '';
|
||||||
|
const colSettings = getColumnSettings(columnName);
|
||||||
|
|
||||||
|
if (colSettings) {
|
||||||
|
// Check for choice-based styling
|
||||||
|
if (value && colSettings.choices && colSettings.choices.length > 0) {
|
||||||
|
const choice = colSettings.choices.find(c => c.value === value);
|
||||||
|
if (choice) {
|
||||||
|
// Use alternating colors based on row index
|
||||||
|
const rowIndex = Array.from(tableBody.children).length;
|
||||||
|
const bgColor = rowIndex % 2 === 0 ? choice.bgColor : choice.bgColorAlt;
|
||||||
|
style += `background-color: ${bgColor} !important;`;
|
||||||
|
|
||||||
|
if (choice.fontStyle === 'bold') {
|
||||||
|
style += 'font-weight: bold;';
|
||||||
|
} else if (choice.fontStyle === 'italic') {
|
||||||
|
style += 'font-style: italic;';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return style ? `style="${style}"` : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
row.innerHTML = `
|
||||||
|
<td class="px-4 py-3 border-r border-gray-200 whitespace-nowrap font-medium" ${getCellStyle('Job_Number', record.Job_Number)}>${record.Job_Number || ''}</td>
|
||||||
|
<td class="px-4 py-3 border-r border-gray-200 whitespace-nowrap" ${getCellStyle('Job_Full_Name', record.Job_Full_Name)}>${record.Job_Full_Name || ''}</td>
|
||||||
|
<td class="px-4 py-3 text-center" ${getCellStyle('Active', record.Active)}>${formatActiveCheckbox(record.Active, record.id)}</td>
|
||||||
|
<td class="px-4 py-3 text-center" ${getCellStyle('Added_To_Calendar', record.Added_To_Calendar)}>${formatEditableCheckbox(record.Added_To_Calendar, 'Added_To_Calendar', record.id)}</td>
|
||||||
|
<td class="px-4 py-3" ${getCellStyle('Attachment_List', record.Attachment_List)}>${record.Attachment_List || ''}</td>
|
||||||
|
<td class="px-4 py-3" ${getCellStyle('Company_Client', record.Company_Client)}>${record.Company_Client || ''}</td>
|
||||||
|
<td class="px-4 py-3" ${getCellStyle('Contact_Person', record.Contact_Person)}>${record.Contact_Person || ''}</td>
|
||||||
|
<td class="px-4 py-3 text-center" ${getCellStyle('Docs_Uploaded', record.Docs_Uploaded)}>${formatEditableCheckbox(record.Docs_Uploaded, 'Docs_Uploaded', record.id)}</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap" ${getCellStyle('Due_Date', record.Due_Date)}>${formatDate(record.Due_Date, record.Due_Date_Notes)}</td>
|
||||||
|
<td class="px-4 py-3" ${getCellStyle('Due_Date_Counter', record.Due_Date_Counter)}>${calculateDueDateCounter(record)}</td>
|
||||||
|
<td class="px-4 py-3" ${getCellStyle('Due_Date_Source', record.Due_Date_Source)}>${record.Due_Date_Source || ''}</td>
|
||||||
|
<td class="px-4 py-3" ${getCellStyle('Due_Time', record.Due_Time)}>${record.Due_Time || ''}</td>
|
||||||
|
<td class="px-4 py-3" ${getCellStyle('Email', record.Email)}>${record.Email || ''}</td>
|
||||||
|
<td class="px-4 py-3 text-center" ${getCellStyle('Estimate_Approved', record.Estimate_Approved)}>${formatEditableCheckbox(record.Estimate_Approved, 'Estimate_Approved', record.id)}</td>
|
||||||
|
<td class="px-4 py-3 text-center" ${getCellStyle('Estimate_Draft', record.Estimate_Draft)}>${formatEditableCheckbox(record.Estimate_Draft, 'Estimate_Draft', record.id)}</td>
|
||||||
|
<td class="px-4 py-3 text-center" ${getCellStyle('Estimate_Reviewed', record.Estimate_Reviewed)}>${formatEditableCheckbox(record.Estimate_Reviewed, 'Estimate_Reviewed', record.id)}</td>
|
||||||
|
<td class="px-4 py-3" ${getCellStyle('Estimator', record.Estimator)}>${record.Estimator || ''}</td>
|
||||||
|
<td class="px-4 py-3 text-center" ${getCellStyle('Flag', record.Flag)}>${formatEditableCheckbox(record.Flag, 'Flag', record.id)}</td>
|
||||||
|
<td class="px-4 py-3 text-center" ${getCellStyle('Has_Attachments', record.Has_Attachments)}>${formatEditableCheckbox(record.Has_Attachments, 'Has_Attachments', record.id)}</td>
|
||||||
|
<td class="px-4 py-3" ${getCellStyle('Job_Address', record.Job_Address)}>${record.Job_Address || ''}</td>
|
||||||
|
<td class="px-4 py-3" ${getCellStyle('Job_Codes', record.Job_Codes)}>${record.Job_Codes || ''}</td>
|
||||||
|
<td class="px-4 py-3" ${getCellStyle('Job_Division', record.Job_Division)}>${record.Job_Division || ''}</td>
|
||||||
|
<td class="px-4 py-3" ${getCellStyle('Job_Folder_Link', record.Job_Folder_Link)}>${record.Job_Folder_Link || ''}</td>
|
||||||
|
<td class="px-4 py-3" ${getCellStyle('Job_Name', record.Job_Name)}>${record.Job_Name || ''}</td>
|
||||||
|
<td class="px-4 py-3" ${getCellStyle('Job_Number_Parent', record.Job_Number_Parent)}>${record.Job_Number_Parent || ''}</td>
|
||||||
|
<td class="px-4 py-3" ${getCellStyle('Job_QB_Link', record.Job_QB_Link)}>${record.Job_QB_Link || ''}</td>
|
||||||
|
<td class="px-4 py-3" ${getCellStyle('Job_Status', record.Job_Status)}>${record.Job_Status || ''}</td>
|
||||||
|
<td class="px-4 py-3" ${getCellStyle('Job_Type', record.Job_Type)}>${record.Job_Type || ''}</td>
|
||||||
|
<td class="px-4 py-3" ${getCellStyle('Note_Ref', record.Note_Ref)}>${record.Note_Ref || ''}</td>
|
||||||
|
<td class="px-4 py-3 max-w-xs truncate" title="${record.Notes || ''}" ${getCellStyle('Notes', record.Notes)}>${record.Notes || ''}</td>
|
||||||
|
<td class="px-4 py-3" ${getCellStyle('Office_Rep', record.Office_Rep)}>${record.Office_Rep || ''}</td>
|
||||||
|
<td class="px-4 py-3 text-center" ${getCellStyle('PSwift_Uploaded', record.PSwift_Uploaded)}>${formatEditableCheckbox(record.PSwift_Uploaded, 'PSwift_Uploaded', record.id)}</td>
|
||||||
|
<td class="px-4 py-3" ${getCellStyle('Phone_Number', record.Phone_Number)}>${record.Phone_Number || ''}</td>
|
||||||
|
<td class="px-4 py-3" ${getCellStyle('Project_Manager', record.Project_Manager)}>${record.Project_Manager || ''}</td>
|
||||||
|
<td class="px-4 py-3 text-center" ${getCellStyle('QB_Created', record.QB_Created)}>${formatEditableCheckbox(record.QB_Created, 'QB_Created', record.id)}</td>
|
||||||
|
<td class="px-4 py-3" ${getCellStyle('Schedule_Confidence', record.Schedule_Confidence)}>${record.Schedule_Confidence || ''}</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap" ${getCellStyle('Start_Date', record.Start_Date)}>${formatDate(record.Start_Date, record.Start_Date_Notes)}</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap" ${getCellStyle('Submission_Date', record.Submission_Date)}>${formatDate(record.Submission_Date)}</td>
|
||||||
|
<td class="px-4 py-3 text-center" ${getCellStyle('Tax_Exempt', record.Tax_Exempt)}>${formatEditableCheckbox(record.Tax_Exempt, 'Tax_Exempt', record.id)}</td>
|
||||||
|
<td class="px-4 py-3 text-center" ${getCellStyle('Tax_Exempt_Docs_Uploaded', record.Tax_Exempt_Docs_Uploaded)}>${formatEditableCheckbox(record.Tax_Exempt_Docs_Uploaded, 'Tax_Exempt_Docs_Uploaded', record.id)}</td>
|
||||||
|
<td class="px-4 py-3 text-center" ${getCellStyle('Voxer_Created', record.Voxer_Created)}>${formatEditableCheckbox(record.Voxer_Created, 'Voxer_Created', record.id)}</td>
|
||||||
|
<td class="px-4 py-3" ${getCellStyle('Voxer_Link', record.Voxer_Link)}>${record.Voxer_Link || ''}</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap" ${getCellStyle('last_note_at', record.last_note_at)}>${formatDateTime(record.last_note_at)}</td>
|
||||||
|
<td class="px-4 py-3 max-w-xs truncate" title="${record.latest_note_summary || ''}" ${getCellStyle('latest_note_summary', record.latest_note_summary)}>${record.latest_note_summary || ''}</td>
|
||||||
|
<td class="px-4 py-3 text-center" ${getCellStyle('note_count', record.note_count)}>${record.note_count || ''}</td>
|
||||||
|
<td class="px-4 py-3 max-w-xs truncate" title="${record.note_thread_text || ''}" ${getCellStyle('note_thread_text', record.note_thread_text)}>${record.note_thread_text || ''}</td>
|
||||||
|
`;
|
||||||
|
|
||||||
|
tableBody.appendChild(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
loadingIndicator.classList.add('hidden');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading table data:', error);
|
||||||
|
loadingIndicator.classList.add('hidden');
|
||||||
|
alert('Error loading data. Please refresh the page.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle checkbox changes
|
||||||
|
document.addEventListener('change', async (e) => {
|
||||||
|
if (e.target.type === 'checkbox' && e.target.dataset.field) {
|
||||||
|
const checkbox = e.target;
|
||||||
|
const fieldName = checkbox.dataset.field;
|
||||||
|
const recordId = checkbox.dataset.recordId;
|
||||||
|
const isReadOnly = checkbox.dataset.readonly === 'true';
|
||||||
|
|
||||||
|
// Handle read-only Active field
|
||||||
|
if (isReadOnly && fieldName === 'Active') {
|
||||||
|
// Revert the checkbox state
|
||||||
|
checkbox.checked = !checkbox.checked;
|
||||||
|
alert('Active status is calculated automatically by selected Job Status values.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update PocketBase for editable fields
|
||||||
|
const newValue = checkbox.checked;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pb.collection('Job_Info_TestEnv').update(recordId, {
|
||||||
|
[fieldName]: newValue
|
||||||
|
});
|
||||||
|
console.log(`Updated ${fieldName} to ${newValue} for record ${recordId}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating field:', error);
|
||||||
|
// Revert checkbox on error
|
||||||
|
checkbox.checked = !checkbox.checked;
|
||||||
|
alert('Failed to update field. Please try again.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
checkAuth();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "job-list",
|
"name": "job-list",
|
||||||
"version": "1.0.0-alpha1",
|
"version": "1.0.0-alpha3",
|
||||||
"description": "Job-List",
|
"description": "Job-List",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ app.use('/*', cors({
|
|||||||
credentials: true
|
credentials: true
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// PocketBase client
|
// PocketBase client (fixed URL per request)
|
||||||
const pb = new PocketBase(process.env.PB_DB || 'https://pocketbase.ccllc.pro');
|
const pb = new PocketBase('https://pocketbase.ccllc.pro');
|
||||||
|
|
||||||
// Use user's PocketBase token
|
// Use user's PocketBase token
|
||||||
function setUserPocketBaseAuth(token: string) {
|
function setUserPocketBaseAuth(token: string) {
|
||||||
@@ -99,17 +99,411 @@ app.post('/api/submit', async (c) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Serve static index.html
|
// Serve static files
|
||||||
app.get('/', async (c) => {
|
app.get('/', async (c) => {
|
||||||
const html = await Bun.file('index.html').text();
|
const html = await Bun.file('index.html').text();
|
||||||
return c.html(html);
|
return c.html(html);
|
||||||
});
|
});
|
||||||
|
|
||||||
const PORT = Number(process.env.PORT || 7500);
|
app.get('/estimatortable.html', async (c) => {
|
||||||
|
const html = await Bun.file('estimatortable.html').text();
|
||||||
|
return c.html(html);
|
||||||
|
});
|
||||||
|
|
||||||
console.log(`Idea & Feedback Form server running at http://localhost:${PORT}`);
|
app.get('/admin.html', async (c) => {
|
||||||
|
const html = await Bun.file('admin.html').text();
|
||||||
|
return c.html(html);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/appsettings.html', async (c) => {
|
||||||
|
const html = await Bun.file('appsettings.html').text();
|
||||||
|
return c.html(html);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Admin login endpoint
|
||||||
|
app.post('/api/admin/login', async (c) => {
|
||||||
|
try {
|
||||||
|
const { email, password } = await c.req.json();
|
||||||
|
if (!email || !password) {
|
||||||
|
return c.json({ message: 'Missing email or password' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticate as PocketBase superuser via `_superusers` collection
|
||||||
|
const authData = await pb.collection('_superusers').authWithPassword(email, password);
|
||||||
|
if (!authData || !authData.token) {
|
||||||
|
return c.json({ message: 'Invalid credentials' }, 401);
|
||||||
|
}
|
||||||
|
return c.json({ ok: true, token: authData.token, email: authData.record?.email, role: 'superuser' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('admin_login_error', { message: (err as Error)?.message });
|
||||||
|
return c.json({ message: 'Login failed' }, 401);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get collection schema (requires admin token)
|
||||||
|
app.get('/api/schema', async (c) => {
|
||||||
|
try {
|
||||||
|
const authHeader = c.req.header('Authorization');
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
return c.json({ message: 'Missing authorization token' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.slice(7);
|
||||||
|
setUserPocketBaseAuth(token);
|
||||||
|
|
||||||
|
// Verify superuser token; `_superusers` can fetch schema
|
||||||
|
try {
|
||||||
|
await pb.collection('_superusers').authRefresh();
|
||||||
|
} catch (e) {
|
||||||
|
return c.json({ message: 'Invalid or non-superuser token' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(c.req.url);
|
||||||
|
const requestedName = url.searchParams.get('name');
|
||||||
|
const collectionName = requestedName || process.env.PB_COLLECTION || 'Job_Info_TestEnv';
|
||||||
|
const pbDb = process.env.PB_DB || 'https://pocketbase.ccllc.pro';
|
||||||
|
|
||||||
|
// Try SDK first
|
||||||
|
let schema: any[] = [];
|
||||||
|
let meta: Record<string, any> = {};
|
||||||
|
try {
|
||||||
|
const collection = await pb.collections.getOne(collectionName);
|
||||||
|
schema = Array.isArray(collection?.schema) ? collection.schema : [];
|
||||||
|
meta = {
|
||||||
|
name: collection?.name,
|
||||||
|
type: collection?.type,
|
||||||
|
listRule: collection?.listRule,
|
||||||
|
viewRule: collection?.viewRule,
|
||||||
|
createRule: collection?.createRule,
|
||||||
|
updateRule: collection?.updateRule,
|
||||||
|
deleteRule: collection?.deleteRule,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
// ignore and try REST fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!schema.length) {
|
||||||
|
// Fallback to REST API for robustness
|
||||||
|
const resp = await fetch(`${pbDb}/api/collections/${encodeURIComponent(collectionName)}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
if (resp.ok) {
|
||||||
|
const data = await resp.json();
|
||||||
|
schema = Array.isArray(data?.schema) ? data.schema : [];
|
||||||
|
meta = {
|
||||||
|
name: data?.name,
|
||||||
|
type: data?.type,
|
||||||
|
listRule: data?.listRule,
|
||||||
|
viewRule: data?.viewRule,
|
||||||
|
createRule: data?.createRule,
|
||||||
|
updateRule: data?.updateRule,
|
||||||
|
deleteRule: data?.deleteRule,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!schema.length) {
|
||||||
|
// As a last resort, try to derive field names and generate schema from sample records
|
||||||
|
try {
|
||||||
|
const perPage = 25;
|
||||||
|
const list = await pb.collection(collectionName).getList(1, perPage);
|
||||||
|
const items = list?.items || [];
|
||||||
|
if (items.length) {
|
||||||
|
const systemKeys = new Set(['id','created','updated','collectionId','collectionName','expand']);
|
||||||
|
const keyStats: Record<string, { count: number; types: Set<string> }> = {};
|
||||||
|
for (const it of items) {
|
||||||
|
for (const k of Object.keys(it)) {
|
||||||
|
if (systemKeys.has(k)) continue;
|
||||||
|
const v = (it as any)[k];
|
||||||
|
const t = Array.isArray(v) ? 'array' : (v === null ? 'null' : typeof v);
|
||||||
|
if (!keyStats[k]) keyStats[k] = { count: 0, types: new Set() };
|
||||||
|
if (v !== undefined && v !== null) keyStats[k].count += 1;
|
||||||
|
keyStats[k].types.add(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const generatedSchema = Object.entries(keyStats).map(([name, stat]) => ({
|
||||||
|
name,
|
||||||
|
type: Array.from(stat.types).join('|'),
|
||||||
|
required: stat.count === items.length
|
||||||
|
}));
|
||||||
|
return c.json({ ok: true, schema: [], meta, generatedSchema, message: 'Generated schema from sample records.' });
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return c.json({ ok: true, schema: [], meta, message: 'No fields found. Check collection name or permissions.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ ok: true, schema, meta });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('schema_fetch_error', { message: (err as Error)?.message });
|
||||||
|
return c.json({ message: 'Internal error' }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// List all collections (requires superuser token)
|
||||||
|
app.get('/api/collections', async (c) => {
|
||||||
|
try {
|
||||||
|
const authHeader = c.req.header('Authorization');
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
return c.json({ message: 'Missing authorization token' }, 401);
|
||||||
|
}
|
||||||
|
const token = authHeader.slice(7);
|
||||||
|
setUserPocketBaseAuth(token);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pb.collection('_superusers').authRefresh();
|
||||||
|
} catch (e) {
|
||||||
|
return c.json({ message: 'Invalid or non-superuser token' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = await pb.collections.getFullList();
|
||||||
|
const items = list.map((c) => ({
|
||||||
|
id: (c as any).id,
|
||||||
|
name: (c as any).name,
|
||||||
|
type: (c as any).type,
|
||||||
|
schemaCount: Array.isArray((c as any).schema) ? (c as any).schema.length : 0,
|
||||||
|
}));
|
||||||
|
return c.json({ ok: true, items });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('collections_list_error', { message: (err as Error)?.message });
|
||||||
|
return c.json({ message: 'Internal error' }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get a single field definition and sample values (requires superuser token)
|
||||||
|
app.get('/api/field', async (c) => {
|
||||||
|
try {
|
||||||
|
const authHeader = c.req.header('Authorization');
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
return c.json({ message: 'Missing authorization token' }, 401);
|
||||||
|
}
|
||||||
|
const token = authHeader.slice(7);
|
||||||
|
setUserPocketBaseAuth(token);
|
||||||
|
|
||||||
|
// Verify superuser
|
||||||
|
try {
|
||||||
|
await pb.collection('_superusers').authRefresh();
|
||||||
|
} catch (e) {
|
||||||
|
return c.json({ message: 'Invalid or non-superuser token' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(c.req.url);
|
||||||
|
const collectionName = url.searchParams.get('name') || process.env.PB_COLLECTION || 'Job_Info_TestEnv';
|
||||||
|
const fieldNameParam = url.searchParams.get('field') || 'job_number';
|
||||||
|
const lc = (s: string) => s.toLowerCase();
|
||||||
|
const candidates = Array.from(new Set([
|
||||||
|
fieldNameParam,
|
||||||
|
lc(fieldNameParam),
|
||||||
|
fieldNameParam.toUpperCase(),
|
||||||
|
fieldNameParam.replace(/[\s-]/g, '_'),
|
||||||
|
lc(fieldNameParam.replace(/[\s-]/g, '_')),
|
||||||
|
// naive camelCase variants
|
||||||
|
fieldNameParam.replace(/[_\s-]([a-z])/g, (_, ch) => ch.toUpperCase()),
|
||||||
|
lc(fieldNameParam.replace(/[_\s-]([a-z])/g, (_, ch) => ch.toUpperCase()))
|
||||||
|
]));
|
||||||
|
|
||||||
|
let definition: any = null;
|
||||||
|
let derived = false;
|
||||||
|
let values: any[] = [];
|
||||||
|
let totalItems = 0;
|
||||||
|
let resolvedField: string | null = null;
|
||||||
|
|
||||||
|
// Try getting field definition via SDK
|
||||||
|
try {
|
||||||
|
const collection = await pb.collections.getOne(collectionName);
|
||||||
|
if (Array.isArray(collection?.schema)) {
|
||||||
|
definition = collection.schema.find((f: any) => candidates.some((cand) => lc(f?.name || '') === lc(cand))) || null;
|
||||||
|
if (definition) resolvedField = definition.name;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// If no explicit schema, derive from a sample record
|
||||||
|
if (!definition) {
|
||||||
|
try {
|
||||||
|
const list = await pb.collection(collectionName).getList(1, 1);
|
||||||
|
const sample = list?.items?.[0];
|
||||||
|
if (sample) {
|
||||||
|
const sampleKeys = Object.keys(sample);
|
||||||
|
const matchKey = sampleKeys.find(k => candidates.some(c => lc(k) === lc(c)));
|
||||||
|
if (matchKey) {
|
||||||
|
derived = true;
|
||||||
|
resolvedField = matchKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect up to 10 values for preview
|
||||||
|
try {
|
||||||
|
const list = await pb.collection(collectionName).getList(1, 1);
|
||||||
|
totalItems = list?.totalItems ?? 0;
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const list = await pb.collection(collectionName).getList(1, 20);
|
||||||
|
const items = list?.items || [];
|
||||||
|
values = items.map((it: any) => {
|
||||||
|
if (resolvedField && Object.prototype.hasOwnProperty.call(it, resolvedField)) return it[resolvedField];
|
||||||
|
// last attempt: case-insensitive per record
|
||||||
|
const itKeys = Object.keys(it);
|
||||||
|
const k = itKeys.find(k => candidates.some(c => lc(k) === lc(c)));
|
||||||
|
return k ? it[k] : undefined;
|
||||||
|
}).filter((v: any) => v !== undefined && v !== null);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
const nonNullCount = values.length;
|
||||||
|
return c.json({ ok: true, collection: collectionName, field: fieldNameParam, resolvedField, definition, derived, values, count: nonNullCount, totalItems });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('field_fetch_error', { message: (err as Error)?.message });
|
||||||
|
return c.json({ message: 'Internal error' }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get sample records for a collection (requires superuser token)
|
||||||
|
app.get('/api/records', async (c) => {
|
||||||
|
try {
|
||||||
|
const authHeader = c.req.header('Authorization');
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
return c.json({ message: 'Missing authorization token' }, 401);
|
||||||
|
}
|
||||||
|
const token = authHeader.slice(7);
|
||||||
|
setUserPocketBaseAuth(token);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pb.collection('_superusers').authRefresh();
|
||||||
|
} catch (e) {
|
||||||
|
return c.json({ message: 'Invalid or non-superuser token' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(c.req.url);
|
||||||
|
const collectionName = url.searchParams.get('name') || process.env.PB_COLLECTION || 'Job_Info_TestEnv';
|
||||||
|
const perPage = Math.max(1, Math.min(100, Number(url.searchParams.get('perPage') || 10)));
|
||||||
|
|
||||||
|
const firstPage = await pb.collection(collectionName).getList(1, perPage);
|
||||||
|
const items = firstPage?.items || [];
|
||||||
|
return c.json({ ok: true, collection: collectionName, totalItems: firstPage?.totalItems ?? 0, items });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('records_fetch_error', { message: (err as Error)?.message });
|
||||||
|
return c.json({ message: 'Internal error' }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Serve admin.html
|
||||||
|
app.get('/admin', async (c) => {
|
||||||
|
const html = await Bun.file(new URL('./admin.html', import.meta.url)).text();
|
||||||
|
return c.html(html);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Export collection schema and headers (requires superuser token)
|
||||||
|
app.get('/api/export/schema', async (c) => {
|
||||||
|
try {
|
||||||
|
const authHeader = c.req.header('Authorization');
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
return c.json({ message: 'Missing authorization token' }, 401);
|
||||||
|
}
|
||||||
|
const token = authHeader.slice(7);
|
||||||
|
setUserPocketBaseAuth(token);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pb.collection('_superusers').authRefresh();
|
||||||
|
} catch (e) {
|
||||||
|
return c.json({ message: 'Invalid or non-superuser token' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(c.req.url);
|
||||||
|
const collectionName = url.searchParams.get('name') || process.env.PB_COLLECTION || 'Job_Info_TestEnv';
|
||||||
|
|
||||||
|
let schema: any[] = [];
|
||||||
|
let meta: Record<string, any> = {};
|
||||||
|
let generatedSchema: any[] = [];
|
||||||
|
|
||||||
|
// Fetch explicit schema via SDK
|
||||||
|
try {
|
||||||
|
const collection = await pb.collections.getOne(collectionName);
|
||||||
|
schema = Array.isArray(collection?.schema) ? collection.schema : [];
|
||||||
|
meta = {
|
||||||
|
id: (collection as any)?.id,
|
||||||
|
name: (collection as any)?.name,
|
||||||
|
type: (collection as any)?.type,
|
||||||
|
listRule: (collection as any)?.listRule,
|
||||||
|
viewRule: (collection as any)?.viewRule,
|
||||||
|
createRule: (collection as any)?.createRule,
|
||||||
|
updateRule: (collection as any)?.updateRule,
|
||||||
|
deleteRule: (collection as any)?.deleteRule,
|
||||||
|
};
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// If no explicit schema, generate from records
|
||||||
|
if (!schema.length) {
|
||||||
|
try {
|
||||||
|
const perPage = 100;
|
||||||
|
const list = await pb.collection(collectionName).getList(1, perPage);
|
||||||
|
const items = list?.items || [];
|
||||||
|
if (items.length) {
|
||||||
|
const systemKeys = new Set(['id','created','updated','collectionId','collectionName','expand']);
|
||||||
|
const keyStats: Record<string, { count: number; types: Set<string> }> = {};
|
||||||
|
for (const it of items) {
|
||||||
|
for (const k of Object.keys(it)) {
|
||||||
|
if (systemKeys.has(k)) continue;
|
||||||
|
const v = (it as any)[k];
|
||||||
|
const t = Array.isArray(v) ? 'array' : (v === null ? 'null' : typeof v);
|
||||||
|
if (!keyStats[k]) keyStats[k] = { count: 0, types: new Set() };
|
||||||
|
if (v !== undefined && v !== null) keyStats[k].count += 1;
|
||||||
|
keyStats[k].types.add(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
generatedSchema = Object.entries(keyStats).map(([name, stat]) => ({
|
||||||
|
name,
|
||||||
|
type: Array.from(stat.types).join('|'),
|
||||||
|
required: stat.count === items.length,
|
||||||
|
occurrences: stat.count,
|
||||||
|
sampleSize: items.length
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect all unique headers (field names)
|
||||||
|
const headers = new Set<string>();
|
||||||
|
try {
|
||||||
|
const perPage = 100;
|
||||||
|
const list = await pb.collection(collectionName).getList(1, perPage);
|
||||||
|
const items = list?.items || [];
|
||||||
|
for (const it of items) {
|
||||||
|
for (const k of Object.keys(it)) {
|
||||||
|
if (!['id','created','updated','collectionId','collectionName','expand'].includes(k)) {
|
||||||
|
headers.add(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
const exportData = {
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
collection: collectionName,
|
||||||
|
meta,
|
||||||
|
schema: schema.length ? schema : generatedSchema,
|
||||||
|
schemaType: schema.length ? 'explicit' : 'generated',
|
||||||
|
headers: Array.from(headers).sort(),
|
||||||
|
};
|
||||||
|
|
||||||
|
c.header('Content-Type', 'application/json');
|
||||||
|
c.header('Content-Disposition', `attachment; filename="${collectionName}-schema-${new Date().toISOString().split('T')[0]}.json"`);
|
||||||
|
return c.json(exportData);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('export_schema_error', { message: (err as Error)?.message });
|
||||||
|
return c.json({ message: 'Internal error' }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const PORT = Number(process.env.PORT || 6500);
|
||||||
|
|
||||||
|
console.log(`\n🚀 Server running on port ${PORT}`);
|
||||||
|
console.log(`\n📊 Estimator Table: \x1b[36mhttp://localhost:${PORT}/estimatortable.html\x1b[0m`);
|
||||||
|
console.log(`📝 Idea & Feedback Form: \x1b[36mhttp://localhost:${PORT}/index.html\x1b[0m`);
|
||||||
|
console.log(`👤 Admin Panel: \x1b[36mhttp://localhost:${PORT}/admin.html\x1b[0m\n`);
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
port: PORT,
|
port: PORT,
|
||||||
|
hostname: '0.0.0.0',
|
||||||
fetch: app.fetch,
|
fetch: app.fetch,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user