Init
This commit is contained in:
+30
@@ -0,0 +1,30 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
build/
|
||||
*.log
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Editor directories
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Backup files
|
||||
*.backup
|
||||
*.bak
|
||||
*.old
|
||||
|
||||
# Lock files (optional - include if using different package managers)
|
||||
# package-lock.json
|
||||
# yarn.lock
|
||||
# bun.lockb
|
||||
@@ -1,2 +1,135 @@
|
||||
# Job-Form
|
||||
# Job Creation Form with Excel Sync
|
||||
|
||||
**Unified system** that combines job creation form with immediate Excel synchronization.
|
||||
|
||||
## Architecture
|
||||
|
||||
This system merges:
|
||||
- **JobSubmissionFlow**: User form with auto-incrementing Job_Number
|
||||
- **SyncToExcelFromPb**: Microsoft Graph API Excel sync logic
|
||||
|
||||
### Flow
|
||||
|
||||
1. User submits job form
|
||||
2. Server authenticates with PocketBase (agent credentials)
|
||||
3. Auto-calculates `Job_Number = max(existing) + 1`
|
||||
4. Creates record in PocketBase `Job_Info_Prod`
|
||||
5. Captures `record.id` from creation response
|
||||
6. Acquires Microsoft Graph API token
|
||||
7. Gets Excel table columns
|
||||
8. Maps PocketBase fields to Excel columns (case-insensitive, only existing columns)
|
||||
9. Adds `pb_id = record.id` to Excel row
|
||||
10. POSTs new row to Excel `Test_Table`
|
||||
|
||||
## Configuration
|
||||
|
||||
All credentials are in `.env` (copied from existing systems):
|
||||
|
||||
```bash
|
||||
# Azure AD
|
||||
CLIENT_ID=3c846e71-9609-40e1-b458-0eb805e21b9f
|
||||
CLIENT_SECRET=7aD8Q~d5K~_PzQv6KqDdrEnmyXHE60eVDpbcnaK_
|
||||
TENANT_ID=3fd97ea7-b124-41f1-855f-52d8ac3b16c7
|
||||
|
||||
# PocketBase Agent
|
||||
PB_DB=https://pocketbase.ccllc.pro
|
||||
PB_AGENT_EMAIL=pbserviceupdate@cardoza.construction
|
||||
PB_AGENT_PASSWORD=gJFjYx5y0_BSMgX
|
||||
PB_COLLECTION=Job_Info_Prod
|
||||
PB_AUTH_COLLECTION=Users
|
||||
|
||||
# Excel
|
||||
DRIVE_ID=b!9YOqqr2xM0G2DFBwMEJYY4UzQgocFddEtpLYWuS9_AtkCKl2q8yjQJteQ7Ti4QSx
|
||||
ITEM_ID=01SPNXLDQRICHB63BFUNGKZ3GE6RL7LGBT
|
||||
EXCEL_TABLE=/drives/.../tables/Test_Table
|
||||
|
||||
# Server
|
||||
PORT=4000
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
**Use npm install** (not bun install) to avoid Windows/OneDrive symlink issues:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Start the server:
|
||||
|
||||
```bash
|
||||
bun run dev
|
||||
```
|
||||
|
||||
Server runs at: http://localhost:4000
|
||||
|
||||
## Key Features
|
||||
|
||||
### Auto-Increment Job Numbers
|
||||
- Queries all existing `Job_Number` values
|
||||
- Calculates `max + 1` for new job
|
||||
- No manual input required
|
||||
|
||||
### Excel Column Mapping
|
||||
- **Case-insensitive**: Matches `Job_Number` to `job_number`, `Company_Client` to `company_client`, etc.
|
||||
- **Only existing columns**: Ignores PocketBase fields not in Excel table
|
||||
- **Special mapping**: `record.id` → Excel column `pb_id`
|
||||
|
||||
### Error Handling
|
||||
- If Excel sync fails, PocketBase record is still created
|
||||
- Returns warning with partial success
|
||||
- Logs Excel error details
|
||||
|
||||
## Endpoints
|
||||
|
||||
- `POST /api/submit` - Submit job form (creates in PB + Excel)
|
||||
- `GET /api/health` - Health check
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Dependencies
|
||||
- **Hono**: Web framework
|
||||
- **PocketBase SDK**: Database client
|
||||
- **@microsoft/microsoft-graph-client**: Excel API
|
||||
- **dotenv**: Environment config
|
||||
|
||||
### Authentication
|
||||
- **PocketBase**: Service account (`pbserviceupdate@cardoza.construction`)
|
||||
- **Microsoft Graph**: OAuth2 client credentials flow
|
||||
|
||||
### Excel Operations
|
||||
- Table path: `EXCEL_TABLE` from .env
|
||||
- Operation: `POST /rows` with `{ values: [[...]] }`
|
||||
- Row format: 2D array matching column order
|
||||
|
||||
## Differences from Other Systems
|
||||
|
||||
### vs. SyncToExcelFromPb
|
||||
- **No batch sync**: Creates one record at a time
|
||||
- **No duplicate detection**: Always adds new row
|
||||
- **No change comparison**: Simple create operation
|
||||
- **No collision warnings**: Assumes Job_Number is unique
|
||||
|
||||
### vs. JobSubmissionFlow
|
||||
- **Excel sync included**: Adds to Excel immediately after PB creation
|
||||
- **pb_id captured**: Stores PocketBase record.id in Excel
|
||||
- **Graph token acquisition**: Handles Microsoft authentication
|
||||
- **Column-aware**: Only maps fields that exist in Excel table
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Bun Package Resolution Error
|
||||
**Symptom**: `Cannot find package 'hono'`
|
||||
**Solution**: Use `npm install` instead of `bun install`
|
||||
|
||||
### Excel Sync Fails
|
||||
- Check `EXCEL_TABLE` path in .env
|
||||
- Verify Azure app has Files.ReadWrite.All permission
|
||||
- Check Excel table name matches exactly (case-sensitive in Graph API)
|
||||
|
||||
### PocketBase Auth Fails
|
||||
- Verify `PB_AGENT_EMAIL` and `PB_AGENT_PASSWORD` are correct
|
||||
- Check agent account exists in `Users` collection
|
||||
- Ensure `PB_DB` URL is accessible
|
||||
|
||||
@@ -0,0 +1,560 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Job Creation Form</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
|
||||
<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 flex justify-center items-center font-sans">
|
||||
<!-- Login Container -->
|
||||
<div id="loginContainer" class="bg-white rounded-2xl shadow-2xl max-w-md w-full p-8">
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-4xl font-bold text-gray-800 mb-2">Cardoza Construction</h1>
|
||||
<p class="text-gray-600">Job Creation Portal</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<p class="text-gray-700 text-center">Sign in with your Microsoft account to access the Job Creation Form</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 id="loginError" class="hidden mt-4 p-3 bg-red-50 border border-red-200 text-red-700 rounded-lg text-sm"></div>
|
||||
</div>
|
||||
|
||||
<!-- Form Container (hidden until logged in) -->
|
||||
<div id="formContainer" class="hidden bg-white rounded-2xl shadow-2xl max-w-4xl w-full p-6 sm:p-12">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div>
|
||||
<h1 class="text-3xl sm:text-4xl font-bold text-gray-800">Job Creation Form</h1>
|
||||
<p class="text-gray-600 text-sm sm:text-base">Please fill out all required fields to submit a new job</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p id="userEmailDisplay" class="text-sm text-gray-600 mb-2"></p>
|
||||
<button type="button" id="logoutBtn" class="px-3 py-1 bg-gray-200 text-gray-800 rounded-lg text-sm hover:bg-gray-300 transition-colors">
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="jobForm" class="space-y-6" autocomplete="off">
|
||||
<!-- Basic Information -->
|
||||
<div class="bg-gray-50 rounded-xl p-4 sm:p-6">
|
||||
<div class="text-lg font-semibold text-gray-700 mb-4 pb-2 border-b-2 border-primary">Basic Information</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 mb-6">
|
||||
<div class="flex flex-col">
|
||||
<label for="Job_Name" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Job Name <span class="text-red-600">*</span>
|
||||
</label>
|
||||
<input type="text" id="Job_Name" name="Job_Name" required placeholder="Enter job name" autocomplete="off"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 mb-6">
|
||||
<div class="flex flex-col">
|
||||
<label for="Job_Status" class="block text-sm font-medium text-gray-700 mb-2">Job Status</label>
|
||||
<select id="Job_Status" name="Job_Status" required autocomplete="off"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all bg-white text-gray-900">
|
||||
<option value="Estimating" selected>Estimating</option>
|
||||
<option value="Est Sent">Est Sent</option>
|
||||
<option value="Est Hold">Est Hold</option>
|
||||
<option value="Follow Up">Follow Up</option>
|
||||
<option value="Awarded">Awarded</option>
|
||||
<option value="In Progress">In Progress</option>
|
||||
<option value="On Hold">On Hold</option>
|
||||
<option value="Billed (In Progress)">Billed (In Progress)</option>
|
||||
<option value="Billed (Closed)">Billed (Closed)</option>
|
||||
<option value="Archive">Archive</option>
|
||||
<option value="Not Awarded">Not Awarded</option>
|
||||
<option value="Not Bidding">Not Bidding</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 mb-6">
|
||||
<div class="flex flex-col">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Job Type</label>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" id="jobTypeFX" name="Job_Type" value="FX" class="w-4 h-4 text-primary focus:ring-primary" autocomplete="off">
|
||||
<span class="text-sm">FX</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" id="jobTypeTM" name="Job_Type" value="TM" class="w-4 h-4 text-primary focus:ring-primary">
|
||||
<span class="text-sm">TM</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" id="jobTypeUP" name="Job_Type" value="UP" class="w-4 h-4 text-primary focus:ring-primary">
|
||||
<span class="text-sm">UP</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" id="jobTypePW" name="Job_Type" value="PW" class="w-4 h-4 text-primary focus:ring-primary">
|
||||
<span class="text-sm">PW</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" id="jobTypeFXEX" name="Job_Type" value="FXEX" class="w-4 h-4 text-primary focus:ring-primary">
|
||||
<span class="text-sm">FXEX</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" id="jobTypePWEX" name="Job_Type" value="PWEX" class="w-4 h-4 text-primary focus:ring-primary">
|
||||
<span class="text-sm">PWEX</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6">
|
||||
<div class="flex flex-col">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Job Division</label>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" id="divisionC" name="Job_Division" value="C#" class="w-4 h-4 text-primary focus:ring-primary">
|
||||
<span class="text-sm">C#</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" id="divisionR" name="Job_Division" value="R#" class="w-4 h-4 text-primary focus:ring-primary">
|
||||
<span class="text-sm">R#</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" id="divisionS" name="Job_Division" value="S#" class="w-4 h-4 text-primary focus:ring-primary">
|
||||
<span class="text-sm">S#</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Personnel -->
|
||||
<div class="bg-gray-50 rounded-xl p-4 sm:p-6">
|
||||
<div class="text-lg font-semibold text-gray-700 mb-4 pb-2 border-b-2 border-primary">Personnel & Estimator</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6 mb-6">
|
||||
<div class="flex flex-col">
|
||||
<label for="Office_Rep" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Office Rep <span class="text-red-600">*</span>
|
||||
</label>
|
||||
<select id="Office_Rep" name="Office_Rep" required autocomplete="off"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all bg-white text-gray-400" data-placeholder="true">
|
||||
<option value="" disabled selected>Select office rep</option>
|
||||
<option value="Beth Cardoza">Beth Cardoza</option>
|
||||
<option value="Steve Brewer">Steve Brewer</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<label for="Estimator" class="block text-sm font-medium text-gray-700 mb-2">Estimator</label>
|
||||
<select id="Estimator" name="Estimator" required autocomplete="off"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all bg-white text-gray-400" data-placeholder="true">
|
||||
<option value="" disabled selected>Select estimator</option>
|
||||
<option value="Beth Cardoza">Beth Cardoza</option>
|
||||
<option value="Steve Brewer">Steve Brewer</option>
|
||||
<option value="Timothy Cardoza">Timothy Cardoza</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
<div class="flex flex-col">
|
||||
<label for="Project_Manager" class="block text-sm font-medium text-gray-700 mb-2">Project Manager</label>
|
||||
<select id="Project_Manager" name="Project_Manager" required autocomplete="off"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all bg-white text-gray-400" data-placeholder="true">
|
||||
<option value="" disabled selected>Select project manager</option>
|
||||
<option value="Dave">Dave</option>
|
||||
<option value="Rick">Rick</option>
|
||||
<option value="Eddie">Eddie</option>
|
||||
<option value="Steve">Steve</option>
|
||||
<option value="Timothy">Timothy</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Schedule & Dates -->
|
||||
<div class="bg-gray-50 rounded-xl p-4 sm:p-6">
|
||||
<div class="text-lg font-semibold text-gray-700 mb-4 pb-2 border-b-2 border-primary">Schedule & Dates</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 mb-6">
|
||||
<div class="flex flex-col">
|
||||
<label for="Schedule_Confidence" class="block text-sm font-medium text-gray-700 mb-2">Schedule Confidence</label>
|
||||
<select id="Schedule_Confidence" name="Schedule_Confidence" required autocomplete="off"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all bg-white text-gray-400" data-placeholder="true">
|
||||
<option value="" disabled selected>Select confidence level</option>
|
||||
<option value="1 (Total Guess)">1 (Total Guess)</option>
|
||||
<option value="2 (Guess)">2 (Guess)</option>
|
||||
<option value="3 (Start Date)">3 (Start Date)</option>
|
||||
<option value="4 (Client Verbal Guess)">4 (Client Verbal Guess)</option>
|
||||
<option value="5">5</option>
|
||||
<option value="6">6</option>
|
||||
<option value="7 (Written Schedule Within 90 Days)">7 (Written Schedule Within 90 Days)</option>
|
||||
<option value="8">8</option>
|
||||
<option value="9">9</option>
|
||||
<option value="10 (Job Starts Within 7 Days)">10 (Job Starts Within 7 Days)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 mb-6">
|
||||
<div class="flex flex-col">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Due Date Source</label>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" id="sourceRequested" name="Due_Date_Source" value="Requested" class="w-4 h-4 text-primary focus:ring-primary" autocomplete="off">
|
||||
<span class="text-sm">Requested</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" id="sourceGuess" name="Due_Date_Source" value="Guess" class="w-4 h-4 text-primary focus:ring-primary">
|
||||
<span class="text-sm">Guess</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" id="sourceStartDate" name="Due_Date_Source" value="Start Date" class="w-4 h-4 text-primary focus:ring-primary">
|
||||
<span class="text-sm">Start Date</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6 mb-6">
|
||||
<div class="flex flex-col">
|
||||
<label for="Due_Date" class="block text-sm font-medium text-gray-700 mb-2">Due Date</label>
|
||||
<input type="text" id="Due_Date" name="Due_Date" class="datepicker w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all" placeholder="Select due date" autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<label for="Due_Time" class="block text-sm font-medium text-gray-700 mb-2">Due Time</label>
|
||||
<input type="text" id="Due_Time" name="Due_Time" placeholder="Enter time" autocomplete="off"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
<div class="flex flex-col">
|
||||
<label for="Start_Date" class="block text-sm font-medium text-gray-700 mb-2">Start Date</label>
|
||||
<input type="text" id="Start_Date" name="Start_Date" class="datepicker w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all" placeholder="Select start date" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Company & Contact -->
|
||||
<div class="bg-gray-50 rounded-xl p-4 sm:p-6">
|
||||
<div class="text-lg font-semibold text-gray-700 mb-4 pb-2 border-b-2 border-primary">Company & Contact Information</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6 mb-6">
|
||||
<div class="flex flex-col">
|
||||
<label for="Company_Client" class="block text-sm font-medium text-gray-700 mb-2">Company / Client</label>
|
||||
<input type="text" id="Company_Client" name="Company_Client" placeholder="Enter company or client name" autocomplete="organization"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all">
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<label for="Contact_Person" class="block text-sm font-medium text-gray-700 mb-2">Contact Person</label>
|
||||
<input type="text" id="Contact_Person" name="Contact_Person" placeholder="Enter contact person" autocomplete="name"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6 mb-6">
|
||||
<div class="flex flex-col">
|
||||
<label for="Phone_Number" class="block text-sm font-medium text-gray-700 mb-2">Phone Number</label>
|
||||
<input type="tel" id="Phone_Number" name="Phone_Number" placeholder="Enter phone number" autocomplete="tel"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all">
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<label for="Email" class="block text-sm font-medium text-gray-700 mb-2">Email</label>
|
||||
<input type="email" id="Email" name="Email" placeholder="Enter email address" autocomplete="email"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6">
|
||||
<div class="flex flex-col">
|
||||
<label for="Job_Address" class="block text-sm font-medium text-gray-700 mb-2">Job Address</label>
|
||||
<input type="text" id="Job_Address" name="Job_Address" placeholder="Enter job address" autocomplete="street-address"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Financial -->
|
||||
<div id="financialSection" class="bg-red-100 border border-red-200 rounded-xl p-4 sm:p-6 transition-colors">
|
||||
<div class="text-lg font-semibold text-gray-700 mb-4 pb-2 border-b-2 border-primary">Financial</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6">
|
||||
<div class="flex flex-col">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Tax Exempt <span class="text-red-600">*</span>
|
||||
</label>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" id="taxExemptTrue" name="Tax_Exempt" value="true" required class="w-4 h-4 text-primary focus:ring-primary" autocomplete="off">
|
||||
<span class="text-sm">Yes</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" id="taxExemptFalse" name="Tax_Exempt" value="false" required class="w-4 h-4 text-primary focus:ring-primary" autocomplete="off">
|
||||
<span class="text-sm">No</span>
|
||||
</label>
|
||||
</div>
|
||||
<p id="taxReminder" class="hidden mt-3 text-sm font-semibold text-red-700">Don't forget to file the paperwork!</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" id="submitBtn" disabled
|
||||
class="w-full py-3 bg-gradient-to-br from-primary to-secondary text-white font-semibold rounded-lg hover:opacity-95 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed shadow-lg hover:shadow-xl">
|
||||
Submit Job
|
||||
</button>
|
||||
|
||||
<div id="message" class="hidden p-4 rounded-lg font-medium"></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/pocketbase/dist/pocketbase.umd.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>
|
||||
<script>
|
||||
// Initialize PocketBase client
|
||||
const pb = new PocketBase('https://pocketbase.ccllc.pro');
|
||||
|
||||
// Update auth UI based on login state
|
||||
function updateAuthUI() {
|
||||
const loginContainer = document.getElementById('loginContainer');
|
||||
const formContainer = document.getElementById('formContainer');
|
||||
const userEmailDisplay = document.getElementById('userEmailDisplay');
|
||||
|
||||
if (pb.authStore.isValid) {
|
||||
// Show form, hide login
|
||||
loginContainer.classList.add('hidden');
|
||||
formContainer.classList.remove('hidden');
|
||||
const userName = pb.authStore.record?.name || pb.authStore.record?.email || 'Unknown User';
|
||||
userEmailDisplay.textContent = userName;
|
||||
} else {
|
||||
// Show login, hide form
|
||||
loginContainer.classList.remove('hidden');
|
||||
formContainer.classList.add('hidden');
|
||||
document.getElementById('loginError').classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Handle login button click
|
||||
document.getElementById('loginBtn').addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
const loginBtn = document.getElementById('loginBtn');
|
||||
const loginError = document.getElementById('loginError');
|
||||
|
||||
loginBtn.disabled = true;
|
||||
loginBtn.textContent = 'Logging in...';
|
||||
loginError.classList.add('hidden');
|
||||
|
||||
try {
|
||||
const authData = await pb.collection('Users').authWithOAuth2({
|
||||
provider: 'microsoft',
|
||||
});
|
||||
console.log('✓ Logged in with Microsoft');
|
||||
updateAuthUI();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
loginError.textContent = `Login failed: ${error.message}`;
|
||||
loginError.classList.remove('hidden');
|
||||
} finally {
|
||||
loginBtn.disabled = false;
|
||||
loginBtn.textContent = 'Login with Microsoft';
|
||||
}
|
||||
});
|
||||
|
||||
// Handle logout button click
|
||||
document.getElementById('logoutBtn').addEventListener('click', () => {
|
||||
pb.authStore.clear();
|
||||
updateAuthUI();
|
||||
});
|
||||
|
||||
// Initial auth UI update
|
||||
updateAuthUI();
|
||||
|
||||
// Initialize date pickers
|
||||
flatpickr('.datepicker', {
|
||||
dateFormat: 'm/d/Y',
|
||||
allowInput: true,
|
||||
altInput: true,
|
||||
altFormat: 'm/d/Y',
|
||||
});
|
||||
|
||||
// Function to check if required fields are filled
|
||||
function checkRequiredFields() {
|
||||
const jobName = document.getElementById('Job_Name').value.trim();
|
||||
const officeRep = document.getElementById('Office_Rep').value;
|
||||
const taxExempt = document.querySelector('input[name="Tax_Exempt"]:checked');
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
|
||||
if (jobName && officeRep && taxExempt) {
|
||||
submitBtn.disabled = false;
|
||||
} else {
|
||||
submitBtn.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Update Financial section background and reminder based on Tax Exempt selection
|
||||
function updateFinancialUI() {
|
||||
const section = document.getElementById('financialSection');
|
||||
const reminder = document.getElementById('taxReminder');
|
||||
const selection = document.querySelector('input[name="Tax_Exempt"]:checked');
|
||||
|
||||
// Default: light red, hide reminder
|
||||
section.classList.remove('bg-green-50', 'border-green-200', 'border-red-300');
|
||||
section.classList.add('bg-red-100', 'border-red-200');
|
||||
reminder.classList.add('hidden');
|
||||
|
||||
if (!selection) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selection.value === 'false') {
|
||||
// No selected: light green
|
||||
section.classList.remove('bg-red-100', 'border-red-200', 'border-red-300');
|
||||
section.classList.add('bg-green-50', 'border-green-200');
|
||||
reminder.classList.add('hidden');
|
||||
} else {
|
||||
// Yes selected: stay red and show reminder
|
||||
section.classList.remove('bg-green-50', 'border-green-200');
|
||||
section.classList.add('bg-red-100', 'border-red-300');
|
||||
reminder.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Style selects to show gray placeholder until a real value is chosen
|
||||
function updateSelectPlaceholders() {
|
||||
const selects = document.querySelectorAll('select[data-placeholder="true"]');
|
||||
selects.forEach((sel) => {
|
||||
if (!sel.value) {
|
||||
sel.classList.add('text-gray-400');
|
||||
sel.classList.remove('text-gray-900');
|
||||
} else {
|
||||
sel.classList.remove('text-gray-400');
|
||||
sel.classList.add('text-gray-900');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add event listeners to required fields
|
||||
document.getElementById('Job_Name').addEventListener('input', checkRequiredFields);
|
||||
document.getElementById('Office_Rep').addEventListener('change', checkRequiredFields);
|
||||
document.getElementById('Office_Rep').addEventListener('change', updateSelectPlaceholders);
|
||||
document.querySelectorAll('input[name="Tax_Exempt"]').forEach(radio => {
|
||||
radio.addEventListener('change', () => {
|
||||
checkRequiredFields();
|
||||
updateFinancialUI();
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('Estimator').addEventListener('change', updateSelectPlaceholders);
|
||||
document.getElementById('Project_Manager').addEventListener('change', updateSelectPlaceholders);
|
||||
document.getElementById('Schedule_Confidence').addEventListener('change', updateSelectPlaceholders);
|
||||
|
||||
// Initial check
|
||||
checkRequiredFields();
|
||||
updateFinancialUI();
|
||||
updateSelectPlaceholders();
|
||||
|
||||
// Form submission
|
||||
document.getElementById('jobForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Check if user is authenticated
|
||||
if (!pb.authStore.isValid) {
|
||||
alert('Please log in with your Microsoft account first');
|
||||
return;
|
||||
}
|
||||
|
||||
const submitBtn = e.target.querySelector('#submitBtn');
|
||||
const message = document.getElementById('message');
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Submitting...';
|
||||
message.classList.add('hidden');
|
||||
|
||||
const formData = new FormData(e.target);
|
||||
const data = Object.fromEntries(formData.entries());
|
||||
|
||||
// Add user's PocketBase token
|
||||
data.pbToken = pb.authStore.token;
|
||||
|
||||
// Convert dates to MM/dd/yyyy format for PocketBase
|
||||
function formatDateToShort(dateString) {
|
||||
if (!dateString) return '';
|
||||
const parts = dateString.split('/');
|
||||
if (parts.length === 3) {
|
||||
const month = parts[0].padStart(2, '0');
|
||||
const day = parts[1].padStart(2, '0');
|
||||
const year = parts[2];
|
||||
return `${month}/${day}/${year}`;
|
||||
}
|
||||
return dateString;
|
||||
}
|
||||
|
||||
if (data.Due_Date) {
|
||||
data.Due_Date = formatDateToShort(data.Due_Date);
|
||||
}
|
||||
if (data.Start_Date) {
|
||||
data.Start_Date = formatDateToShort(data.Start_Date);
|
||||
}
|
||||
|
||||
// Convert Tax_Exempt to boolean
|
||||
if (data.Tax_Exempt) {
|
||||
data.Tax_Exempt = data.Tax_Exempt === 'true';
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/submit', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
message.className = 'p-4 rounded-lg font-medium bg-green-50 text-green-700 border border-green-200';
|
||||
message.textContent = result.message;
|
||||
message.classList.remove('hidden');
|
||||
e.target.reset();
|
||||
checkRequiredFields();
|
||||
} else {
|
||||
throw new Error(result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
message.className = 'p-4 rounded-lg font-medium bg-red-50 text-red-700 border border-red-200';
|
||||
message.textContent = error.message || 'Failed to submit form. Please try again.';
|
||||
message.classList.remove('hidden');
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Submit Job';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Version Info -->
|
||||
<div class="fixed bottom-4 right-4 text-xs text-gray-400">
|
||||
v1.0.0-beta1
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
|
||||
Generated
+384
@@ -0,0 +1,384 @@
|
||||
{
|
||||
"name": "job-creation-with-excel-sync",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "job-creation-with-excel-sync",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@microsoft/microsoft-graph-client": "^3.0.7",
|
||||
"dotenv": "^17.2.3",
|
||||
"hono": "^4.10.8",
|
||||
"pocketbase": "^0.26.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"@types/node": "latest",
|
||||
"autoprefixer": "^10.4.22",
|
||||
"postcss": "^8.5.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.28.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
|
||||
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@microsoft/microsoft-graph-client": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@microsoft/microsoft-graph-client/-/microsoft-graph-client-3.0.7.tgz",
|
||||
"integrity": "sha512-/AazAV/F+HK4LIywF9C+NYHcJo038zEnWkteilcxC1FM/uK/4NVGDKGrxx7nNq1ybspAroRKT4I1FHfxQzxkUw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"tslib": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@azure/identity": {
|
||||
"optional": true
|
||||
},
|
||||
"@azure/msal-browser": {
|
||||
"optional": true
|
||||
},
|
||||
"buffer": {
|
||||
"optional": true
|
||||
},
|
||||
"stream-browserify": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@types/bun": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.3.4.tgz",
|
||||
"integrity": "sha512-EEPTKXHP+zKGPkhRLv+HI0UEX8/o+65hqARxLy8Ov5rIxMBPNTjeZww00CIihrIQGEQBYg+0roO5qOnS/7boGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bun-types": "1.3.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.1.tgz",
|
||||
"integrity": "sha512-czWPzKIAXucn9PtsttxmumiQ9N0ok9FrBwgRWrwmVLlp86BrMExzvXRLFYRJ+Ex3g6yqj+KuaxfX1JTgV2lpfg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/autoprefixer": {
|
||||
"version": "10.4.22",
|
||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.22.tgz",
|
||||
"integrity": "sha512-ARe0v/t9gO28Bznv6GgqARmVqcWOV3mfgUPn9becPHMiD3o9BwlRgaeccZnwTpZ7Zwqrm+c1sUSsMxIzQzc8Xg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/autoprefixer"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"browserslist": "^4.27.0",
|
||||
"caniuse-lite": "^1.0.30001754",
|
||||
"fraction.js": "^5.3.4",
|
||||
"normalize-range": "^0.1.2",
|
||||
"picocolors": "^1.1.1",
|
||||
"postcss-value-parser": "^4.2.0"
|
||||
},
|
||||
"bin": {
|
||||
"autoprefixer": "bin/autoprefixer"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"postcss": "^8.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.9.7",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.7.tgz",
|
||||
"integrity": "sha512-k9xFKplee6KIio3IDbwj+uaCLpqzOwakOgmqzPezM0sFJlFKcg30vk2wOiAJtkTSfx0SSQDSe8q+mWA/fSH5Zg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"baseline-browser-mapping": "dist/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.28.1",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
|
||||
"integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/browserslist"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/browserslist"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
"electron-to-chromium": "^1.5.263",
|
||||
"node-releases": "^2.0.27",
|
||||
"update-browserslist-db": "^1.2.0"
|
||||
},
|
||||
"bin": {
|
||||
"browserslist": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/bun-types": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.4.tgz",
|
||||
"integrity": "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001760",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001760.tgz",
|
||||
"integrity": "sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/browserslist"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/caniuse-lite"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "17.2.3",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
|
||||
"integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.267",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz",
|
||||
"integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/fraction.js": {
|
||||
"version": "5.3.4",
|
||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
|
||||
"integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/rawify"
|
||||
}
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.10.8",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.10.8.tgz",
|
||||
"integrity": "sha512-DDT0A0r6wzhe8zCGoYOmMeuGu3dyTAE40HHjwUsWFTEy5WxK1x2WDSsBPlEXgPbRIFY6miDualuUDbasPogIww==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.27",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
||||
"integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/normalize-range": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz",
|
||||
"integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/pocketbase": {
|
||||
"version": "0.26.5",
|
||||
"resolved": "https://registry.npmjs.org/pocketbase/-/pocketbase-0.26.5.tgz",
|
||||
"integrity": "sha512-SXcq+sRvVpNxfLxPB1C+8eRatL7ZY4o3EVl/0OdE3MeR9fhPyZt0nmmxLqYmkLvXCN9qp3lXWV/0EUYb3MmMXQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/postcss"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss-value-parser": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
|
||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.2.tgz",
|
||||
"integrity": "sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/browserslist"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/browserslist"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"escalade": "^3.2.0",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"bin": {
|
||||
"update-browserslist-db": "cli.js"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"browserslist": ">= 4.21.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "job-creation-with-excel-sync",
|
||||
"version": "1.0.0",
|
||||
"description": "Unified job creation form with PocketBase and Excel sync",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun run server.ts",
|
||||
"start": "bun run server.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@microsoft/microsoft-graph-client": "^3.0.7",
|
||||
"dotenv": "^17.2.3",
|
||||
"hono": "^4.10.8",
|
||||
"pocketbase": "^0.26.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"@types/node": "latest"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
import { Hono } from 'hono';
|
||||
import { serveStatic } from 'hono/bun';
|
||||
import { cors } from 'hono/cors';
|
||||
import PocketBase from 'pocketbase';
|
||||
import { Client } from '@microsoft/microsoft-graph-client';
|
||||
import { config } from 'dotenv';
|
||||
import { mkdir, appendFile } from 'fs/promises';
|
||||
import { existsSync } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
config();
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
// ============================================================================
|
||||
// ERROR LOGGING
|
||||
// ============================================================================
|
||||
|
||||
const LOG_DIR = path.join(process.cwd(), 'logs');
|
||||
const ERROR_LOG_PATH = path.join(LOG_DIR, 'error.log');
|
||||
|
||||
// Ensure logs directory exists
|
||||
if (!existsSync(LOG_DIR)) {
|
||||
await mkdir(LOG_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
async function logError(context: string, error: any, additionalData?: any) {
|
||||
const timestamp = new Date().toISOString();
|
||||
const logEntry = {
|
||||
timestamp,
|
||||
context,
|
||||
error: {
|
||||
message: error?.message || String(error),
|
||||
stack: error?.stack,
|
||||
status: error?.status || error?.statusCode,
|
||||
},
|
||||
data: additionalData
|
||||
};
|
||||
|
||||
const logLine = `${timestamp} [ERROR] ${context}: ${JSON.stringify(logEntry)}\n`;
|
||||
|
||||
try {
|
||||
await appendFile(ERROR_LOG_PATH, logLine);
|
||||
console.error(`✗ [${context}] ${error?.message || error}`);
|
||||
} catch (writeError) {
|
||||
console.error('Failed to write to error log:', writeError);
|
||||
console.error('Original error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Enable CORS
|
||||
app.use('/*', cors());
|
||||
|
||||
// Serve static files from frontend directory
|
||||
app.use('/*', serveStatic({ root: './frontend' }));
|
||||
|
||||
// PocketBase client
|
||||
const pb = new PocketBase(process.env.PB_DB || 'https://pocketbase.ccllc.pro');
|
||||
|
||||
// (Removed) formula seeding logic: we rely on Excel's table calculated columns defined in the workbook
|
||||
|
||||
// Microsoft Graph authentication
|
||||
async function getGraphToken() {
|
||||
const tokenEndpoint = `https://login.microsoftonline.com/${process.env.TENANT_ID}/oauth2/v2.0/token`;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.append('client_id', process.env.CLIENT_ID!);
|
||||
params.append('client_secret', process.env.CLIENT_SECRET!);
|
||||
params.append('scope', 'https://graph.microsoft.com/.default');
|
||||
params.append('grant_type', 'client_credentials');
|
||||
|
||||
const response = await fetch(tokenEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: params,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
const error = new Error(`Graph token request failed: ${response.status} ${response.statusText} - ${text}`);
|
||||
await logError('Graph API Token', error, { endpoint: tokenEndpoint });
|
||||
throw error;
|
||||
}
|
||||
|
||||
type TokenResponse = { access_token: string };
|
||||
const data = await response.json() as TokenResponse;
|
||||
if (!data || typeof data.access_token !== 'string' || !data.access_token) {
|
||||
const error = new Error('Graph token response missing access_token');
|
||||
await logError('Graph API Token', error, { response: data });
|
||||
throw error;
|
||||
}
|
||||
return data.access_token;
|
||||
}
|
||||
|
||||
// Create Microsoft Graph client
|
||||
async function getGraphClient() {
|
||||
const token = await getGraphToken();
|
||||
|
||||
return Client.init({
|
||||
authProvider: (done) => {
|
||||
done(null, token);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Use user's PocketBase token (passed from authenticated frontend)
|
||||
function setUserPocketBaseAuth(token: string) {
|
||||
pb.authStore.save(token);
|
||||
}
|
||||
|
||||
// Get Excel table column names
|
||||
async function getExcelColumns(client: any, excelTablePath: string) {
|
||||
const colsResp = await client.api(`${excelTablePath}/columns`).get();
|
||||
const columns = colsResp?.value || [];
|
||||
const columnNames: string[] = columns.map((c: any) => c.name);
|
||||
return columnNames;
|
||||
}
|
||||
|
||||
// Cache for table structure (queried once, reused for all submissions)
|
||||
let tableCache: {
|
||||
columnNames: string[];
|
||||
} | null = null;
|
||||
|
||||
// Resolve the Excel table name from env; prefers EXCEL_TABLE_NAME (just the table's name),
|
||||
// and falls back to parsing EXCEL_TABLE if provided as a full path, else default to Job_List.
|
||||
function resolveTableName(): string {
|
||||
const nameFromSimpleEnv = process.env.EXCEL_TABLE_NAME?.trim();
|
||||
if (nameFromSimpleEnv) return nameFromSimpleEnv;
|
||||
|
||||
const nameFromPath = process.env.EXCEL_TABLE?.split('/tables/').pop()?.trim();
|
||||
if (nameFromPath) return nameFromPath;
|
||||
|
||||
return 'Job_List';
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CALCULATED COLUMN FUNCTIONS (server-side formula equivalents)
|
||||
// ============================================================================
|
||||
// These replace Excel table formulas with TypeScript calculations on entry
|
||||
// Ensures consistent, calculated values added to each new row
|
||||
|
||||
/**
|
||||
* Job_Full_Name = CONCAT([@[Job_Name]]," - ",[@[Job_Address]]," - ",[@[Company_Client]])
|
||||
*/
|
||||
function calculateJobFullName(record: any): string {
|
||||
const jobName = record.Job_Name || record.job_name || '';
|
||||
const jobAddress = record.Job_Address || record.job_address || '';
|
||||
const companyClient = record.Company_Client || record.company_client || '';
|
||||
|
||||
const parts = [jobName, jobAddress, companyClient].filter(p => p && String(p).trim());
|
||||
return parts.join(' - ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Due_Date_Counter = IF([@[Job_Status]]="Estimating",IF(ISNUMBER(SEARCH("ASAP",[@[Due_Date]])),"ASAP",IF([@[Due_Date]]<TODAY(),"Passed due",IF([@[Due_Date]]=TODAY(),"Due today",([@[Due_Date]]-TODAY())&IF([@[Due_Date]]-TODAY()=1," day"," days")))),"N/A")
|
||||
*/
|
||||
function calculateDueDateCounter(record: any): string {
|
||||
const jobStatus = record.Job_Status || record.job_status || '';
|
||||
const dueDateStr = record.Due_Date || record.due_date || '';
|
||||
|
||||
if (jobStatus !== 'Estimating') {
|
||||
return 'N/A';
|
||||
}
|
||||
|
||||
if (!dueDateStr) {
|
||||
return 'N/A';
|
||||
}
|
||||
|
||||
// Check if "ASAP" appears in the date string
|
||||
if (String(dueDateStr).toUpperCase().includes('ASAP')) {
|
||||
return 'ASAP';
|
||||
}
|
||||
|
||||
// Parse the date (could be ISO or MM/dd/yyyy)
|
||||
let dueDate = new Date(dueDateStr);
|
||||
if (isNaN(dueDate.getTime())) {
|
||||
return 'N/A';
|
||||
}
|
||||
|
||||
const today = new Date();
|
||||
// Reset time portions to midnight for fair comparison
|
||||
today.setHours(0, 0, 0, 0);
|
||||
dueDate.setHours(0, 0, 0, 0);
|
||||
|
||||
const timeDiff = dueDate.getTime() - today.getTime();
|
||||
const daysDiff = Math.floor(timeDiff / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (daysDiff < 0) {
|
||||
return 'Passed due';
|
||||
} else if (daysDiff === 0) {
|
||||
return 'Due today';
|
||||
} else {
|
||||
const dayLabel = daysDiff === 1 ? ' day' : ' days';
|
||||
return `${daysDiff}${dayLabel}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Job_QB_Link = [@[Job_Division]]&[@[Job_Number]]&[@[Job_Type]]&" - "&[@[Job_Name]]
|
||||
*/
|
||||
function calculateJobQbLink(record: any): string {
|
||||
const jobDivision = record.Job_Division || record.job_division || '';
|
||||
const jobNumber = record.Job_Number || record.job_number || '';
|
||||
const jobType = record.Job_Type || record.job_type || '';
|
||||
const jobName = record.Job_Name || record.job_name || '';
|
||||
|
||||
return `${jobDivision}${jobNumber}${jobType} - ${jobName}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Voxer_Link = [@[Job_Division]]&[@[Job_Number]]&[@[Job_Type]]&" - "&[@[Job_Name]]&" - "&[@[Job_Address]]&" - "&[@[Company_Client]]&" - "&[@[Contact_Person]]&" - "&[@[Phone_Number]]
|
||||
*/
|
||||
function calculateVoxerLink(record: any): string {
|
||||
const jobDivision = record.Job_Division || record.job_division || '';
|
||||
const jobNumber = record.Job_Number || record.job_number || '';
|
||||
const jobType = record.Job_Type || record.job_type || '';
|
||||
const jobName = record.Job_Name || record.job_name || '';
|
||||
const jobAddress = record.Job_Address || record.job_address || '';
|
||||
const companyClient = record.Company_Client || record.company_client || '';
|
||||
const contactPerson = record.Contact_Person || record.contact_person || '';
|
||||
const phoneNumber = record.Phone_Number || record.phone_number || '';
|
||||
|
||||
return `${jobDivision}${jobNumber}${jobType} - ${jobName} - ${jobAddress} - ${companyClient} - ${contactPerson} - ${phoneNumber}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Job_Codes = [@[Job_Number]]&" - "&[@[Job_Name]]
|
||||
*/
|
||||
function calculateJobCodes(record: any): string {
|
||||
const jobNumber = record.Job_Number || record.job_number || '';
|
||||
const jobName = record.Job_Name || record.job_name || '';
|
||||
|
||||
return `${jobNumber} - ${jobName}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Active = IF(OR([@[Job_Status]]="Estimating",[@[Job_Status]]="Est Sent",[@[Job_Status]]="Est Hold",[@[Job_Status]]="Follow Up",[@[Job_Status]]="Awarded",[@[Job_Status]]="In Progress",[@[Job_Status]]="Billed In Progress"),TRUE,FALSE)
|
||||
*/
|
||||
function calculateActive(record: any): boolean {
|
||||
const jobStatus = record.Job_Status || record.job_status || '';
|
||||
const activeStatuses = [
|
||||
'Estimating',
|
||||
'Est Sent',
|
||||
'Est Hold',
|
||||
'Follow Up',
|
||||
'Awarded',
|
||||
'In Progress',
|
||||
'Billed In Progress'
|
||||
];
|
||||
|
||||
return activeStatuses.includes(jobStatus);
|
||||
}
|
||||
|
||||
// Initialize table cache on startup
|
||||
async function initializeTableCache() {
|
||||
const client = await getGraphClient();
|
||||
const tableName = resolveTableName();
|
||||
const excelTablePath = `/drives/${process.env.DRIVE_ID}/items/${process.env.ITEM_ID}/workbook/tables/${tableName}`;
|
||||
|
||||
try {
|
||||
const colsResp = await client.api(`${excelTablePath}/columns`).get();
|
||||
const columns = colsResp?.value || [];
|
||||
const columnNames: string[] = columns.map((c: any) => c.name);
|
||||
|
||||
tableCache = { columnNames };
|
||||
console.log(`✓ Table cache initialized with ${columnNames.length} columns`);
|
||||
} catch (error: any) {
|
||||
console.error('✗ Failed to initialize table cache:', error);
|
||||
await logError('Table Cache Initialization', error, {
|
||||
driveId: process.env.DRIVE_ID,
|
||||
itemId: process.env.ITEM_ID
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Add record to Excel (only columns that exist in table)
|
||||
async function addToExcel(record: any, pbId: string) {
|
||||
const client = await getGraphClient();
|
||||
const tableName = resolveTableName();
|
||||
const excelTablePath = `/drives/${process.env.DRIVE_ID}/items/${process.env.ITEM_ID}/workbook/tables/${tableName}`;
|
||||
|
||||
// Initialize cache if not already done
|
||||
if (!tableCache) {
|
||||
await initializeTableCache();
|
||||
}
|
||||
|
||||
const { columnNames } = tableCache!;
|
||||
|
||||
try {
|
||||
// Build row values in column order
|
||||
// Helper: format PocketBase UTC/ISO dates to MM/dd/yyyy for Excel display
|
||||
function formatPbDateToShort(val: any): string {
|
||||
if (!val) return '';
|
||||
const d = new Date(val);
|
||||
if (isNaN(d.getTime())) return '';
|
||||
// Use UTC accessors to preserve date (don't convert to local time)
|
||||
const mm = String(d.getUTCMonth() + 1).padStart(2, '0');
|
||||
const dd = String(d.getUTCDate()).padStart(2, '0');
|
||||
const yyyy = String(d.getUTCFullYear());
|
||||
return `${mm}/${dd}/${yyyy}`;
|
||||
}
|
||||
|
||||
const rowValues = columnNames.map((col) => {
|
||||
const colLower = col.toLowerCase();
|
||||
|
||||
// Special case: pb_id column gets the record.id
|
||||
if (colLower === 'pb_id') {
|
||||
return pbId || '';
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// CALCULATED COLUMNS: compute values server-side on entry
|
||||
// ========================================================================
|
||||
|
||||
// Job_Full_Name: CONCAT(Job_Name, " - ", Job_Address, " - ", Company_Client)
|
||||
if (colLower === 'job_full_name') {
|
||||
return calculateJobFullName(record);
|
||||
}
|
||||
|
||||
// Due_Date_Counter: Status-aware due date counter (ASAP, Passed due, Due today, X days)
|
||||
if (colLower === 'due_date_counter') {
|
||||
// Send Excel formula so the table auto-calculates
|
||||
return '=IF([@[Job_Status]]<>"Estimating","N/A",IF(ISNUMBER(SEARCH("ASAP",[@[Due_Date]])),"ASAP",IF([@[Due_Date]]<TODAY(),"Passed due",IF([@[Due_Date]]=TODAY(),"Due today",[@[Due_Date]]-TODAY()&IF([@[Due_Date]]-TODAY()=1," day"," days")))))';
|
||||
}
|
||||
|
||||
// Job_QB_Link: Job_Division + Job_Number + Job_Type + " - " + Job_Name
|
||||
if (colLower === 'job_qb_link') {
|
||||
return calculateJobQbLink(record);
|
||||
}
|
||||
|
||||
// Voxer_Link: Long concatenation with division, number, type, name, address, client, contact, phone
|
||||
if (colLower === 'voxer_link') {
|
||||
return calculateVoxerLink(record);
|
||||
}
|
||||
|
||||
// Job_Codes: Job_Number + " - " + Job_Name
|
||||
if (colLower === 'job_codes') {
|
||||
return calculateJobCodes(record);
|
||||
}
|
||||
|
||||
// Active: boolean based on Job_Status membership in active statuses list
|
||||
if (colLower === 'active') {
|
||||
// Send Excel formula so the table auto-calculates
|
||||
return '=OR([@[Job_Status]]="Estimating",[@[Job_Status]]="Est Sent",[@[Job_Status]]="Est Hold",[@[Job_Status]]="Follow Up",[@[Job_Status]]="Awarded",[@[Job_Status]]="In Progress",[@[Job_Status]]="Billed In Progress")';
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// REGULAR COLUMNS: format dates, fall back to direct value
|
||||
// ========================================================================
|
||||
|
||||
// Try exact match first, then case-insensitive
|
||||
let val = record[col];
|
||||
if (val === undefined) {
|
||||
const recordKeys = Object.keys(record);
|
||||
const matchingKey = recordKeys.find(k => k.toLowerCase() === colLower);
|
||||
if (matchingKey) {
|
||||
val = record[matchingKey];
|
||||
}
|
||||
}
|
||||
|
||||
// Format known date columns from PB to Excel-friendly short dates
|
||||
if (['due_date','start_date','submission_date'].includes(colLower)) {
|
||||
return formatPbDateToShort(val);
|
||||
}
|
||||
|
||||
return val == null ? '' : val;
|
||||
});
|
||||
|
||||
// Add row to Excel table at index 0 (first position after headers)
|
||||
await client.api(`${excelTablePath}/rows`).post({
|
||||
index: 0,
|
||||
values: [rowValues]
|
||||
});
|
||||
|
||||
console.log(`✓ Synced to Excel (pb_id: ${pbId})`);
|
||||
|
||||
// DISABLED: formula copy causing issues - skip for now
|
||||
// Previous logic was attempting to copy formulas which may corrupt or duplicate rows
|
||||
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
console.error('✗ Failed to add record to Excel:', error);
|
||||
await logError('Excel Sync', error, {
|
||||
pbId,
|
||||
jobNumber: record.Job_Number || record.job_number,
|
||||
driveId: process.env.DRIVE_ID,
|
||||
itemId: process.env.ITEM_ID
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// API endpoint to submit job
|
||||
app.post('/api/submit', async (c) => {
|
||||
let data: any;
|
||||
try {
|
||||
data = await c.req.json();
|
||||
const userToken = data.pbToken;
|
||||
|
||||
if (!userToken) {
|
||||
return c.json({
|
||||
success: false,
|
||||
message: 'User authentication required. Please log in first.'
|
||||
}, 401);
|
||||
}
|
||||
|
||||
// Use user's PocketBase token
|
||||
setUserPocketBaseAuth(userToken);
|
||||
|
||||
// Convert picker values to UTC ISO strings before PocketBase create
|
||||
function toIsoUtc(val: any): string | undefined {
|
||||
if (!val) return undefined;
|
||||
// If already ISO-like, try Date parsing
|
||||
let d = new Date(val);
|
||||
if (isNaN(d.getTime())) {
|
||||
// Try m/d/Y
|
||||
const m = /^\s*(\d{1,2})\/(\d{1,2})\/(\d{2,4})\s*$/.exec(String(val));
|
||||
if (m) {
|
||||
const month = Number(m[1]);
|
||||
const day = Number(m[2]);
|
||||
const year = Number(m[3].length === 2 ? `20${m[3]}` : m[3]);
|
||||
// Assume local midnight, then convert to UTC
|
||||
d = new Date(year, month - 1, day, 0, 0, 0);
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
// Normalize to UTC midnight (strip time if present)
|
||||
const utc = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0));
|
||||
return utc.toISOString();
|
||||
}
|
||||
|
||||
const dueDateRaw = data.Due_Date ?? data.due_date ?? data.DueDate ?? data.dueDate;
|
||||
const startDateRaw = data.Start_Date ?? data.start_date ?? data.StartDate ?? data.startDate;
|
||||
const dueIso = toIsoUtc(dueDateRaw);
|
||||
const startIso = toIsoUtc(startDateRaw);
|
||||
if (dueIso) data.Due_Date = dueIso;
|
||||
if (startIso) data.Start_Date = startIso;
|
||||
|
||||
// Get all existing job numbers and find the max
|
||||
const existingJobs = await pb.collection(process.env.PB_COLLECTION!).getFullList({
|
||||
fields: 'Job_Number',
|
||||
sort: '-created',
|
||||
});
|
||||
|
||||
let maxJobNumber = 0;
|
||||
existingJobs.forEach((job: any) => {
|
||||
const jobNum = parseInt(job.Job_Number, 10);
|
||||
if (!isNaN(jobNum) && jobNum > maxJobNumber) {
|
||||
maxJobNumber = jobNum;
|
||||
}
|
||||
});
|
||||
|
||||
// Create new job number as string
|
||||
const newJobNumber = String(maxJobNumber + 1);
|
||||
data.Job_Number = newJobNumber;
|
||||
|
||||
// Remove pbToken from data before creating record
|
||||
delete data.pbToken;
|
||||
|
||||
// Create the record in PocketBase as the authenticated user
|
||||
const record = await pb.collection(process.env.PB_COLLECTION!).create(data);
|
||||
console.log(`✓ Created PocketBase record with Job_Number: ${newJobNumber}, ID: ${record.id}`);
|
||||
|
||||
// Add to Excel with pb_id = record.id
|
||||
try {
|
||||
await addToExcel(record, record.id);
|
||||
} catch (excelError: any) {
|
||||
console.error('⚠️ Excel sync failed, but PocketBase record was created:', excelError);
|
||||
return c.json({
|
||||
success: true,
|
||||
warning: 'Job created in PocketBase but Excel sync failed',
|
||||
message: `Job created successfully with Job Number: ${newJobNumber}`,
|
||||
jobId: record.id,
|
||||
jobNumber: newJobNumber,
|
||||
excelError: excelError.message
|
||||
});
|
||||
}
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
message: `Job created successfully with Job Number: ${newJobNumber} and synced to Excel`,
|
||||
jobId: record.id,
|
||||
jobNumber: newJobNumber
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('✗ Error submitting job:', error);
|
||||
await logError('Job Submission', error, {
|
||||
jobName: data?.Job_Name,
|
||||
userId: pb.authStore.record?.id
|
||||
});
|
||||
return c.json({
|
||||
success: false,
|
||||
message: error.message || 'Failed to create job'
|
||||
}, 400);
|
||||
}
|
||||
});
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/api/health', (c) => {
|
||||
return c.json({
|
||||
status: 'ok',
|
||||
service: 'job-creation-with-excel-sync',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
});
|
||||
|
||||
const PORT = Number(process.env.PORT || 4000);
|
||||
|
||||
console.log(`Job Creation with Excel Sync server running at http://localhost:${PORT}`);
|
||||
|
||||
// Initialize table cache only (non-blocking)
|
||||
initializeTableCache().catch((error) => {
|
||||
console.error('✗ Failed to initialize table cache:', error);
|
||||
});
|
||||
|
||||
export default {
|
||||
port: PORT,
|
||||
fetch: app.fetch,
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"types": ["bun-types", "node", "bun"],
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"composite": true,
|
||||
"strict": true,
|
||||
"downlevelIteration": true,
|
||||
"skipLibCheck": true,
|
||||
"jsx": "react-jsx",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"allowJs": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user