Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d4628eb9a1 | |||
| 6f02194c23 | |||
| cc52c11bf6 | |||
| 827f32a3d9 | |||
| ca486a6196 | |||
| d8cc287330 |
@@ -0,0 +1,42 @@
|
|||||||
|
# Session Notes
|
||||||
|
|
||||||
|
## 2026-01-07
|
||||||
|
|
||||||
|
### Context
|
||||||
|
- Picked up the Job-Form project
|
||||||
|
- Stack: TypeScript server, HTML frontend with TailwindCSS
|
||||||
|
- Current version: v1.0.0-beta4
|
||||||
|
|
||||||
|
### Project Summary
|
||||||
|
- **Purpose**: Job creation form with Excel sync and PocketBase backend
|
||||||
|
- **Features**:
|
||||||
|
- Form submission → PocketBase record → Excel sync
|
||||||
|
- Teams channel notifications (Adaptive Cards)
|
||||||
|
- Teams message management UI
|
||||||
|
|
||||||
|
### Known Issues / Limitations
|
||||||
|
- Teams message deletion not supported via Graph API (HTTP 405) — platform limitation
|
||||||
|
- Workaround: Delete messages directly in Teams client
|
||||||
|
|
||||||
|
### Today's Work
|
||||||
|
- [x] Migrate to full Bun-native stack (removed dotenv, use Bun file APIs)
|
||||||
|
- [x] Fix Planner task creation to use form's due date with proper UTC handling
|
||||||
|
- [x] Remove hardcoded test values — now uses activeConfig for all environments
|
||||||
|
- [x] Make assignee configurable via `PLANNER_ASSIGNEE_ID` env var
|
||||||
|
|
||||||
|
### PROD Mode Checklist
|
||||||
|
Before switching `MODE = 'PROD'`, ensure `/home/admin/secrets/.env` contains:
|
||||||
|
- `PB_COLLECTION` - PocketBase collection name (e.g., `Job_Info_Prod`)
|
||||||
|
- `EXCEL_TABLE_NAME` - Excel table name (e.g., `Job_List`)
|
||||||
|
- `PLANNER_GROUP_ID` - Microsoft Planner group ID
|
||||||
|
- `PLANNER_PLAN_ID` - Microsoft Planner plan ID
|
||||||
|
- `PLANNER_BUCKET_ID` - Microsoft Planner bucket ID
|
||||||
|
- `PLANNER_ASSIGNEE_ID` - User ID for Planner task assignee (e.g., `1a6e9d10-138a-43e4-a09e-1f50463148c9`)
|
||||||
|
- `PLANNER_ASSIGNEE_NAME` - Display name for assignee (e.g., `Alex Ewing`)
|
||||||
|
- All standard Azure AD + PocketBase creds (CLIENT_ID, CLIENT_SECRET, etc.)
|
||||||
|
|
||||||
|
**No hardcoded test values remain in the codebase.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_Last updated: 2026-01-07_
|
||||||
+68
-38
@@ -64,7 +64,7 @@
|
|||||||
<label for="Job_Name" class="block text-sm font-medium text-gray-700 mb-2">
|
<label for="Job_Name" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Job Name <span class="text-red-600">*</span>
|
Job Name <span class="text-red-600">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input type="text" id="Job_Name" name="Job_Name" required placeholder="Enter job name" autocomplete="off"
|
<input type="text" id="Job_Name" name="Job_Name" required placeholder="Enter job name" autocomplete="off" value="unk"
|
||||||
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">
|
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">
|
||||||
<p class="text-xs text-gray-500 mt-1">Use a descriptive name (no symbols-only, no N/A).</p>
|
<p class="text-xs text-gray-500 mt-1">Use a descriptive name (no symbols-only, no N/A).</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -96,7 +96,7 @@
|
|||||||
<label class="block text-sm font-medium text-gray-700 mb-2">Job Type</label>
|
<label class="block text-sm font-medium text-gray-700 mb-2">Job Type</label>
|
||||||
<div class="flex flex-wrap gap-4">
|
<div class="flex flex-wrap gap-4">
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
<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">
|
<input type="radio" id="jobTypeFX" name="Job_Type" value="FX" class="w-4 h-4 text-primary focus:ring-primary" checked autocomplete="off">
|
||||||
<span class="text-sm">FX</span>
|
<span class="text-sm">FX</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
@@ -132,7 +132,7 @@
|
|||||||
<span class="text-sm">C#</span>
|
<span class="text-sm">C#</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
<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">
|
<input type="radio" id="divisionR" name="Job_Division" value="R#" class="w-4 h-4 text-primary focus:ring-primary" checked>
|
||||||
<span class="text-sm">R#</span>
|
<span class="text-sm">R#</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
@@ -151,13 +151,13 @@
|
|||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6 mb-6">
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6 mb-6">
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<label for="Company_Client" class="block text-sm font-medium text-gray-700 mb-2">Company / Client</label>
|
<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"
|
<input type="text" id="Company_Client" name="Company_Client" placeholder="Enter company or client name" autocomplete="organization" value="Tester"
|
||||||
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">
|
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="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<label for="Contact_Person" class="block text-sm font-medium text-gray-700 mb-2">Contact Person</label>
|
<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"
|
<input type="text" id="Contact_Person" name="Contact_Person" placeholder="Enter contact person" autocomplete="name" value="Tester"
|
||||||
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">
|
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>
|
</div>
|
||||||
@@ -165,14 +165,14 @@
|
|||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6 mb-6">
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6 mb-6">
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<label for="Phone_Number" class="block text-sm font-medium text-gray-700 mb-2">Phone Number</label>
|
<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="(###) ###-####" autocomplete="tel"
|
<input type="tel" id="Phone_Number" name="Phone_Number" placeholder="(###) ###-####" autocomplete="tel" value="(111) 111-1111"
|
||||||
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">
|
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">
|
||||||
<p class="text-xs text-gray-500 mt-1">Use format (###) ###-####. Extension can go in EXT.</p>
|
<p class="text-xs text-gray-500 mt-1">Use format (###) ###-####. Extension can go in EXT.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<label for="Email" class="block text-sm font-medium text-gray-700 mb-2">Email</label>
|
<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"
|
<input type="email" id="Email" name="Email" placeholder="Enter email address" autocomplete="email" value="test@test"
|
||||||
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">
|
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>
|
</div>
|
||||||
@@ -180,13 +180,13 @@
|
|||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6 mb-6">
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6 mb-6">
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<label for="EXT" class="block text-sm font-medium text-gray-700 mb-2">EXT (optional)</label>
|
<label for="EXT" class="block text-sm font-medium text-gray-700 mb-2">EXT (optional)</label>
|
||||||
<input type="text" id="EXT" name="EXT" placeholder="Extension" autocomplete="off"
|
<input type="text" id="EXT" name="EXT" placeholder="Extension" autocomplete="off" value="123"
|
||||||
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">
|
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="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<label for="Fax" class="block text-sm font-medium text-gray-700 mb-2">Fax (optional)</label>
|
<label for="Fax" class="block text-sm font-medium text-gray-700 mb-2">Fax (optional)</label>
|
||||||
<input type="text" id="Fax" name="Fax" placeholder="Fax number" autocomplete="off"
|
<input type="text" id="Fax" name="Fax" placeholder="Fax number" autocomplete="off" value="(222) 222-2222"
|
||||||
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">
|
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>
|
</div>
|
||||||
@@ -195,7 +195,7 @@
|
|||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<label for="Contact_Notes" class="block text-sm font-medium text-gray-700 mb-2">Contact Notes (optional)</label>
|
<label for="Contact_Notes" class="block text-sm font-medium text-gray-700 mb-2">Contact Notes (optional)</label>
|
||||||
<textarea id="Contact_Notes" name="Contact_Notes" rows="2" placeholder="Add extra contact details or instructions"
|
<textarea id="Contact_Notes" name="Contact_Notes" rows="2" placeholder="Add extra contact details or instructions"
|
||||||
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"></textarea>
|
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">testing</textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -203,7 +203,7 @@
|
|||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<label for="Job_Address" class="block text-sm font-medium text-gray-700 mb-2">Job Address</label>
|
<label for="Job_Address" class="block text-sm font-medium text-gray-700 mb-2">Job Address</label>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<input type="text" id="Job_Address" name="Job_Address" placeholder="e.g., 123 Main St, Los Angeles, CA 90012 or 34.0522, -118.2437" autocomplete="street-address"
|
<input type="text" id="Job_Address" name="Job_Address" placeholder="e.g., 123 Main St, Los Angeles, CA 90012 or 34.0522, -118.2437" autocomplete="street-address" value="123 Main St. Los Angeles, CA"
|
||||||
class="flex-1 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">
|
class="flex-1 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">
|
||||||
<button type="button" id="openMapPicker" class="px-4 py-2.5 bg-primary text-white rounded-lg hover:opacity-90 transition-opacity flex items-center gap-2 whitespace-nowrap">
|
<button type="button" id="openMapPicker" class="px-4 py-2.5 bg-primary text-white rounded-lg hover:opacity-90 transition-opacity flex items-center gap-2 whitespace-nowrap">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
@@ -237,8 +237,8 @@
|
|||||||
</label>
|
</label>
|
||||||
<select id="Office_Rep" name="Office_Rep" required autocomplete="off"
|
<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">
|
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="" disabled>Select office rep</option>
|
||||||
<option value="Beth Cardoza">Beth Cardoza</option>
|
<option value="Beth Cardoza" selected>Beth Cardoza</option>
|
||||||
<option value="Steve Brewer">Steve Brewer</option>
|
<option value="Steve Brewer">Steve Brewer</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -247,8 +247,8 @@
|
|||||||
<label for="Estimator" class="block text-sm font-medium text-gray-700 mb-2">Estimator</label>
|
<label for="Estimator" class="block text-sm font-medium text-gray-700 mb-2">Estimator</label>
|
||||||
<select id="Estimator" name="Estimator" required autocomplete="off"
|
<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">
|
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="" disabled>Select estimator</option>
|
||||||
<option value="Beth Cardoza">Beth Cardoza</option>
|
<option value="Beth Cardoza" selected>Beth Cardoza</option>
|
||||||
<option value="Steve Brewer">Steve Brewer</option>
|
<option value="Steve Brewer">Steve Brewer</option>
|
||||||
<option value="Timothy Cardoza">Timothy Cardoza</option>
|
<option value="Timothy Cardoza">Timothy Cardoza</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -260,9 +260,9 @@
|
|||||||
<label for="Project_Manager" class="block text-sm font-medium text-gray-700 mb-2">Project Manager</label>
|
<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"
|
<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">
|
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="" disabled>Select project manager</option>
|
||||||
<option value="Dave">Dave</option>
|
<option value="Dave">Dave</option>
|
||||||
<option value="Rick">Rick</option>
|
<option value="Rick" selected>Rick</option>
|
||||||
<option value="Eddie">Eddie</option>
|
<option value="Eddie">Eddie</option>
|
||||||
<option value="Steve">Steve</option>
|
<option value="Steve">Steve</option>
|
||||||
<option value="Timothy">Timothy</option>
|
<option value="Timothy">Timothy</option>
|
||||||
@@ -279,7 +279,7 @@
|
|||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6 mb-6">
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6 mb-6">
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<label for="Start_Date" class="block text-sm font-medium text-gray-700 mb-2" title="Estimated date work will start.">Start Date</label>
|
<label for="Start_Date" class="block text-sm font-medium text-gray-700 mb-2" title="Estimated date work will start.">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">
|
<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" value="1/1/26">
|
||||||
<p class="text-xs text-gray-500 mt-1">Estimated date work will start.</p>
|
<p class="text-xs text-gray-500 mt-1">Estimated date work will start.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -290,10 +290,10 @@
|
|||||||
<label for="Schedule_Confidence" class="block text-sm font-medium text-gray-700 mb-2">Schedule Confidence</label>
|
<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"
|
<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">
|
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="" disabled>Select confidence level</option>
|
||||||
<option value="1 (Total Guess)">1 (Total Guess)</option>
|
<option value="1 (Total Guess)">1 (Total Guess)</option>
|
||||||
<option value="2 (Guess)">2 (Guess)</option>
|
<option value="2 (Guess)">2 (Guess)</option>
|
||||||
<option value="3 (Start Date)">3 (Start Date)</option>
|
<option value="3 (Start Date)" selected>3 (Start Date)</option>
|
||||||
<option value="4 (Client Verbal Guess)">4 (Client Verbal Guess)</option>
|
<option value="4 (Client Verbal Guess)">4 (Client Verbal Guess)</option>
|
||||||
<option value="5">5</option>
|
<option value="5">5</option>
|
||||||
<option value="6">6</option>
|
<option value="6">6</option>
|
||||||
@@ -309,13 +309,13 @@
|
|||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6 mb-6">
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6 mb-6">
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<label for="Due_Date" class="block text-sm font-medium text-gray-700 mb-2" title="Date estimate is due to client.">Due Date</label>
|
<label for="Due_Date" class="block text-sm font-medium text-gray-700 mb-2" title="Date estimate is due to client.">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">
|
<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" value="1/6/26">
|
||||||
<p class="text-xs text-gray-500 mt-1">Date estimate is due to client.</p>
|
<p class="text-xs text-gray-500 mt-1">Date estimate is due to client.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<label for="Due_Time" class="block text-sm font-medium text-gray-700 mb-2">Due Time</label>
|
<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" list="due-time-options" placeholder="Select or type" autocomplete="off"
|
<input type="text" id="Due_Time" name="Due_Time" list="due-time-options" placeholder="Select or type" autocomplete="off" value="1:00 PM"
|
||||||
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">
|
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">
|
||||||
<datalist id="due-time-options">
|
<datalist id="due-time-options">
|
||||||
<option value="5:00 AM"></option>
|
<option value="5:00 AM"></option>
|
||||||
@@ -354,7 +354,7 @@
|
|||||||
<label class="block text-sm font-medium text-gray-700 mb-2">Due Date Source</label>
|
<label class="block text-sm font-medium text-gray-700 mb-2">Due Date Source</label>
|
||||||
<div class="flex flex-wrap gap-4">
|
<div class="flex flex-wrap gap-4">
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
<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">
|
<input type="radio" id="sourceRequested" name="Due_Date_Source" value="Requested" class="w-4 h-4 text-primary focus:ring-primary" checked autocomplete="off">
|
||||||
<span class="text-sm">Requested</span>
|
<span class="text-sm">Requested</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
@@ -374,7 +374,7 @@
|
|||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<label for="Notes" class="block text-sm font-medium text-gray-700 mb-2">Any other information we need to know? (optional)</label>
|
<label for="Notes" class="block text-sm font-medium text-gray-700 mb-2">Any other information we need to know? (optional)</label>
|
||||||
<textarea id="Notes" name="Notes" rows="3" placeholder="Add any additional details, special instructions, or notes"
|
<textarea id="Notes" name="Notes" rows="3" placeholder="Add any additional details, special instructions, or notes"
|
||||||
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"></textarea>
|
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">testing notes</textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -392,12 +392,12 @@
|
|||||||
</label>
|
</label>
|
||||||
<div class="flex flex-wrap gap-4">
|
<div class="flex flex-wrap gap-4">
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
<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">
|
<input type="radio" id="taxExemptFalse" name="Tax_Exempt" value="false" required class="w-4 h-4 text-primary focus:ring-primary" checked autocomplete="off">
|
||||||
<span class="text-sm">Yes</span>
|
<span class="text-sm">No</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
<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">
|
<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">No</span>
|
<span class="text-sm">Yes</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<p id="taxReminder" class="hidden mt-3 text-sm font-semibold text-red-700">Don't forget to file the paperwork!</p>
|
<p id="taxReminder" class="hidden mt-3 text-sm font-semibold text-red-700">Don't forget to file the paperwork!</p>
|
||||||
@@ -407,13 +407,13 @@
|
|||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6 mt-6">
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6 mt-6">
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<label for="Job_Size_Guess" class="block text-sm font-medium text-gray-700 mb-2" title="Basic financial cost/value of the job. Example: $25,000.00 (just a guess).">Job Size Guess</label>
|
<label for="Job_Size_Guess" class="block text-sm font-medium text-gray-700 mb-2" title="Basic financial cost/value of the job. Example: $25,000.00 (just a guess).">Job Size Guess</label>
|
||||||
<input type="text" id="Job_Size_Guess" name="Job_Size_Guess" placeholder="$0,000.00 (guess)"
|
<input type="text" id="Job_Size_Guess" name="Job_Size_Guess" placeholder="$0,000.00 (guess)" value="1"
|
||||||
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">
|
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">
|
||||||
<p class="text-xs text-gray-500 mt-1">Enter a rough value (e.g., $25,000.00). Will auto-format in USD.</p>
|
<p class="text-xs text-gray-500 mt-1">Enter a rough value (e.g., $25,000.00). Will auto-format in USD.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<label for="Job_Size_Guess_Source" class="block text-sm font-medium text-gray-700 mb-2" title="Who provided this guess? Estimator, Rick, Project Manager, etc.">Guess Source</label>
|
<label for="Job_Size_Guess_Source" class="block text-sm font-medium text-gray-700 mb-2" title="Who provided this guess? Estimator, Rick, Project Manager, etc.">Guess Source</label>
|
||||||
<input type="text" id="Job_Size_Guess_Source" name="Job_Size_Guess_Source" placeholder="Estimator, Rick, PM, etc."
|
<input type="text" id="Job_Size_Guess_Source" name="Job_Size_Guess_Source" placeholder="Estimator, Rick, PM, etc." value="me"
|
||||||
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">
|
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>
|
</div>
|
||||||
@@ -592,14 +592,16 @@
|
|||||||
document.getElementById('Job_Name').addEventListener('blur', (e) => {
|
document.getElementById('Job_Name').addEventListener('blur', (e) => {
|
||||||
const jobName = (e.target.value || '').toString().trim();
|
const jobName = (e.target.value || '').toString().trim();
|
||||||
const invalidLiterals = ['n/a', 'na', 'n.a.', 'n\\a', 'none'];
|
const invalidLiterals = ['n/a', 'na', 'n.a.', 'n\\a', 'none'];
|
||||||
|
const allowedSpecials = ['unk', 'unknown'];
|
||||||
const onlySymbols = /^[^A-Za-z0-9]+$/.test(jobName);
|
const onlySymbols = /^[^A-Za-z0-9]+$/.test(jobName);
|
||||||
const hasLetters = /[A-Za-z]/.test(jobName);
|
const hasLetters = /[A-Za-z]/.test(jobName);
|
||||||
const isInvalidLiteral = invalidLiterals.includes(jobName.toLowerCase());
|
const isInvalidLiteral = invalidLiterals.includes(jobName.toLowerCase());
|
||||||
|
const isAllowedSpecial = allowedSpecials.includes(jobName.toLowerCase());
|
||||||
const isLongEnough = jobName.length >= 4;
|
const isLongEnough = jobName.length >= 4;
|
||||||
|
|
||||||
if (jobName.length > 0 && (!isLongEnough || !hasLetters || onlySymbols || isInvalidLiteral)) {
|
if (jobName.length > 0 && ((!isLongEnough || !hasLetters || onlySymbols || isInvalidLiteral) && !isAllowedSpecial)) {
|
||||||
e.target.classList.add('border-red-500', 'bg-red-50');
|
e.target.classList.add('border-red-500', 'bg-red-50');
|
||||||
e.target.title = 'Job name must be at least 4 characters with letters (no symbols-only, no N/A).';
|
e.target.title = 'Job name must be at least 4 characters with letters (no symbols-only, no N/A, but UNK/UNKNOWN allowed).';
|
||||||
} else {
|
} else {
|
||||||
e.target.classList.remove('border-red-500', 'bg-red-50');
|
e.target.classList.remove('border-red-500', 'bg-red-50');
|
||||||
e.target.title = '';
|
e.target.title = '';
|
||||||
@@ -813,16 +815,18 @@
|
|||||||
data.Job_Address = addr;
|
data.Job_Address = addr;
|
||||||
data.Address_Notes = addrNotes;
|
data.Address_Notes = addrNotes;
|
||||||
|
|
||||||
// Job name validation: must be at least 4 characters, contain letters, not symbols-only, not "n/a"
|
// Job name validation: must be at least 4 characters, contain letters, not symbols-only, not "n/a" (but allow "unk"/"unknown")
|
||||||
const jobName = (data.Job_Name || '').toString().trim();
|
const jobName = (data.Job_Name || '').toString().trim();
|
||||||
const invalidLiterals = ['n/a', 'na', 'n.a.', 'n\\a', 'none'];
|
const invalidLiterals = ['n/a', 'na', 'n.a.', 'n\\a', 'none'];
|
||||||
|
const allowedSpecials = ['unk', 'unknown'];
|
||||||
const onlySymbols = /^[^A-Za-z0-9]+$/.test(jobName);
|
const onlySymbols = /^[^A-Za-z0-9]+$/.test(jobName);
|
||||||
const hasLetters = /[A-Za-z]/.test(jobName);
|
const hasLetters = /[A-Za-z]/.test(jobName);
|
||||||
const isInvalidLiteral = invalidLiterals.includes(jobName.toLowerCase());
|
const isInvalidLiteral = invalidLiterals.includes(jobName.toLowerCase());
|
||||||
|
const isAllowedSpecial = allowedSpecials.includes(jobName.toLowerCase());
|
||||||
const isLongEnough = jobName.length >= 4;
|
const isLongEnough = jobName.length >= 4;
|
||||||
if (!isLongEnough || !hasLetters || onlySymbols || isInvalidLiteral) {
|
if ((!isLongEnough || !hasLetters || onlySymbols || isInvalidLiteral) && !isAllowedSpecial) {
|
||||||
message.className = 'p-4 rounded-lg font-medium bg-red-50 text-red-700 border border-red-200';
|
message.className = 'p-4 rounded-lg font-medium bg-red-50 text-red-700 border border-red-200';
|
||||||
message.textContent = 'Job name must be at least 4 characters with letters (no symbols-only, no N/A).';
|
message.textContent = 'Job name must be at least 4 characters with letters (no symbols-only, no N/A, but UNK/UNKNOWN allowed).';
|
||||||
message.classList.remove('hidden');
|
message.classList.remove('hidden');
|
||||||
submitBtn.disabled = false;
|
submitBtn.disabled = false;
|
||||||
submitBtn.textContent = 'Submit Job';
|
submitBtn.textContent = 'Submit Job';
|
||||||
@@ -836,7 +840,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/submit', {
|
const response = await fetch('http://localhost:3020/api/submit', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -951,9 +955,35 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Version Info -->
|
<!-- Version Info -->
|
||||||
<div class="fixed bottom-4 right-4 text-xs text-gray-400">
|
<div class="fixed bottom-4 right-4 text-xs text-gray-400 flex items-center gap-2">
|
||||||
v1.0.0-beta3
|
<span id="env-badge" class="hidden px-1.5 py-0.5 rounded-sm font-semibold uppercase tracking-wider text-[10px]"></span>
|
||||||
|
<span id="version-text">v1.0.0-beta3</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Fetch environment info
|
||||||
|
async function updateEnvBadge() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/config');
|
||||||
|
if (!response.ok) return;
|
||||||
|
const data = await response.json();
|
||||||
|
const badge = document.getElementById('env-badge');
|
||||||
|
|
||||||
|
if (data.mode === 'PROD') {
|
||||||
|
badge.textContent = 'PROD';
|
||||||
|
badge.className = 'px-1.5 py-0.5 rounded-sm font-bold uppercase tracking-wider text-[10px] bg-green-600 text-white';
|
||||||
|
badge.classList.remove('hidden');
|
||||||
|
} else if (data.mode === 'TEST') {
|
||||||
|
badge.textContent = 'TEST';
|
||||||
|
badge.className = 'px-1.5 py-0.5 rounded-sm font-bold uppercase tracking-wider text-[10px] bg-amber-500 text-white';
|
||||||
|
badge.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch config:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateEnvBadge();
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
|
||||||
|
import { Client } from '@microsoft/microsoft-graph-client';
|
||||||
|
|
||||||
|
// Bun-native env loader
|
||||||
|
async function loadEnv(filePath: string) {
|
||||||
|
const file = Bun.file(filePath);
|
||||||
|
if (!(await file.exists())) {
|
||||||
|
console.warn(`⚠ Env file not found: ${filePath}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const text = await file.text();
|
||||||
|
for (const line of text.split('\n')) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||||
|
const eqIndex = trimmed.indexOf('=');
|
||||||
|
if (eqIndex === -1) continue;
|
||||||
|
const key = trimmed.slice(0, eqIndex).trim();
|
||||||
|
let value = trimmed.slice(eqIndex + 1).trim();
|
||||||
|
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
||||||
|
value = value.slice(1, -1);
|
||||||
|
}
|
||||||
|
process.env[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadEnv('/home/admin/secrets/.env');
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function inspectTables() {
|
||||||
|
const token = await getGraphToken();
|
||||||
|
const client = Client.init({
|
||||||
|
authProvider: (done) => {
|
||||||
|
done(null, token);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const driveId = process.env.DRIVE_ID;
|
||||||
|
const itemId = process.env.ITEM_ID;
|
||||||
|
const baseUrl = `/drives/${driveId}/items/${itemId}/workbook/tables`;
|
||||||
|
|
||||||
|
console.log(`Inspecting tables in file: ${itemId}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await client.api(baseUrl).get();
|
||||||
|
const tables = res.value;
|
||||||
|
|
||||||
|
for (const table of tables) {
|
||||||
|
console.log(`\nTable: ${table.name}`);
|
||||||
|
const cols = await client.api(`${baseUrl}/${table.name}/columns`).get();
|
||||||
|
console.log(`- Column count: ${cols.value.length}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inspectTables();
|
||||||
@@ -9,7 +9,6 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@microsoft/microsoft-graph-client": "^3.0.7",
|
"@microsoft/microsoft-graph-client": "^3.0.7",
|
||||||
"dotenv": "^17.2.3",
|
|
||||||
"hono": "^4.10.8",
|
"hono": "^4.10.8",
|
||||||
"pocketbase": "^0.26.5"
|
"pocketbase": "^0.26.5"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { config } from 'dotenv';
|
import { config } from 'dotenv';
|
||||||
|
|
||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { serveStatic } from 'hono/bun';
|
import { serveStatic } from 'hono/bun';
|
||||||
import { cors } from 'hono/cors';
|
import { cors } from 'hono/cors';
|
||||||
@@ -13,7 +14,50 @@ import { processNewJobRecord } from './extracted-graph-logic/post-record-integra
|
|||||||
// Load secrets from absolute path (not in project directory)
|
// Load secrets from absolute path (not in project directory)
|
||||||
config({ path: '/home/admin/secrets/.env' });
|
config({ path: '/home/admin/secrets/.env' });
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ENVIRONMENT SETTINGS (Toggle between TEST and PROD here)
|
||||||
|
// ============================================================================
|
||||||
|
const MODE = 'TEST'; // CHANGE THIS TO 'PROD' TO GO LIVE
|
||||||
|
|
||||||
|
const CONFIG = {
|
||||||
|
TEST: {
|
||||||
|
pbCollection: 'Job_Info_TestEnv',
|
||||||
|
excelTable: 'Test_Table',
|
||||||
|
plannerGroupId: 'e45f2188-dc65-42d6-b7b0-d0a873e07472',
|
||||||
|
plannerPlanId: '87nMEU5OqUqro1xLjY58-2UAD4uj',
|
||||||
|
plannerBucketId: '9VuH0OQEa0GQsCHtPgUCFmUACHVF',
|
||||||
|
description: '🧪 TEST ENVIRONMENT'
|
||||||
|
},
|
||||||
|
PROD: {
|
||||||
|
pbCollection: process.env.PB_COLLECTION || 'Job_Info_Prod',
|
||||||
|
excelTable: process.env.EXCEL_TABLE_NAME || 'Job_List',
|
||||||
|
plannerGroupId: process.env.PLANNER_GROUP_ID || 'e45f2188-dc65-42d6-b7b0-d0a873e07472',
|
||||||
|
plannerPlanId: process.env.PLANNER_PLAN_ID || '87nMEU5OqUqro1xLjY58-2UAD4uj',
|
||||||
|
plannerBucketId: process.env.PLANNER_BUCKET_ID || '9VuH0OQEa0GQsCHtPgUCFmUACHVF',
|
||||||
|
description: '🚀 PRODUCTION ENVIRONMENT'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeConfig = MODE === 'PROD' ? CONFIG.PROD : CONFIG.TEST;
|
||||||
|
console.log('\n' + '*'.repeat(60));
|
||||||
|
console.log(' RUNNING IN: ' + activeConfig.description);
|
||||||
|
console.log(' PocketBase: ' + activeConfig.pbCollection);
|
||||||
|
console.log(' Excel Table: ' + activeConfig.excelTable);
|
||||||
|
console.log(' Planner Plan: ' + activeConfig.plannerPlanId);
|
||||||
|
console.log(' Planner Bucket: ' + activeConfig.plannerBucketId);
|
||||||
|
console.log(' Planner Plan: ' + activeConfig.plannerPlanId);
|
||||||
|
console.log(' Planner Bucket: ' + activeConfig.plannerBucketId);
|
||||||
|
console.log('*'.repeat(60) + '\n');
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
// Environment config endpoint
|
||||||
|
app.get('/api/config', (c) => {
|
||||||
|
return c.json({
|
||||||
|
mode: MODE,
|
||||||
|
description: activeConfig.description
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// ERROR LOGGING
|
// ERROR LOGGING
|
||||||
@@ -249,13 +293,7 @@ let tableCache: {
|
|||||||
// Resolve the Excel table name from env; prefers EXCEL_TABLE_NAME (just the table's name),
|
// 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 Test_Table.
|
// and falls back to parsing EXCEL_TABLE if provided as a full path, else default to Test_Table.
|
||||||
function resolveTableName(): string {
|
function resolveTableName(): string {
|
||||||
const nameFromSimpleEnv = process.env.EXCEL_TABLE_NAME?.trim();
|
return activeConfig.excelTable;
|
||||||
if (nameFromSimpleEnv) return nameFromSimpleEnv;
|
|
||||||
|
|
||||||
const nameFromPath = process.env.EXCEL_TABLE?.split('/tables/').pop()?.trim();
|
|
||||||
if (nameFromPath) return nameFromPath;
|
|
||||||
|
|
||||||
return 'Test_Table';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -409,12 +447,12 @@ async function createPlannerTask({
|
|||||||
const title = `Disposition New Job Folder for ${jobNumber} ${jobFullName}`;
|
const title = `Disposition New Job Folder for ${jobNumber} ${jobFullName}`;
|
||||||
|
|
||||||
// Create the Planner task
|
// Create the Planner task
|
||||||
const groupId = process.env.PLANNER_GROUP_ID;
|
const groupId = activeConfig.plannerGroupId;
|
||||||
const planId = process.env.PLANNER_PLAN_ID;
|
const planId = activeConfig.plannerPlanId;
|
||||||
const bucketId = process.env.PLANNER_BUCKET_ID;
|
const bucketId = activeConfig.plannerBucketId;
|
||||||
|
|
||||||
if (!groupId || !planId || !bucketId) {
|
if (!groupId || !planId || !bucketId) {
|
||||||
throw new Error('Missing Planner configuration: PLANNER_GROUP_ID, PLANNER_PLAN_ID, or PLANNER_BUCKET_ID');
|
throw new Error('Missing Planner configuration in activeConfig');
|
||||||
}
|
}
|
||||||
|
|
||||||
const newTask = await client.api('/planner/tasks').post({
|
const newTask = await client.api('/planner/tasks').post({
|
||||||
@@ -430,7 +468,24 @@ async function createPlannerTask({
|
|||||||
...(dueDateObj ? { dueDateTime: dueDateObj } : {}),
|
...(dueDateObj ? { dueDateTime: dueDateObj } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set the Notes/Description via task details (match the working test script)
|
console.log(`✓ Planner task created in Plan ID: ${planId}`);
|
||||||
|
|
||||||
|
console.log(`✓ Planner task created in Plan ID: ${planId}`);
|
||||||
|
|
||||||
|
// Build checklist object with unique IDs
|
||||||
|
const checklist: Record<string, { '@odata.type': string; title: string; isChecked: boolean; orderHint: string }> = {};
|
||||||
|
const checklistItems = ['Create Voxer Chat', 'Create Quickbooks', 'Upload plans'];
|
||||||
|
checklistItems.forEach((item, index) => {
|
||||||
|
const itemId = `checklist_${Date.now()}_${index}`;
|
||||||
|
checklist[itemId] = {
|
||||||
|
'@odata.type': 'microsoft.graph.plannerChecklistItem',
|
||||||
|
title: item,
|
||||||
|
isChecked: false,
|
||||||
|
orderHint: ` ${index}!`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set the Notes/Description AND checklist via task details
|
||||||
const taskDetails = await client.api(`/planner/tasks/${newTask.id}/details`).get();
|
const taskDetails = await client.api(`/planner/tasks/${newTask.id}/details`).get();
|
||||||
const detailsEtag = taskDetails['@odata.etag'];
|
const detailsEtag = taskDetails['@odata.etag'];
|
||||||
console.log(`[Planner] Task created ${newTask.id}, fetched details ETag: ${detailsEtag}`);
|
console.log(`[Planner] Task created ${newTask.id}, fetched details ETag: ${detailsEtag}`);
|
||||||
@@ -439,8 +494,22 @@ async function createPlannerTask({
|
|||||||
.header('If-Match', detailsEtag)
|
.header('If-Match', detailsEtag)
|
||||||
.patch({
|
.patch({
|
||||||
description: 'Move the new folder to the correct Job Pages folder.',
|
description: 'Move the new folder to the correct Job Pages folder.',
|
||||||
|
checklist: checklist,
|
||||||
});
|
});
|
||||||
console.log(`[Planner] Successfully patched description`);
|
console.log(`[Planner] Successfully patched description and added ${checklistItems.length} checklist items`);
|
||||||
|
|
||||||
|
// Add "plum" label (category6) to task
|
||||||
|
const taskForLabel = await client.api(`/planner/tasks/${newTask.id}`).get();
|
||||||
|
const taskEtag = taskForLabel['@odata.etag'];
|
||||||
|
await client
|
||||||
|
.api(`/planner/tasks/${newTask.id}`)
|
||||||
|
.header('If-Match', taskEtag)
|
||||||
|
.patch({
|
||||||
|
appliedCategories: {
|
||||||
|
category6: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(`[Planner] Added plum label to task`);
|
||||||
|
|
||||||
console.log(`✓ Planner task created: "${title}" assigned to ${assigneeName}`);
|
console.log(`✓ Planner task created: "${title}" assigned to ${assigneeName}`);
|
||||||
return { success: true, taskId: newTask.id, title };
|
return { success: true, taskId: newTask.id, title };
|
||||||
@@ -648,6 +717,7 @@ async function updateExcelFolderLink(pbId: string, link: string) {
|
|||||||
|
|
||||||
// API endpoint to submit job
|
// API endpoint to submit job
|
||||||
app.post('/api/submit', async (c) => {
|
app.post('/api/submit', async (c) => {
|
||||||
|
const pbCollection = activeConfig.pbCollection;
|
||||||
let data: any;
|
let data: any;
|
||||||
try {
|
try {
|
||||||
data = await c.req.json();
|
data = await c.req.json();
|
||||||
@@ -722,8 +792,8 @@ app.post('/api/submit', async (c) => {
|
|||||||
const onlySymbols = /^[^A-Za-z0-9]+$/.test(jobName);
|
const onlySymbols = /^[^A-Za-z0-9]+$/.test(jobName);
|
||||||
const hasLetters = /[A-Za-z]/.test(jobName);
|
const hasLetters = /[A-Za-z]/.test(jobName);
|
||||||
const isInvalidLiteral = invalidLiterals.includes(jobName.toLowerCase());
|
const isInvalidLiteral = invalidLiterals.includes(jobName.toLowerCase());
|
||||||
const isLongEnough = jobName.length >= 4;
|
const isLongEnough = jobName.length >= 4 || jobName.toUpperCase() === "UNK";
|
||||||
if (!isLongEnough || !hasLetters || onlySymbols || isInvalidLiteral) {
|
if (!isLongEnough || (!hasLetters && jobName.toUpperCase() !== "UNK") || onlySymbols || isInvalidLiteral) {
|
||||||
return c.json({
|
return c.json({
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Job name must be at least 4 characters with letters (no symbols-only, no N/A).'
|
message: 'Job name must be at least 4 characters with letters (no symbols-only, no N/A).'
|
||||||
@@ -742,7 +812,7 @@ app.post('/api/submit', async (c) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get all existing job numbers and find the max
|
// Get all existing job numbers and find the max
|
||||||
const existingJobs = await pb.collection(process.env.PB_COLLECTION!).getFullList({
|
const existingJobs = await pb.collection(activeConfig.pbCollection).getFullList({
|
||||||
fields: 'Job_Number',
|
fields: 'Job_Number',
|
||||||
sort: '-created',
|
sort: '-created',
|
||||||
});
|
});
|
||||||
@@ -763,7 +833,7 @@ app.post('/api/submit', async (c) => {
|
|||||||
delete data.pbToken;
|
delete data.pbToken;
|
||||||
|
|
||||||
// Create the record in PocketBase as the authenticated user
|
// Create the record in PocketBase as the authenticated user
|
||||||
const record = await pb.collection(process.env.PB_COLLECTION!).create(data);
|
const record = await pb.collection(activeConfig.pbCollection).create(data);
|
||||||
console.log(`✓ Created PocketBase record with Job_Number: ${newJobNumber}, ID: ${record.id}`);
|
console.log(`✓ Created PocketBase record with Job_Number: ${newJobNumber}, ID: ${record.id}`);
|
||||||
|
|
||||||
// Persist calculated fields to PocketBase immediately after creation
|
// Persist calculated fields to PocketBase immediately after creation
|
||||||
@@ -773,7 +843,7 @@ app.post('/api/submit', async (c) => {
|
|||||||
const voxerLinkVal = calculateVoxerLink(record);
|
const voxerLinkVal = calculateVoxerLink(record);
|
||||||
const jobCodesVal = calculateJobCodes(record);
|
const jobCodesVal = calculateJobCodes(record);
|
||||||
|
|
||||||
await pb.collection(process.env.PB_COLLECTION!).update(record.id, {
|
await pb.collection(activeConfig.pbCollection).update(record.id, {
|
||||||
Job_Full_Name: fullName,
|
Job_Full_Name: fullName,
|
||||||
Job_QB_Link: qbLink,
|
Job_QB_Link: qbLink,
|
||||||
Voxer_Link: voxerLinkVal,
|
Voxer_Link: voxerLinkVal,
|
||||||
@@ -843,7 +913,7 @@ app.post('/api/submit', async (c) => {
|
|||||||
try {
|
try {
|
||||||
const dueStatic = calculateDueDateCounter(record);
|
const dueStatic = calculateDueDateCounter(record);
|
||||||
const activeStatic = calculateActive(record);
|
const activeStatic = calculateActive(record);
|
||||||
await pb.collection(process.env.PB_COLLECTION!).update(record.id, {
|
await pb.collection(activeConfig.pbCollection).update(record.id, {
|
||||||
Job_Folder_Link: jobFolderLink || '',
|
Job_Folder_Link: jobFolderLink || '',
|
||||||
Due_Date_Counter: dueStatic,
|
Due_Date_Counter: dueStatic,
|
||||||
Active: activeStatic
|
Active: activeStatic
|
||||||
@@ -1157,6 +1227,9 @@ app.post('/api/test-teams-webhook', async (c) => {
|
|||||||
return c.json({ success: ok, status: resp.status, body: text });
|
return c.json({ success: ok, status: resp.status, body: text });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Serve static files from frontend directory
|
||||||
|
app.use('/*', serveStatic({ root: './frontend' }));
|
||||||
|
|
||||||
const PORT = Number(process.env.PORT || 3020);
|
const PORT = Number(process.env.PORT || 3020);
|
||||||
|
|
||||||
console.log(`Job Creation with Excel Sync server running at http://localhost:${PORT}`);
|
console.log(`Job Creation with Excel Sync server running at http://localhost:${PORT}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user