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