Spaces:
Sleeping
Sleeping
Commit Β·
45e997f
0
Parent(s):
PotholeIQ: initial deployable build (Box + Supabase, HF Spaces Docker)
Browse filesThis view is limited to 50 files because it contains too many changes. Β See raw diff
- .dockerignore +10 -0
- .gitattributes +9 -0
- .gitignore +29 -0
- BACKEND_SETUP.md +95 -0
- CLIENT_DEMO_QUESTIONS.md +73 -0
- Dockerfile +43 -0
- LICENSE +31 -0
- Pothole Case Review Sheet (DocGen).docx +3 -0
- Pothole Closeout Job Sheet (DocGen Template).docx +3 -0
- Pothole Closeout Job Sheet (DocGen).docx +3 -0
- Pothole Closeout Job Sheet SAMPLE.docx +3 -0
- Pothole Repair Work Order (DocGen).docx +3 -0
- Pothole Repair Work Order.docx +3 -0
- Pothole Work Order (DocGen Template).docx +3 -0
- Pothole Work Order SAMPLE.docx +3 -0
- README.md +67 -0
- START_SERVER.bat +60 -0
- WORKFLOW_STORAGE_ONLY_GUIDE.md +260 -0
- WORKFLOW_V2_GUIDE.md +434 -0
- add-supervisor.js +38 -0
- ai/detect_pothole.py +85 -0
- ai/gen_closeout.py +149 -0
- ai/gen_doc.py +186 -0
- ai/gen_workorder.py +343 -0
- ai/pothole-yolov8.pt +3 -0
- ai/pothole_server.py +129 -0
- authority-routing.json +13 -0
- backend-api.js +293 -0
- box-build/BOX_APP_CONSOLE.md +123 -0
- box-build/BOX_APP_DASHBOARD.md +69 -0
- box-build/BOX_AUTOMATE_WORKFLOW.md +145 -0
- box-build/REVISED_WORKFLOW.md +149 -0
- box-build/WORKFLOW_ONE_BUILD.md +86 -0
- box-build/WORKFLOW_TWO_BUILD.md +65 -0
- box-build/add-breakdown-fields.sh +22 -0
- box-build/metadata-template.json +93 -0
- box-build/relay-flow-diagram.html +277 -0
- box-build/revised-workflow-diagram.html +206 -0
- box-build/seed-demo-cases.js +116 -0
- box-build/wf1-revised-diagram.html +119 -0
- box-folders.js +656 -0
- box-service.js +1318 -0
- box-upload.js +200 -0
- box-workflow.js +189 -0
- case-report.html +303 -0
- command-center.html +391 -0
- config-store.js +223 -0
- crew-closeout.html +602 -0
- dashcam-demo.html +931 -0
- dashcam-demo.js +989 -0
.dockerignore
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
node_modules
|
| 2 |
+
data
|
| 3 |
+
.git
|
| 4 |
+
*.log
|
| 5 |
+
server.pid
|
| 6 |
+
__pycache__
|
| 7 |
+
*.pyc
|
| 8 |
+
.DS_Store
|
| 9 |
+
Dockerfile
|
| 10 |
+
.dockerignore
|
.gitattributes
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# HuggingFace requires ALL binary files to be stored via Git LFS.
|
| 2 |
+
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.jpg filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.jpeg filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.png filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.gif filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.webp filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
*.docx filter=lfs diff=lfs merge=lfs -text
|
| 9 |
+
*.pdf filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Dependencies
|
| 2 |
+
node_modules/
|
| 3 |
+
|
| 4 |
+
# Local data + secrets β data/config.json holds Box credentials locally.
|
| 5 |
+
# On hosted deployments (HF Spaces) all of this comes from env-var secrets.
|
| 6 |
+
data/
|
| 7 |
+
start.bat
|
| 8 |
+
start.local.bat
|
| 9 |
+
.env
|
| 10 |
+
.env.*
|
| 11 |
+
|
| 12 |
+
# Logs / runtime
|
| 13 |
+
*.log
|
| 14 |
+
server.pid
|
| 15 |
+
*.stderr.log
|
| 16 |
+
*.stdout.log
|
| 17 |
+
|
| 18 |
+
# Python
|
| 19 |
+
__pycache__/
|
| 20 |
+
*.pyc
|
| 21 |
+
|
| 22 |
+
# OS / editor
|
| 23 |
+
.DS_Store
|
| 24 |
+
Thumbs.db
|
| 25 |
+
|
| 26 |
+
# Scratch
|
| 27 |
+
*.tmp
|
| 28 |
+
_*.png
|
| 29 |
+
_*.html
|
BACKEND_SETUP.md
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Backend Setup
|
| 2 |
+
|
| 3 |
+
## What This Backend Does
|
| 4 |
+
|
| 5 |
+
- Serves the app and API from one Node server
|
| 6 |
+
- Stores shared cases and workflow state in `data/cases.json`
|
| 7 |
+
- Accepts frontend case syncs and workflow actions
|
| 8 |
+
- Stores artifacts in Box while the backend/database stays the source of truth
|
| 9 |
+
- Optionally accepts legacy Box Automate updates at `POST /api/box/automate-update`
|
| 10 |
+
|
| 11 |
+
## Start It
|
| 12 |
+
|
| 13 |
+
Run:
|
| 14 |
+
|
| 15 |
+
```bat
|
| 16 |
+
START_SERVER.bat
|
| 17 |
+
```
|
| 18 |
+
|
| 19 |
+
Preferred mode is Node.js. The server runs at:
|
| 20 |
+
|
| 21 |
+
```txt
|
| 22 |
+
http://localhost:3030
|
| 23 |
+
```
|
| 24 |
+
|
| 25 |
+
Health check:
|
| 26 |
+
|
| 27 |
+
```txt
|
| 28 |
+
GET http://localhost:3030/api/health
|
| 29 |
+
```
|
| 30 |
+
|
| 31 |
+
## Optional Secret For Box Automate
|
| 32 |
+
|
| 33 |
+
Set this before starting the server if you want Box callback protection:
|
| 34 |
+
|
| 35 |
+
```powershell
|
| 36 |
+
$env:BOX_AUTOMATE_SECRET="replace-with-a-secret"
|
| 37 |
+
node server.js
|
| 38 |
+
```
|
| 39 |
+
|
| 40 |
+
Box Automate should then send the same value in:
|
| 41 |
+
|
| 42 |
+
```txt
|
| 43 |
+
X-Box-Automate-Secret
|
| 44 |
+
```
|
| 45 |
+
|
| 46 |
+
or as:
|
| 47 |
+
|
| 48 |
+
```txt
|
| 49 |
+
?secret=replace-with-a-secret
|
| 50 |
+
```
|
| 51 |
+
|
| 52 |
+
## Main API Endpoints
|
| 53 |
+
|
| 54 |
+
- `GET /api/health`
|
| 55 |
+
- `GET /api/cases`
|
| 56 |
+
- `GET /api/cases/:caseId`
|
| 57 |
+
- `POST /api/cases/upsert`
|
| 58 |
+
- `POST /api/cases/bulk-upsert`
|
| 59 |
+
- `POST /api/cases/:caseId/patch`
|
| 60 |
+
- `POST /api/workflow/supervisor-decision`
|
| 61 |
+
- `POST /api/workflow/dispatch-plan`
|
| 62 |
+
- `POST /api/workflow/crew-closeout`
|
| 63 |
+
- `POST /api/workflow/resolve-case`
|
| 64 |
+
- `POST /api/box/automate-update`
|
| 65 |
+
|
| 66 |
+
## Box Automate Payload Shape
|
| 67 |
+
|
| 68 |
+
Send JSON like this from Box:
|
| 69 |
+
|
| 70 |
+
```json
|
| 71 |
+
{
|
| 72 |
+
"caseId": "PHL-20260530-ABCDE1",
|
| 73 |
+
"workflowStage": "crew_dispatch",
|
| 74 |
+
"workflowStatus": "Assigned",
|
| 75 |
+
"workflowPriority": "high",
|
| 76 |
+
"workflowNextAction": "Dispatch crew",
|
| 77 |
+
"folderId": "1234567890",
|
| 78 |
+
"boxPath": "Potholes/Ward-29/Case-PHL-20260530-ABCDE1",
|
| 79 |
+
"assignedTo": "dispatch@city.gov",
|
| 80 |
+
"resolutionNotes": ""
|
| 81 |
+
}
|
| 82 |
+
```
|
| 83 |
+
|
| 84 |
+
## What You Still Need From Box
|
| 85 |
+
|
| 86 |
+
- A Box folder ID for the app root
|
| 87 |
+
- Supervisor email
|
| 88 |
+
- Dispatch or crew email/group
|
| 89 |
+
- A public URL for this backend when you want Box to call it from the cloud
|
| 90 |
+
|
| 91 |
+
For local testing with Box cloud callbacks, use a tunnel such as `ngrok` or `cloudflared`, then point Box Automate `HTTPS Request` to:
|
| 92 |
+
|
| 93 |
+
```txt
|
| 94 |
+
https://your-public-url/api/box/automate-update
|
| 95 |
+
```
|
CLIENT_DEMO_QUESTIONS.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# PotholeIQ β Client Demo: Questions & Inputs to Collect
|
| 2 |
+
|
| 3 |
+
Use this during the session. β = blocker (get it or we can't deploy). π₯ = a file/credential to collect. β
= a decision to confirm.
|
| 4 |
+
|
| 5 |
+
---
|
| 6 |
+
|
| 7 |
+
## 1. Box Access & Authentication β (blocks everything β token expires every 60 min)
|
| 8 |
+
- β Who is your Box **enterprise admin**? Can they **authorize a custom app**? (needed for permanent auth)
|
| 9 |
+
- β
Auth method: **CCG** (admin authorizes once β permanent, recommended) or **OAuth** (one user login β 60-day)?
|
| 10 |
+
- π₯ Box **Enterprise ID** + the **folder** to use (intake folder, case-root folder).
|
| 11 |
+
- β
Which Box features are **enabled/licensed**: Relay (Automate), **Sign**, **Doc Gen**, **Forms**, **Apps** (custom dashboards)?
|
| 12 |
+
- π₯ A **service/bot Box account** to run the integration as (so it's not tied to a person)?
|
| 13 |
+
|
| 14 |
+
## 2. City, GIS & Department
|
| 15 |
+
- β
Confirm **city / jurisdiction** (demo = Philadelphia).
|
| 16 |
+
- βπ₯ GIS boundary files for **districts** and **maintenance/service zones** β not just wards. Format? (GeoJSON / Shapefile). *(Today we only have ward polygons and derive the rest.)*
|
| 17 |
+
- π₯ Official **ward β district β maintenance-zone** mapping, if it exists.
|
| 18 |
+
- π₯ **Department name + logo / letterhead** for documents (work order, review sheet, closeout).
|
| 19 |
+
|
| 20 |
+
## 3. Roles, People & Crew Dispatch
|
| 21 |
+
- π₯ **Reviewers / district engineers / supervisors** β names + **Box account emails**.
|
| 22 |
+
- β
Who **approves** a case, and who **dispatches** the crew?
|
| 23 |
+
- β
How is the **crew chosen per pothole**? (we built: reviewer types the crew email at approval β confirm)
|
| 24 |
+
- β
Are **crews Box users**, or external (email only β affects Sign vs notifications)?
|
| 25 |
+
- β
Who gets **notified** at each stage (new case / approved / dispatched / resolved)?
|
| 26 |
+
|
| 27 |
+
## 4. Metadata Template & Fields
|
| 28 |
+
- β
Confirm the **case fields + dropdown values**. Known quirks to fix or keep:
|
| 29 |
+
- `weatherRisk` = **Low / Medium / Large** (no "High" β typo?)
|
| 30 |
+
- `duplicateStatus` = `new / "duplicate " / pending` (**trailing space** on "duplicate")
|
| 31 |
+
- `status` includes **`In progress`** (odd capitalization)
|
| 32 |
+
- β
Any **additional fields** they track today (asset ID, 311 reference, etc.)?
|
| 33 |
+
|
| 34 |
+
## 5. Severity Scoring, SLA & Cost
|
| 35 |
+
- β
Agree with the **severity factors** (prior reports, traffic/road class, damage/size, weather) and **weights**?
|
| 36 |
+
- β
**SLA targets** by severity (e.g. Critical = N days)? β drives "target completion" + days-to-resolve.
|
| 37 |
+
- π₯ **Actual repair-cost** figures by size? (we use researched estimates: Small ~$90β180, Medium ~$250β450, Large ~$450β800).
|
| 38 |
+
|
| 39 |
+
## 6. Forms & Documents
|
| 40 |
+
- β
Confirm **citizen intake** fields and **crew closeout** form fields (current closeout: Case ID, Repair completed?, Method, Materials, Labor hours, After photo, Crew name, Crew contact).
|
| 41 |
+
- β
Confirm **Work Order / Review Sheet / Closeout** layouts β fields to add/remove?
|
| 42 |
+
- β
**Box Sign** β how many signatures and who? (we set crew + supervisor).
|
| 43 |
+
|
| 44 |
+
## 7. Duplicates, Data & Volume
|
| 45 |
+
- β
**Duplicate radius** (we use 80 m) β confirm.
|
| 46 |
+
- β
OK with an **external DB** (Supabase / PostGIS) for fast geo/duplicate search, or **all-in-Box** only?
|
| 47 |
+
- π₯ **Historical case data** to import?
|
| 48 |
+
- β
Expected **volume** (reports/day) β for sizing.
|
| 49 |
+
|
| 50 |
+
## 8. AI / Detection
|
| 51 |
+
- β
Is the **open-source YOLO** pothole detector + size classification acceptable, or a specific accuracy bar / different model?
|
| 52 |
+
- β
Size thresholds (Small/Medium/Large) β tune to their definition?
|
| 53 |
+
|
| 54 |
+
## 9. Deployment & Infra
|
| 55 |
+
- β Where will the **backend + portal + dashboard** be hosted? **Domain/URL**?
|
| 56 |
+
- β **HTTPS + public endpoint** for Box callbacks/forms β who provides it?
|
| 57 |
+
- β
Weather: use **our OpenWeather key** or **theirs** (their account/limits)?
|
| 58 |
+
- β
Who can access the **supervisor dashboard** (login list)?
|
| 59 |
+
|
| 60 |
+
## 10. The Big Decision β Architecture
|
| 61 |
+
- β
Which model do they want?
|
| 62 |
+
1. **Fully inside Box** (Relay + Sign + Doc Gen + Apps)
|
| 63 |
+
2. **Box as storage only** (our portal + backend do the logic)
|
| 64 |
+
3. **Hybrid (recommended)** β backend does geo/scoring/duplicate/weather + AI; Box does content, workflow, docs, sign, dashboard, governance
|
| 65 |
+
|
| 66 |
+
---
|
| 67 |
+
|
| 68 |
+
### Fast "must-leave-with" list (the 5 that unblock everything)
|
| 69 |
+
1. β Admin to authorize the app (permanent auth) β or schedule it.
|
| 70 |
+
2. β District + maintenance-zone GIS data (or confirm we derive from ward).
|
| 71 |
+
3. β Hosting + HTTPS endpoint owner.
|
| 72 |
+
4. π₯ Reviewer/supervisor Box emails + department name/logo.
|
| 73 |
+
5. β
Architecture option (1 / 2 / 3).
|
Dockerfile
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# PotholeIQ β Node backend + Python YOLO classifier + Chromium PDF rendering.
|
| 2 |
+
# Built for HuggingFace Spaces (Docker SDK, app_port 7860) but runs anywhere.
|
| 3 |
+
FROM node:20-slim
|
| 4 |
+
|
| 5 |
+
# System deps:
|
| 6 |
+
# - python3 + pip -> YOLOv8 classifier (ai/pothole_server.py) + docx generation
|
| 7 |
+
# - chromium -> before/after PDF rendering (htmlToPdf)
|
| 8 |
+
# - libgl1 / libglib2.0 -> OpenCV runtime used by ultralytics
|
| 9 |
+
# - fonts-liberation -> readable PDF text
|
| 10 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 11 |
+
python3 python3-pip \
|
| 12 |
+
chromium fonts-liberation \
|
| 13 |
+
libgl1 libglib2.0-0 \
|
| 14 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 15 |
+
|
| 16 |
+
# Python deps. CPU-only torch first (much smaller than the default CUDA build).
|
| 17 |
+
COPY requirements.txt /tmp/requirements.txt
|
| 18 |
+
RUN pip3 install --no-cache-dir --break-system-packages \
|
| 19 |
+
torch torchvision --index-url https://download.pytorch.org/whl/cpu \
|
| 20 |
+
&& pip3 install --no-cache-dir --break-system-packages -r /tmp/requirements.txt
|
| 21 |
+
|
| 22 |
+
WORKDIR /app
|
| 23 |
+
|
| 24 |
+
# Node deps (cached layer)
|
| 25 |
+
COPY package.json package-lock.json ./
|
| 26 |
+
RUN npm ci --omit=dev
|
| 27 |
+
|
| 28 |
+
# App code (see .dockerignore for exclusions β no secrets, no local data)
|
| 29 |
+
COPY . .
|
| 30 |
+
|
| 31 |
+
# Runtime env. PORT 7860 is what HuggingFace Spaces routes to.
|
| 32 |
+
ENV PORT=7860 \
|
| 33 |
+
HOST=0.0.0.0 \
|
| 34 |
+
PYTHON_BIN=python3 \
|
| 35 |
+
CHROME_PATH=/usr/bin/chromium \
|
| 36 |
+
YOLO_CONFIG_DIR=/tmp/ultralytics
|
| 37 |
+
|
| 38 |
+
# HF Spaces runs the container as an arbitrary non-root user; make the app dir
|
| 39 |
+
# writable for the ephemeral data store (data/cases.json etc.).
|
| 40 |
+
RUN mkdir -p /app/data && chmod -R 777 /app/data && chmod 1777 /tmp
|
| 41 |
+
|
| 42 |
+
EXPOSE 7860
|
| 43 |
+
CMD ["node", "server.js"]
|
LICENSE
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
CONFIDENTIAL & PROPRIETARY LICENSE AGREEMENT
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 Varun. All rights reserved.
|
| 4 |
+
|
| 5 |
+
NOTICE: All information contained herein is, and remains the property of the Copyright Holder (Varun). The intellectual and technical concepts contained herein are proprietary to the Copyright Holder and may be covered by patents, patents in process, and are protected by trade secret or copyright law.
|
| 6 |
+
|
| 7 |
+
Dissemination of this information or reproduction of this material is strictly forbidden unless prior written permission is obtained from the Copyright Holder.
|
| 8 |
+
|
| 9 |
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION:
|
| 10 |
+
|
| 11 |
+
1. DEFINITIONS:
|
| 12 |
+
"Software" shall mean the Citizen Pothole Submission Wizard, Pothole Citizen Portal, and all associated files, source code, scripts, HTML, CSS, assets, and documentation located in this repository.
|
| 13 |
+
"Licensor" or "Copyright Holder" shall mean Varun, the sole owner and author of the Software.
|
| 14 |
+
"You" (or "Your") shall mean any individual or Legal Entity exercising permissions granted by this License.
|
| 15 |
+
|
| 16 |
+
2. OWNERSHIP AND INTELLECTUAL PROPERTY:
|
| 17 |
+
The Software is licensed, not sold. All rights, title, and interest in and to the Software, including but not limited to all copyrights, trade secrets, patents, trademarks, and other intellectual property rights, are and shall remain the exclusive property of the Licensor.
|
| 18 |
+
|
| 19 |
+
3. RESTRICTIONS:
|
| 20 |
+
You may NOT:
|
| 21 |
+
a. Copy, reproduce, redistribute, republish, upload, post, transmit, or distribute the Software in any way, in whole or in part, without explicit, prior written authorization from the Licensor.
|
| 22 |
+
b. Modify, translate, adapt, merge, make derivative works of, disassemble, decompile, decrypt, or reverse engineer any part of the Software.
|
| 23 |
+
c. Remove, alter, or obscure any copyright, trademark, or other proprietary rights notices contained in or on the Software.
|
| 24 |
+
d. Sublicense, rent, lease, lend, sell, or distribute the Software or any portion thereof to any third party.
|
| 25 |
+
e. Use the Software for any unauthorized, unlawful, or competitive commercial purpose.
|
| 26 |
+
|
| 27 |
+
4. NO WARRANTY:
|
| 28 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
| 29 |
+
|
| 30 |
+
5. TERMINATION:
|
| 31 |
+
This License and Your right to use the Software shall terminate automatically and immediately if You fail to comply with any of its terms. Upon termination, You must immediately cease all use of the Software and destroy all copies of the Software in Your possession.
|
Pothole Case Review Sheet (DocGen).docx
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:e2eae884c5096fb92b1d34307234a69c19a00574737620161f8f4d5da9e43c75
|
| 3 |
+
size 37165
|
Pothole Closeout Job Sheet (DocGen Template).docx
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:1a0710f1d930941db1d3829ba41d0e921e00e9b7c852d35d5f7f5f56de182427
|
| 3 |
+
size 37725
|
Pothole Closeout Job Sheet (DocGen).docx
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:18dda542dafb4a50b2ed2f35b3759e4a49ee7ec8495379ecc7008581db397a12
|
| 3 |
+
size 17378
|
Pothole Closeout Job Sheet SAMPLE.docx
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:7e08e6fba69dba8d55e447cab92f8e2f1b8719076ea798b03b14e0da23af458f
|
| 3 |
+
size 165704
|
Pothole Repair Work Order (DocGen).docx
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:624994a7611c4f44265b3c1b3f11ead562a97f6c5c2ba5eb728a0e3b8bce0d90
|
| 3 |
+
size 17356
|
Pothole Repair Work Order.docx
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:111c6b2732a42a2a855360391ffde13ba8db380e20202dffa74ad1ff7c2b77b1
|
| 3 |
+
size 17188
|
Pothole Work Order (DocGen Template).docx
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:46b6ee4a88ad6ba10f5bd7238fe653987fc457a9c8451a85db019b1fcb711389
|
| 3 |
+
size 39063
|
Pothole Work Order SAMPLE.docx
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:82c7a27628716fc0e93f01260e8223c1c020d82cf5fb8e798f4ac9f8c2010965
|
| 3 |
+
size 195767
|
README.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: PotholeIQ
|
| 3 |
+
emoji: π£οΈ
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: false
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# PotholeIQ β Pothole Management on the Box Platform
|
| 12 |
+
|
| 13 |
+
One workflow from citizen intake to verified repair: AI photo classification
|
| 14 |
+
(YOLOv8), severity scoring, GIS mapping, supervisor review, crew dispatch,
|
| 15 |
+
digital closeout, and an auditable **before/after report** β with **Box** as the
|
| 16 |
+
content system of record and **Supabase** as the data mirror.
|
| 17 |
+
|
| 18 |
+
## Pages
|
| 19 |
+
|
| 20 |
+
| Page | Purpose |
|
| 21 |
+
|---|---|
|
| 22 |
+
| `/index.html` | Citizen submission portal (photo + GPS, AI classification) |
|
| 23 |
+
| `/command-center.html` | Supervisor Command Center (KPIs, map, approve/dispatch/resolve) |
|
| 24 |
+
| `/crew-closeout.html?case=ID` | Standalone crew closeout form (locked to one case) |
|
| 25 |
+
| `/case-report.html?case=ID` | Printable before/after report |
|
| 26 |
+
| `/track.html` | Citizen case tracking |
|
| 27 |
+
|
| 28 |
+
## Configuration (Space secrets β env vars)
|
| 29 |
+
|
| 30 |
+
**Box (required)**
|
| 31 |
+
|
| 32 |
+
| Secret | Example / note |
|
| 33 |
+
|---|---|
|
| 34 |
+
| `BOX_AUTH_MODE` | `client_credentials` |
|
| 35 |
+
| `BOX_CLIENT_ID` | Box app client id |
|
| 36 |
+
| `BOX_CLIENT_SECRET` | Box app client secret |
|
| 37 |
+
| `BOX_SUBJECT_TYPE` | `enterprise` |
|
| 38 |
+
| `BOX_SUBJECT_ID` | Box enterprise id |
|
| 39 |
+
| `BOX_INTAKE_FOLDER_ID` | Intake folder id |
|
| 40 |
+
| `BOX_CASE_ROOT_FOLDER_ID` | Case root folder id (often same as intake) |
|
| 41 |
+
| `BOX_METADATA_SCOPE` | `enterprise` |
|
| 42 |
+
| `BOX_METADATA_TEMPLATE_KEY` | `potholeCase` |
|
| 43 |
+
|
| 44 |
+
**Supabase (recommended β data mirror + supervisor login)**
|
| 45 |
+
|
| 46 |
+
| Secret | Note |
|
| 47 |
+
|---|---|
|
| 48 |
+
| `SUPABASE_URL` | `https://<ref>.supabase.co` |
|
| 49 |
+
| `SUPABASE_SECRET_KEY` | service-role key (server-side only) |
|
| 50 |
+
| `DASHBOARD_AUTH` | `true` to require supervisor login on dashboard APIs |
|
| 51 |
+
|
| 52 |
+
**Optional**
|
| 53 |
+
|
| 54 |
+
| Secret | Note |
|
| 55 |
+
|---|---|
|
| 56 |
+
| `CLOSEOUT_NOTIFY_RECIPIENTS` | Comma-separated default recipients for before/after reports |
|
| 57 |
+
| `BOX_AUTOMATE_SECRET` | Shared secret for Box Automate β `/api/box/automate-update` |
|
| 58 |
+
| `DATA_DIR` | Set to `/data/potholeiq` if HF persistent storage is enabled |
|
| 59 |
+
| `BOX_CASES_TTL_MS` | Box case-list cache TTL (default 45000) |
|
| 60 |
+
|
| 61 |
+
## Architecture notes
|
| 62 |
+
|
| 63 |
+
- **Box = source of truth for content**: photos, sidecars, generated documents,
|
| 64 |
+
before/after PDFs (in `Closeout Reports`), metadata (`potholeCase` template).
|
| 65 |
+
- **Supabase = data mirror** (`db/schema.sql`, run once in the Supabase SQL editor).
|
| 66 |
+
- The local `data/` store is a working cache only β safe to lose on restarts.
|
| 67 |
+
- First AI classification after a cold start takes ~30β60 s (model load).
|
START_SERVER.bat
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@echo off
|
| 2 |
+
title Pothole Portal - Local Server
|
| 3 |
+
color 0A
|
| 4 |
+
echo.
|
| 5 |
+
echo ============================================
|
| 6 |
+
echo POTHOLE CITIZEN PORTAL - LOCAL SERVER
|
| 7 |
+
echo ============================================
|
| 8 |
+
echo.
|
| 9 |
+
echo Starting server on port 3030...
|
| 10 |
+
echo.
|
| 11 |
+
|
| 12 |
+
REM Prefer the Node backend because it serves both the app and the API
|
| 13 |
+
node --version >nul 2>&1
|
| 14 |
+
if %errorlevel% == 0 (
|
| 15 |
+
echo [Node.js] Full app + API server running at:
|
| 16 |
+
echo.
|
| 17 |
+
echo http://localhost:3030
|
| 18 |
+
echo.
|
| 19 |
+
echo This includes the Box workflow backend endpoints.
|
| 20 |
+
echo Press Ctrl+C to stop.
|
| 21 |
+
echo.
|
| 22 |
+
node server.js
|
| 23 |
+
goto end
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
REM Fallback to Python static hosting if Node is unavailable
|
| 27 |
+
python --version >nul 2>&1
|
| 28 |
+
if %errorlevel% == 0 (
|
| 29 |
+
echo [Python] Static server running at:
|
| 30 |
+
echo.
|
| 31 |
+
echo http://localhost:3030
|
| 32 |
+
echo.
|
| 33 |
+
echo WARNING: API backend endpoints will NOT be available in this mode.
|
| 34 |
+
echo.
|
| 35 |
+
python -m http.server 3030
|
| 36 |
+
goto end
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
REM Try Python3
|
| 40 |
+
python3 --version >nul 2>&1
|
| 41 |
+
if %errorlevel% == 0 (
|
| 42 |
+
echo [Python3] Static server running at:
|
| 43 |
+
echo.
|
| 44 |
+
echo http://localhost:3030
|
| 45 |
+
echo.
|
| 46 |
+
echo WARNING: API backend endpoints will NOT be available in this mode.
|
| 47 |
+
echo.
|
| 48 |
+
python3 -m http.server 3030
|
| 49 |
+
goto end
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
echo ERROR: Neither Node.js nor Python was found.
|
| 53 |
+
echo.
|
| 54 |
+
echo Please install Node.js from https://nodejs.org
|
| 55 |
+
echo or Python from https://python.org
|
| 56 |
+
echo then run this file again.
|
| 57 |
+
echo.
|
| 58 |
+
|
| 59 |
+
:end
|
| 60 |
+
pause
|
WORKFLOW_STORAGE_ONLY_GUIDE.md
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Pothole Workflow: Box Storage Only
|
| 2 |
+
|
| 3 |
+
This is the recommended workflow if Box is used only as the shared content repository.
|
| 4 |
+
|
| 5 |
+
In this model:
|
| 6 |
+
|
| 7 |
+
- Box stores photos, JSON files, case folders, review summaries, closeout proof, and reports
|
| 8 |
+
- The app and backend handle review, approvals, dispatch, authority notification, crew forms, and reporting logic
|
| 9 |
+
- GIS, weather, duplicate checks, and severity scoring all happen outside Box
|
| 10 |
+
|
| 11 |
+
## 1. Architecture
|
| 12 |
+
|
| 13 |
+
Use this split everywhere:
|
| 14 |
+
|
| 15 |
+
- `Box`
|
| 16 |
+
- intake photo storage
|
| 17 |
+
- intake JSON storage
|
| 18 |
+
- organized case folders
|
| 19 |
+
- enriched JSON files
|
| 20 |
+
- human-readable review summaries
|
| 21 |
+
- after-photos and repair proof
|
| 22 |
+
- generated reports
|
| 23 |
+
|
| 24 |
+
- `Backend / app`
|
| 25 |
+
- GIS mapping
|
| 26 |
+
- reverse geocoding
|
| 27 |
+
- duplicate detection
|
| 28 |
+
- weather enrichment
|
| 29 |
+
- severity scoring
|
| 30 |
+
- supervisor review decisions
|
| 31 |
+
- authority email
|
| 32 |
+
- crew assignment and routing
|
| 33 |
+
- crew completion form
|
| 34 |
+
- before/after report generation
|
| 35 |
+
|
| 36 |
+
## 2. End-to-End Flow
|
| 37 |
+
|
| 38 |
+
### Stage 1. Citizen Intake
|
| 39 |
+
|
| 40 |
+
Owner:
|
| 41 |
+
- citizen portal
|
| 42 |
+
|
| 43 |
+
System actions:
|
| 44 |
+
- collect photo
|
| 45 |
+
- collect GPS or manual pin
|
| 46 |
+
- reverse geocode address
|
| 47 |
+
- capture AI review status and pothole size
|
| 48 |
+
- upload photo to Box `Incoming Detections`
|
| 49 |
+
- upload JSON sidecar to Box `Incoming Detections`
|
| 50 |
+
- upsert the same case into the backend
|
| 51 |
+
|
| 52 |
+
Status:
|
| 53 |
+
- `Submitted`
|
| 54 |
+
|
| 55 |
+
Main files:
|
| 56 |
+
- `index.html`
|
| 57 |
+
- `box-upload.js`
|
| 58 |
+
- `box-service.js`
|
| 59 |
+
|
| 60 |
+
### Stage 2. Ops Enrichment
|
| 61 |
+
|
| 62 |
+
Owner:
|
| 63 |
+
- ops review console
|
| 64 |
+
|
| 65 |
+
System actions:
|
| 66 |
+
- load submitted case from backend
|
| 67 |
+
- resolve official GIS ward, district, and maintenance zone
|
| 68 |
+
- check duplicates by GPS radius
|
| 69 |
+
- pull weather risk
|
| 70 |
+
- calculate severity score and severity level
|
| 71 |
+
- organize the case into the right Box folder
|
| 72 |
+
- write enriched JSON into the case folder
|
| 73 |
+
- write a readable review summary into the case folder
|
| 74 |
+
- write Box metadata for reporting and indexing
|
| 75 |
+
|
| 76 |
+
Status:
|
| 77 |
+
- `Under Review`
|
| 78 |
+
|
| 79 |
+
Main files:
|
| 80 |
+
- `ops-review.html`
|
| 81 |
+
- `enrichment.js`
|
| 82 |
+
- `gis.js`
|
| 83 |
+
- `box-folders.js`
|
| 84 |
+
|
| 85 |
+
### Stage 3. Supervisor Review
|
| 86 |
+
|
| 87 |
+
Owner:
|
| 88 |
+
- supervisor using our app, not Box
|
| 89 |
+
|
| 90 |
+
System actions:
|
| 91 |
+
- review image
|
| 92 |
+
- review readable summary
|
| 93 |
+
- review severity, duplicate status, weather, ward, district, and zone
|
| 94 |
+
- choose:
|
| 95 |
+
- approve
|
| 96 |
+
- reject
|
| 97 |
+
- merge duplicate
|
| 98 |
+
|
| 99 |
+
Status:
|
| 100 |
+
- `Under Review` until approved or rejected
|
| 101 |
+
|
| 102 |
+
Suggested result fields:
|
| 103 |
+
- `supervisorDecision`
|
| 104 |
+
- `supervisorReason`
|
| 105 |
+
- `supervisorAt`
|
| 106 |
+
- `supervisorBy`
|
| 107 |
+
|
| 108 |
+
### Stage 4. Authority Notification
|
| 109 |
+
|
| 110 |
+
Owner:
|
| 111 |
+
- backend
|
| 112 |
+
|
| 113 |
+
System actions:
|
| 114 |
+
- only for approved primary cases
|
| 115 |
+
- look up authority by district or maintenance zone
|
| 116 |
+
- send authority email with case details
|
| 117 |
+
- include Box folder path or Box link
|
| 118 |
+
|
| 119 |
+
Status:
|
| 120 |
+
- stays `Under Review` until dispatch is created
|
| 121 |
+
|
| 122 |
+
### Stage 5. Work Order and Dispatch
|
| 123 |
+
|
| 124 |
+
Owner:
|
| 125 |
+
- dispatcher or backend
|
| 126 |
+
|
| 127 |
+
System actions:
|
| 128 |
+
- create work order outside Box
|
| 129 |
+
- assign crew
|
| 130 |
+
- generate route plan
|
| 131 |
+
- save dispatch artifact to Box
|
| 132 |
+
- patch backend case with crew assignment
|
| 133 |
+
|
| 134 |
+
Status:
|
| 135 |
+
- `Assigned`
|
| 136 |
+
|
| 137 |
+
Main files:
|
| 138 |
+
- `deploy-crew.html`
|
| 139 |
+
|
| 140 |
+
### Stage 6. Crew Work Start
|
| 141 |
+
|
| 142 |
+
Owner:
|
| 143 |
+
- field crew
|
| 144 |
+
|
| 145 |
+
System actions:
|
| 146 |
+
- crew accepts assignment
|
| 147 |
+
- mark start time
|
| 148 |
+
- mark assigned crew and route stop
|
| 149 |
+
|
| 150 |
+
Status:
|
| 151 |
+
- `In Progress`
|
| 152 |
+
|
| 153 |
+
### Stage 7. Crew Completion
|
| 154 |
+
|
| 155 |
+
Owner:
|
| 156 |
+
- field crew
|
| 157 |
+
|
| 158 |
+
System actions:
|
| 159 |
+
- submit after-photo
|
| 160 |
+
- submit materials used
|
| 161 |
+
- submit labor hours
|
| 162 |
+
- submit repair notes
|
| 163 |
+
- submit signature if required
|
| 164 |
+
- upload closeout files to Box
|
| 165 |
+
|
| 166 |
+
Status:
|
| 167 |
+
- still `In Progress` until verified
|
| 168 |
+
|
| 169 |
+
Suggested fields:
|
| 170 |
+
- `afterPhotoFileId`
|
| 171 |
+
- `materialsUsed`
|
| 172 |
+
- `laborHours`
|
| 173 |
+
- `resolutionNotes`
|
| 174 |
+
- `crewSignature`
|
| 175 |
+
- `completedAt`
|
| 176 |
+
|
| 177 |
+
### Stage 8. Supervisor Closeout
|
| 178 |
+
|
| 179 |
+
Owner:
|
| 180 |
+
- supervisor
|
| 181 |
+
|
| 182 |
+
System actions:
|
| 183 |
+
- verify repair proof
|
| 184 |
+
- verify after-photo
|
| 185 |
+
- mark case resolved
|
| 186 |
+
- optionally notify citizen
|
| 187 |
+
|
| 188 |
+
Status:
|
| 189 |
+
- `Resolved`
|
| 190 |
+
|
| 191 |
+
### Stage 9. Reporting
|
| 192 |
+
|
| 193 |
+
Owner:
|
| 194 |
+
- backend
|
| 195 |
+
|
| 196 |
+
System actions:
|
| 197 |
+
- generate before/after report
|
| 198 |
+
- generate daily and weekly summaries
|
| 199 |
+
- upload reports to Box `Reports`
|
| 200 |
+
|
| 201 |
+
Outputs:
|
| 202 |
+
- per-case audit report
|
| 203 |
+
- daily summary report
|
| 204 |
+
- weekly summary report
|
| 205 |
+
|
| 206 |
+
## 3. Box Folder Responsibilities
|
| 207 |
+
|
| 208 |
+
Use Box for these artifacts only:
|
| 209 |
+
|
| 210 |
+
- original photo
|
| 211 |
+
- original JSON sidecar
|
| 212 |
+
- enriched JSON
|
| 213 |
+
- readable review summary
|
| 214 |
+
- before and after photos
|
| 215 |
+
- work order file
|
| 216 |
+
- dispatch plan export
|
| 217 |
+
- audit report
|
| 218 |
+
- daily and weekly reports
|
| 219 |
+
|
| 220 |
+
## 4. JSON Fields To Keep Standard
|
| 221 |
+
|
| 222 |
+
These are the business fields we should keep stable:
|
| 223 |
+
|
| 224 |
+
- `caseId`
|
| 225 |
+
- `submittedAt`
|
| 226 |
+
- `address`
|
| 227 |
+
- `areaLabel`
|
| 228 |
+
- `location.lat`
|
| 229 |
+
- `location.lng`
|
| 230 |
+
- `location.source`
|
| 231 |
+
- `ward`
|
| 232 |
+
- `district`
|
| 233 |
+
- `maintenanceZone`
|
| 234 |
+
- `duplicateStatus`
|
| 235 |
+
- `weatherStatus`
|
| 236 |
+
- `potholeSize`
|
| 237 |
+
- `aiResult`
|
| 238 |
+
- `aiPrediction`
|
| 239 |
+
- `aiReviewStatus`
|
| 240 |
+
- `aiReviewMessage`
|
| 241 |
+
|
| 242 |
+
## 5. Current Best Operating Order
|
| 243 |
+
|
| 244 |
+
1. Citizen submits report
|
| 245 |
+
2. Box stores photo and intake JSON
|
| 246 |
+
3. Ops review enriches the case outside Box
|
| 247 |
+
4. Box stores the enriched JSON and review summary
|
| 248 |
+
5. Supervisor reviews in our app
|
| 249 |
+
6. Backend sends authority notification
|
| 250 |
+
7. Dispatcher assigns crew and route
|
| 251 |
+
8. Crew uploads closeout proof
|
| 252 |
+
9. Backend generates reports into Box
|
| 253 |
+
|
| 254 |
+
## 6. Immediate Next Build Order
|
| 255 |
+
|
| 256 |
+
1. Supervisor decision outside Box
|
| 257 |
+
2. Authority email automation outside Box
|
| 258 |
+
3. Crew completion form outside Box
|
| 259 |
+
4. Before/after report generator outside Box
|
| 260 |
+
5. Save all artifacts back into Box
|
WORKFLOW_V2_GUIDE.md
ADDED
|
@@ -0,0 +1,434 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Pothole Operations Workflow V2
|
| 2 |
+
|
| 3 |
+
This is the recommended operating workflow for the current `pothole box` app.
|
| 4 |
+
|
| 5 |
+
It is designed around the code that already exists:
|
| 6 |
+
|
| 7 |
+
- Citizen intake: `index.html`, `app.js`, `box-upload.js`
|
| 8 |
+
- Ops review and enrichment: `ops-review.html`, `enrichment.js`, `box-folders.js`
|
| 9 |
+
- Shared case state: `server.js`, `backend-api.js`
|
| 10 |
+
- Crew dispatch: `deploy-crew.html`
|
| 11 |
+
- Citizen tracking: `track.html`
|
| 12 |
+
- GIS routing: `gis.js`
|
| 13 |
+
- Weather enrichment: `enrichment.js`
|
| 14 |
+
|
| 15 |
+
## 1. Core Rules
|
| 16 |
+
|
| 17 |
+
Use these rules everywhere.
|
| 18 |
+
|
| 19 |
+
1. Keep only these public case statuses:
|
| 20 |
+
`Submitted`, `Under Review`, `Assigned`, `In Progress`, `Resolved`, `Rejected`
|
| 21 |
+
|
| 22 |
+
2. Box is the file and metadata system:
|
| 23 |
+
photos, JSON sidecars, case folders, enriched files, dispatch artifacts, completion proof
|
| 24 |
+
|
| 25 |
+
3. The backend is the shared live case state:
|
| 26 |
+
every screen should read and write through the backend, not only `localStorage`
|
| 27 |
+
|
| 28 |
+
4. GIS, weather, AI, and duplicate detection are decision inputs:
|
| 29 |
+
they help routing and prioritization, but they do not auto-dispatch by themselves
|
| 30 |
+
|
| 31 |
+
5. Authority email must not be sent on raw citizen submission:
|
| 32 |
+
send it only after supervisor approval and duplicate review
|
| 33 |
+
|
| 34 |
+
6. Duplicate child cases must not create extra crew jobs:
|
| 35 |
+
one pothole cluster should have one active operational case owner
|
| 36 |
+
|
| 37 |
+
7. A case is not truly closed until repair proof exists:
|
| 38 |
+
after-photo, crew notes, completion timestamp, and supervisor confirmation
|
| 39 |
+
|
| 40 |
+
## 2. Recommended End-to-End Workflow
|
| 41 |
+
|
| 42 |
+
### Stage 1. Citizen Submission
|
| 43 |
+
|
| 44 |
+
Owner: citizen portal
|
| 45 |
+
|
| 46 |
+
Trigger:
|
| 47 |
+
- User submits photo, location, and optional contact
|
| 48 |
+
|
| 49 |
+
System actions:
|
| 50 |
+
- Run image and road checks
|
| 51 |
+
- Capture GPS and reverse geocode
|
| 52 |
+
- Resolve GIS mapping if configured
|
| 53 |
+
- Upload photo and metadata sidecar to Box
|
| 54 |
+
- Create initial workflow metadata
|
| 55 |
+
- Upsert case to backend
|
| 56 |
+
|
| 57 |
+
Status after this stage:
|
| 58 |
+
- `Submitted`
|
| 59 |
+
|
| 60 |
+
Files involved:
|
| 61 |
+
- `index.html`
|
| 62 |
+
- `app.js`
|
| 63 |
+
- `box-upload.js`
|
| 64 |
+
|
| 65 |
+
### Stage 2. Ops Enrichment
|
| 66 |
+
|
| 67 |
+
Owner: ops review console
|
| 68 |
+
|
| 69 |
+
Trigger:
|
| 70 |
+
- New `Submitted` case appears in ops queue
|
| 71 |
+
|
| 72 |
+
System actions:
|
| 73 |
+
- Run severity enrichment
|
| 74 |
+
- Check duplicates by GPS radius
|
| 75 |
+
- Pull weather risk
|
| 76 |
+
- Resolve district, ward, and maintenance zone
|
| 77 |
+
- Organize the case into the correct Box folder
|
| 78 |
+
- Write enriched metadata back to Box and backend
|
| 79 |
+
|
| 80 |
+
Status after this stage:
|
| 81 |
+
- `Under Review`
|
| 82 |
+
|
| 83 |
+
Files involved:
|
| 84 |
+
- `ops-review.html`
|
| 85 |
+
- `enrichment.js`
|
| 86 |
+
- `box-folders.js`
|
| 87 |
+
- `gis.js`
|
| 88 |
+
|
| 89 |
+
### Stage 3. Supervisor Decision
|
| 90 |
+
|
| 91 |
+
Owner: supervisor
|
| 92 |
+
|
| 93 |
+
Supervisor can choose one of three outcomes:
|
| 94 |
+
|
| 95 |
+
1. Reject
|
| 96 |
+
- Use when the report is invalid, unsafe, or not a pothole
|
| 97 |
+
- Final status: `Rejected`
|
| 98 |
+
|
| 99 |
+
2. Merge into an existing active case
|
| 100 |
+
- Use when duplicate detection is confirmed
|
| 101 |
+
- Do not create a new crew assignment
|
| 102 |
+
- Do not send a new authority email
|
| 103 |
+
- Keep one primary operational case for the cluster
|
| 104 |
+
- Child case should stay linked to the parent in metadata
|
| 105 |
+
|
| 106 |
+
3. Approve for action
|
| 107 |
+
- Use when it is a valid pothole and should move to operations
|
| 108 |
+
- Continue to authority notification and dispatch
|
| 109 |
+
|
| 110 |
+
Status after approval:
|
| 111 |
+
- Keep `Under Review` until authority notification and dispatch preparation are done
|
| 112 |
+
|
| 113 |
+
Files involved:
|
| 114 |
+
- `ops-review.html`
|
| 115 |
+
|
| 116 |
+
### Stage 4. Authority Notification
|
| 117 |
+
|
| 118 |
+
Owner: backend automation
|
| 119 |
+
|
| 120 |
+
Trigger:
|
| 121 |
+
- Supervisor approved the case
|
| 122 |
+
- Case is the primary case in its duplicate cluster
|
| 123 |
+
- District and maintenance zone are known
|
| 124 |
+
|
| 125 |
+
Notification policy:
|
| 126 |
+
|
| 127 |
+
1. Critical severity
|
| 128 |
+
- Send immediately to the concerned authority and dispatch lead
|
| 129 |
+
|
| 130 |
+
2. High, Medium, Low severity
|
| 131 |
+
- Send in district or zone digest batches every 30 to 60 minutes
|
| 132 |
+
|
| 133 |
+
Email must include:
|
| 134 |
+
- Case ID
|
| 135 |
+
- Address
|
| 136 |
+
- GIS district, ward, maintenance zone
|
| 137 |
+
- Severity score and level
|
| 138 |
+
- Duplicate count and linked cases
|
| 139 |
+
- Reporter count if relevant
|
| 140 |
+
- Box folder path or Box folder link
|
| 141 |
+
- Recommended next action
|
| 142 |
+
|
| 143 |
+
Important guardrail:
|
| 144 |
+
- Do not send one email per duplicate report
|
| 145 |
+
|
| 146 |
+
Status after this stage:
|
| 147 |
+
- Still `Under Review` until a crew is actually assigned
|
| 148 |
+
|
| 149 |
+
Build needed:
|
| 150 |
+
- Add backend email service and routing table
|
| 151 |
+
|
| 152 |
+
### Stage 5. Crew Dispatch
|
| 153 |
+
|
| 154 |
+
Owner: dispatcher or supervisor
|
| 155 |
+
|
| 156 |
+
Trigger:
|
| 157 |
+
- Approved primary case is ready for field work
|
| 158 |
+
|
| 159 |
+
System actions:
|
| 160 |
+
- Group approved active potholes by ward or zone
|
| 161 |
+
- Generate route plan
|
| 162 |
+
- Assign crew
|
| 163 |
+
- Save dispatch plan
|
| 164 |
+
- Push assignment to backend
|
| 165 |
+
|
| 166 |
+
Status after this stage:
|
| 167 |
+
- `Assigned`
|
| 168 |
+
|
| 169 |
+
Files involved:
|
| 170 |
+
- `deploy-crew.html`
|
| 171 |
+
|
| 172 |
+
### Stage 6. Crew Work Start
|
| 173 |
+
|
| 174 |
+
Owner: field crew
|
| 175 |
+
|
| 176 |
+
Trigger:
|
| 177 |
+
- Crew accepts the assignment or starts work on site
|
| 178 |
+
|
| 179 |
+
System actions:
|
| 180 |
+
- Mark case start time
|
| 181 |
+
- Mark assigned crew
|
| 182 |
+
- Optionally capture ETA or on-site note
|
| 183 |
+
|
| 184 |
+
Status after this stage:
|
| 185 |
+
- `In Progress`
|
| 186 |
+
|
| 187 |
+
Build needed:
|
| 188 |
+
- Crew action form or simple crew mobile screen
|
| 189 |
+
|
| 190 |
+
### Stage 7. Crew Completion Form
|
| 191 |
+
|
| 192 |
+
Owner: field crew
|
| 193 |
+
|
| 194 |
+
Required fields:
|
| 195 |
+
- Case ID
|
| 196 |
+
- Crew name
|
| 197 |
+
- Completion timestamp
|
| 198 |
+
- After-photo
|
| 199 |
+
- Repair notes
|
| 200 |
+
- Material used or fill quantity
|
| 201 |
+
- Could not complete reason, if blocked
|
| 202 |
+
|
| 203 |
+
System actions:
|
| 204 |
+
- Upload completion proof to Box
|
| 205 |
+
- Patch backend case
|
| 206 |
+
- Flag case for supervisor closeout
|
| 207 |
+
|
| 208 |
+
Status after this stage:
|
| 209 |
+
- Keep `In Progress` until supervisor verifies proof
|
| 210 |
+
|
| 211 |
+
Build needed:
|
| 212 |
+
- New crew completion form page tied to Box and backend
|
| 213 |
+
|
| 214 |
+
### Stage 8. Supervisor Closeout
|
| 215 |
+
|
| 216 |
+
Owner: supervisor
|
| 217 |
+
|
| 218 |
+
Trigger:
|
| 219 |
+
- Crew completion proof received
|
| 220 |
+
|
| 221 |
+
System actions:
|
| 222 |
+
- Review after-photo and notes
|
| 223 |
+
- Confirm repair is complete
|
| 224 |
+
- Mark resolved timestamp
|
| 225 |
+
- Optionally notify citizen if contact was provided
|
| 226 |
+
|
| 227 |
+
Status after this stage:
|
| 228 |
+
- `Resolved`
|
| 229 |
+
|
| 230 |
+
### Stage 9. Reporting
|
| 231 |
+
|
| 232 |
+
Owner: backend automation
|
| 233 |
+
|
| 234 |
+
Trigger:
|
| 235 |
+
- Scheduled daily and weekly jobs
|
| 236 |
+
|
| 237 |
+
Reports to generate:
|
| 238 |
+
- New cases by district and ward
|
| 239 |
+
- Duplicate clusters
|
| 240 |
+
- Average approval time
|
| 241 |
+
- Average assignment time
|
| 242 |
+
- Average repair completion time
|
| 243 |
+
- Open active cases by zone
|
| 244 |
+
- Rejected reports
|
| 245 |
+
- Weather-linked spikes
|
| 246 |
+
- Crew productivity summary
|
| 247 |
+
|
| 248 |
+
Output:
|
| 249 |
+
- Save CSV or PDF report into a Box reports folder
|
| 250 |
+
- Email summary to management if needed
|
| 251 |
+
|
| 252 |
+
Build needed:
|
| 253 |
+
- Scheduled report generator on backend
|
| 254 |
+
|
| 255 |
+
## 3. Status Rules
|
| 256 |
+
|
| 257 |
+
Use this as the single status policy.
|
| 258 |
+
|
| 259 |
+
### `Submitted`
|
| 260 |
+
|
| 261 |
+
Meaning:
|
| 262 |
+
- Citizen report received
|
| 263 |
+
- Photo and metadata saved
|
| 264 |
+
- Waiting for ops enrichment
|
| 265 |
+
|
| 266 |
+
### `Under Review`
|
| 267 |
+
|
| 268 |
+
Meaning:
|
| 269 |
+
- Ops is validating the report
|
| 270 |
+
- Duplicate and routing checks are in progress
|
| 271 |
+
- Supervisor approval is pending or just completed but dispatch is not done yet
|
| 272 |
+
|
| 273 |
+
### `Assigned`
|
| 274 |
+
|
| 275 |
+
Meaning:
|
| 276 |
+
- A crew has been assigned
|
| 277 |
+
- Dispatch plan exists
|
| 278 |
+
|
| 279 |
+
### `In Progress`
|
| 280 |
+
|
| 281 |
+
Meaning:
|
| 282 |
+
- Crew is actively working or has started the job
|
| 283 |
+
|
| 284 |
+
### `Resolved`
|
| 285 |
+
|
| 286 |
+
Meaning:
|
| 287 |
+
- Repair proof exists
|
| 288 |
+
- Supervisor or workflow confirmed the fix
|
| 289 |
+
|
| 290 |
+
### `Rejected`
|
| 291 |
+
|
| 292 |
+
Meaning:
|
| 293 |
+
- The report is invalid or closed without field action
|
| 294 |
+
|
| 295 |
+
## 4. What Should Live Where
|
| 296 |
+
|
| 297 |
+
### Keep in Box
|
| 298 |
+
|
| 299 |
+
- Original photo
|
| 300 |
+
- Original metadata sidecar
|
| 301 |
+
- Enriched metadata file
|
| 302 |
+
- Case folder metadata
|
| 303 |
+
- Dispatch sheet
|
| 304 |
+
- After-photo and repair proof
|
| 305 |
+
- Final reports
|
| 306 |
+
|
| 307 |
+
### Keep in Backend
|
| 308 |
+
|
| 309 |
+
- Current status
|
| 310 |
+
- Current assignee
|
| 311 |
+
- Workflow timestamps
|
| 312 |
+
- Parent and child duplicate links
|
| 313 |
+
- Authority email audit trail
|
| 314 |
+
- Crew start and completion data
|
| 315 |
+
|
| 316 |
+
### Keep as Integrations
|
| 317 |
+
|
| 318 |
+
- GIS boundary lookup
|
| 319 |
+
- Weather forecast lookup
|
| 320 |
+
- Email delivery
|
| 321 |
+
|
| 322 |
+
## 5. Safe Rollout Order
|
| 323 |
+
|
| 324 |
+
This is the best order to implement without breaking the app.
|
| 325 |
+
|
| 326 |
+
### Step 1. Stabilize shared case state
|
| 327 |
+
|
| 328 |
+
Do first.
|
| 329 |
+
|
| 330 |
+
Goal:
|
| 331 |
+
- Make the backend the live source of truth across intake, ops review, dispatch, and tracking
|
| 332 |
+
|
| 333 |
+
Why:
|
| 334 |
+
- Right now some workflow still depends on `localStorage`, which is risky for multi-user operations
|
| 335 |
+
|
| 336 |
+
### Step 2. Lock the status model
|
| 337 |
+
|
| 338 |
+
Do second.
|
| 339 |
+
|
| 340 |
+
Goal:
|
| 341 |
+
- Use only the six public statuses already supported by the tracking UI
|
| 342 |
+
|
| 343 |
+
Why:
|
| 344 |
+
- Avoid adding new public statuses until the tracking and review UIs are updated together
|
| 345 |
+
|
| 346 |
+
### Step 3. Add authority routing table
|
| 347 |
+
|
| 348 |
+
Do third.
|
| 349 |
+
|
| 350 |
+
Goal:
|
| 351 |
+
- Map each district or maintenance zone to the correct authority email list
|
| 352 |
+
|
| 353 |
+
Needed fields:
|
| 354 |
+
- `district`
|
| 355 |
+
- `ward`
|
| 356 |
+
- `maintenanceZone`
|
| 357 |
+
- `authorityEmails`
|
| 358 |
+
- `dispatchEmails`
|
| 359 |
+
|
| 360 |
+
### Step 4. Add real authority email automation
|
| 361 |
+
|
| 362 |
+
Do fourth.
|
| 363 |
+
|
| 364 |
+
Goal:
|
| 365 |
+
- Send approved active pothole notifications from the backend
|
| 366 |
+
|
| 367 |
+
Rules:
|
| 368 |
+
- Immediate for `Critical`
|
| 369 |
+
- Batched digest for the rest
|
| 370 |
+
- No email for duplicate child cases
|
| 371 |
+
|
| 372 |
+
### Step 5. Add crew completion form
|
| 373 |
+
|
| 374 |
+
Do fifth.
|
| 375 |
+
|
| 376 |
+
Goal:
|
| 377 |
+
- Give crews a proper way to upload after-photos and repair notes
|
| 378 |
+
|
| 379 |
+
### Step 6. Add closeout verification
|
| 380 |
+
|
| 381 |
+
Do sixth.
|
| 382 |
+
|
| 383 |
+
Goal:
|
| 384 |
+
- Resolve only after proof is reviewed
|
| 385 |
+
|
| 386 |
+
### Step 7. Add scheduled reporting
|
| 387 |
+
|
| 388 |
+
Do seventh.
|
| 389 |
+
|
| 390 |
+
Goal:
|
| 391 |
+
- Auto-generate daily and weekly reports into Box
|
| 392 |
+
|
| 393 |
+
### Step 8. Move secrets off the browser
|
| 394 |
+
|
| 395 |
+
Do this before production use.
|
| 396 |
+
|
| 397 |
+
Goal:
|
| 398 |
+
- Remove Box and weather secrets from browser storage
|
| 399 |
+
- Keep them on the backend instead
|
| 400 |
+
|
| 401 |
+
## 6. Immediate Next Recommendation
|
| 402 |
+
|
| 403 |
+
If you want the cleanest next build step, do this first:
|
| 404 |
+
|
| 405 |
+
1. Make backend case sync the source of truth
|
| 406 |
+
2. Add authority email automation on approved primary cases
|
| 407 |
+
3. Add crew completion form
|
| 408 |
+
4. Add daily and weekly report generator
|
| 409 |
+
|
| 410 |
+
That order gives you the biggest operational improvement with the least confusion.
|
| 411 |
+
|
| 412 |
+
## 7. Short Version
|
| 413 |
+
|
| 414 |
+
The correct workflow is:
|
| 415 |
+
|
| 416 |
+
1. Citizen submits report
|
| 417 |
+
2. Ops enriches and checks duplicates
|
| 418 |
+
3. Supervisor rejects, merges, or approves
|
| 419 |
+
4. Backend notifies the correct authority
|
| 420 |
+
5. Dispatcher assigns crew
|
| 421 |
+
6. Crew marks work in progress
|
| 422 |
+
7. Crew submits completion proof
|
| 423 |
+
8. Supervisor closes the case
|
| 424 |
+
9. Backend generates reports
|
| 425 |
+
|
| 426 |
+
The most important correction is this:
|
| 427 |
+
|
| 428 |
+
Do not treat Box alone as the whole system.
|
| 429 |
+
|
| 430 |
+
Use:
|
| 431 |
+
- Box for files and workflow metadata
|
| 432 |
+
- Backend for live state and automation
|
| 433 |
+
- GIS and weather as decision inputs
|
| 434 |
+
- Email and reports as backend-driven automations
|
add-supervisor.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Add (or update) a supervisor who can access the Command Center.
|
| 3 |
+
*
|
| 4 |
+
* Usage:
|
| 5 |
+
* SUPABASE_URL=... SUPABASE_SECRET_KEY=sb_secret_... \
|
| 6 |
+
* node add-supervisor.js <email> <password>
|
| 7 |
+
*
|
| 8 |
+
* Creates an auto-confirmed Supabase user with app_metadata.role = "supervisor"
|
| 9 |
+
* (the role the backend + login page require). Re-running for an existing email
|
| 10 |
+
* resets the password and ensures the role.
|
| 11 |
+
*/
|
| 12 |
+
'use strict';
|
| 13 |
+
const { createClient } = require('@supabase/supabase-js');
|
| 14 |
+
|
| 15 |
+
const URL = process.env.SUPABASE_URL;
|
| 16 |
+
const SECRET = process.env.SUPABASE_SECRET_KEY || process.env.SUPABASE_KEY;
|
| 17 |
+
const [email, password] = process.argv.slice(2);
|
| 18 |
+
|
| 19 |
+
if (!URL || !SECRET) { console.error('Set SUPABASE_URL and SUPABASE_SECRET_KEY env vars.'); process.exit(1); }
|
| 20 |
+
if (!email || !password) { console.error('Usage: node add-supervisor.js <email> <password>'); process.exit(1); }
|
| 21 |
+
|
| 22 |
+
(async () => {
|
| 23 |
+
const admin = createClient(URL, SECRET, { auth: { persistSession: false } });
|
| 24 |
+
const { error } = await admin.auth.admin.createUser({
|
| 25 |
+
email, password, email_confirm: true, app_metadata: { role: 'supervisor' },
|
| 26 |
+
});
|
| 27 |
+
if (error && /already/i.test(error.message)) {
|
| 28 |
+
const list = await admin.auth.admin.listUsers();
|
| 29 |
+
const u = list.data.users.find((x) => x.email === email);
|
| 30 |
+
if (!u) { console.error('User exists but could not be located.'); process.exit(1); }
|
| 31 |
+
await admin.auth.admin.updateUserById(u.id, { password, app_metadata: { role: 'supervisor' } });
|
| 32 |
+
console.log('Updated supervisor:', email);
|
| 33 |
+
} else if (error) {
|
| 34 |
+
console.error('Error:', error.message); process.exit(1);
|
| 35 |
+
} else {
|
| 36 |
+
console.log('Created supervisor:', email);
|
| 37 |
+
}
|
| 38 |
+
})().catch((e) => { console.error('ERROR', e.message); process.exit(1); });
|
ai/detect_pothole.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
"""
|
| 3 |
+
Open-source pothole classifier β YOLOv8 (Ultralytics), runs locally, no API cost.
|
| 4 |
+
Model: peterhdd/pothole-detection-yolov8 (single "pothole" class).
|
| 5 |
+
|
| 6 |
+
Usage: python ai/detect_pothole.py <image_path>
|
| 7 |
+
Output: one line of JSON on stdout:
|
| 8 |
+
{"isPothole": bool, "confidence": float, "size": "Small|Medium|Large"|null,
|
| 9 |
+
"count": int, "largestAreaFraction": float, "boxes": [...], "model": "..."}
|
| 10 |
+
Errors are also returned as JSON: {"error": "..."}.
|
| 11 |
+
"""
|
| 12 |
+
import sys
|
| 13 |
+
import os
|
| 14 |
+
import json
|
| 15 |
+
|
| 16 |
+
# Size thresholds: largest detection box area as a fraction of the whole frame.
|
| 17 |
+
# A single 2D photo has no true scale, so this is a heuristic proxy (tune as needed).
|
| 18 |
+
SIZE_LARGE = 0.12
|
| 19 |
+
SIZE_MEDIUM = 0.04
|
| 20 |
+
CONF_THRESHOLD = 0.25
|
| 21 |
+
MODEL_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "pothole-yolov8.pt")
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def main():
|
| 25 |
+
if len(sys.argv) < 2:
|
| 26 |
+
print(json.dumps({"error": "usage: detect_pothole.py <image_path>"}))
|
| 27 |
+
return
|
| 28 |
+
image_path = sys.argv[1]
|
| 29 |
+
if not os.path.exists(image_path):
|
| 30 |
+
print(json.dumps({"error": "image not found: " + image_path}))
|
| 31 |
+
return
|
| 32 |
+
if not os.path.exists(MODEL_FILE):
|
| 33 |
+
print(json.dumps({"error": "model file missing: " + MODEL_FILE}))
|
| 34 |
+
return
|
| 35 |
+
try:
|
| 36 |
+
from ultralytics import YOLO
|
| 37 |
+
except Exception as e: # noqa: BLE001
|
| 38 |
+
print(json.dumps({"error": "ultralytics not installed: " + str(e)}))
|
| 39 |
+
return
|
| 40 |
+
|
| 41 |
+
try:
|
| 42 |
+
model = YOLO(MODEL_FILE)
|
| 43 |
+
result = model.predict(image_path, conf=CONF_THRESHOLD, verbose=False)[0]
|
| 44 |
+
h, w = result.orig_shape
|
| 45 |
+
frame_area = float(h * w) if h and w else 1.0
|
| 46 |
+
|
| 47 |
+
boxes = []
|
| 48 |
+
max_frac = 0.0
|
| 49 |
+
max_conf = 0.0
|
| 50 |
+
for b in result.boxes:
|
| 51 |
+
x1, y1, x2, y2 = (float(v) for v in b.xyxy[0].tolist())
|
| 52 |
+
conf = float(b.conf[0])
|
| 53 |
+
frac = ((x2 - x1) * (y2 - y1)) / frame_area
|
| 54 |
+
boxes.append({
|
| 55 |
+
"x1": round(x1), "y1": round(y1), "x2": round(x2), "y2": round(y2),
|
| 56 |
+
"conf": round(conf, 3), "areaFraction": round(frac, 4),
|
| 57 |
+
})
|
| 58 |
+
max_frac = max(max_frac, frac)
|
| 59 |
+
max_conf = max(max_conf, conf)
|
| 60 |
+
|
| 61 |
+
is_pothole = len(boxes) > 0
|
| 62 |
+
if not is_pothole:
|
| 63 |
+
size = None
|
| 64 |
+
elif max_frac >= SIZE_LARGE:
|
| 65 |
+
size = "Large"
|
| 66 |
+
elif max_frac >= SIZE_MEDIUM:
|
| 67 |
+
size = "Medium"
|
| 68 |
+
else:
|
| 69 |
+
size = "Small"
|
| 70 |
+
|
| 71 |
+
print(json.dumps({
|
| 72 |
+
"isPothole": is_pothole,
|
| 73 |
+
"confidence": round(max_conf, 3),
|
| 74 |
+
"size": size,
|
| 75 |
+
"count": len(boxes),
|
| 76 |
+
"largestAreaFraction": round(max_frac, 4),
|
| 77 |
+
"boxes": boxes,
|
| 78 |
+
"model": "peterhdd/pothole-detection-yolov8",
|
| 79 |
+
}))
|
| 80 |
+
except Exception as e: # noqa: BLE001
|
| 81 |
+
print(json.dumps({"error": str(e)}))
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
if __name__ == "__main__":
|
| 85 |
+
main()
|
ai/gen_closeout.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
"""Generate a clean Pothole Repair CLOSEOUT / Job Completion Sheet (.docx).
|
| 3 |
+
|
| 4 |
+
The post-repair record the crew completes (often via Box Forms): what was done,
|
| 5 |
+
materials, hours, before/after photos, actual cost, inspection, sign-off.
|
| 6 |
+
|
| 7 |
+
Usage: python gen_closeout.py <spec.json> <output.docx>
|
| 8 |
+
Works for live docs AND as a Box Doc Gen template when values are {{tags}}.
|
| 9 |
+
"""
|
| 10 |
+
import sys
|
| 11 |
+
import os
|
| 12 |
+
import json
|
| 13 |
+
|
| 14 |
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
| 15 |
+
from docx import Document
|
| 16 |
+
from docx.shared import Pt, Inches, RGBColor
|
| 17 |
+
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
| 18 |
+
from docx.enum.table import WD_ALIGN_VERTICAL
|
| 19 |
+
from docx.oxml.ns import qn
|
| 20 |
+
from docx.oxml import OxmlElement
|
| 21 |
+
# reuse the work order's polished styling helpers for a consistent look
|
| 22 |
+
from gen_workorder import (
|
| 23 |
+
NAVY, GREY, LIGHT, DARK, WHITE,
|
| 24 |
+
shade, no_borders, grid, cell_pad, widths, run, spacer, section, info_grid, sign_block,
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
GREEN = RGBColor(0x15, 0x7A, 0x3F)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def photo_row(doc, before, after):
|
| 31 |
+
"""Two-column before/after photo row (embeds files; otherwise shows the link/placeholder)."""
|
| 32 |
+
t = doc.add_table(rows=2, cols=2)
|
| 33 |
+
no_borders(t)
|
| 34 |
+
cell_pad(t, top=20, bottom=20, left=20, right=80)
|
| 35 |
+
run(t.cell(0, 0).paragraphs[0], "BEFORE (reported)", size=7.5, bold=True, color=LIGHT, caps=True)
|
| 36 |
+
run(t.cell(0, 1).paragraphs[0], "AFTER (repaired)", size=7.5, bold=True, color=LIGHT, caps=True)
|
| 37 |
+
for col, val in ((0, before), (1, after)):
|
| 38 |
+
cell = t.cell(1, col)
|
| 39 |
+
p = cell.paragraphs[0]
|
| 40 |
+
if val and os.path.exists(str(val)):
|
| 41 |
+
try:
|
| 42 |
+
run(p, "")
|
| 43 |
+
p.add_run().add_picture(val, width=Inches(3.0))
|
| 44 |
+
continue
|
| 45 |
+
except Exception:
|
| 46 |
+
pass
|
| 47 |
+
if val:
|
| 48 |
+
run(p, str(val), size=9, color=NAVY)
|
| 49 |
+
else:
|
| 50 |
+
run(p, "β photo attached", size=9.5, color=GREY)
|
| 51 |
+
widths(t, [3.45, 3.45])
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def main():
|
| 55 |
+
if len(sys.argv) < 3:
|
| 56 |
+
print(json.dumps({"error": "usage: gen_closeout.py <spec.json> <output.docx>"}))
|
| 57 |
+
return
|
| 58 |
+
with open(sys.argv[1], "r", encoding="utf-8") as f:
|
| 59 |
+
s = json.load(f)
|
| 60 |
+
out_path = sys.argv[2]
|
| 61 |
+
g = lambda k, d="": s.get(k) if s.get(k) not in (None, "") else d # noqa: E731
|
| 62 |
+
|
| 63 |
+
doc = Document()
|
| 64 |
+
normal = doc.styles["Normal"]
|
| 65 |
+
normal.font.name = "Calibri"
|
| 66 |
+
normal.font.size = Pt(10)
|
| 67 |
+
normal.font.color.rgb = DARK
|
| 68 |
+
for sec in doc.sections:
|
| 69 |
+
sec.left_margin = sec.right_margin = Inches(0.75)
|
| 70 |
+
sec.top_margin = sec.bottom_margin = Inches(0.6)
|
| 71 |
+
|
| 72 |
+
# ββ Header βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 73 |
+
head = doc.add_table(rows=1, cols=2)
|
| 74 |
+
no_borders(head)
|
| 75 |
+
cell_pad(head, 0, 0, 0, 0)
|
| 76 |
+
L = head.cell(0, 0).paragraphs[0]
|
| 77 |
+
run(L, g("city", "City of Philadelphia"), size=9, bold=True, color=NAVY)
|
| 78 |
+
L.add_run("\n")
|
| 79 |
+
run(L, g("department", "Department of Streets Β· Road Maintenance Division"), size=8.5, color=GREY)
|
| 80 |
+
L.add_run("\n\n")
|
| 81 |
+
run(L, "Pothole Repair β Closeout Job Sheet", size=18, bold=True, color=DARK)
|
| 82 |
+
R = head.cell(0, 1)
|
| 83 |
+
rp = R.paragraphs[0]
|
| 84 |
+
rp.alignment = WD_ALIGN_PARAGRAPH.RIGHT
|
| 85 |
+
run(rp, "CASE ID", size=7.5, bold=True, color=LIGHT, caps=True)
|
| 86 |
+
rp2 = R.add_paragraph(); rp2.alignment = WD_ALIGN_PARAGRAPH.RIGHT
|
| 87 |
+
run(rp2, g("caseId", "____________"), size=15, bold=True, color=NAVY)
|
| 88 |
+
rp4 = R.add_paragraph(); rp4.alignment = WD_ALIGN_PARAGRAPH.RIGHT
|
| 89 |
+
run(rp4, "Status ", size=9, color=GREY)
|
| 90 |
+
run(rp4, "COMPLETED", size=9, bold=True, color=GREEN)
|
| 91 |
+
widths(head, [4.0, 3.0])
|
| 92 |
+
|
| 93 |
+
rule = doc.add_paragraph(); rule.paragraph_format.space_before = Pt(4); rule.paragraph_format.space_after = Pt(2)
|
| 94 |
+
pbdr = OxmlElement("w:pBdr"); b = OxmlElement("w:bottom")
|
| 95 |
+
b.set(qn("w:val"), "single"); b.set(qn("w:sz"), "18"); b.set(qn("w:color"), "0B3D91")
|
| 96 |
+
pbdr.append(b); rule._p.get_or_add_pPr().append(pbdr)
|
| 97 |
+
|
| 98 |
+
# ββ Repair details (ONLY the crew closeout form fields) ββββββββββββββββββ
|
| 99 |
+
section(doc, "Repair Details")
|
| 100 |
+
info_grid(doc, [
|
| 101 |
+
("Case ID", g("caseId")),
|
| 102 |
+
("Repair Completed?", g("repairCompleted", "β Yes β No")),
|
| 103 |
+
("Repair Method", g("repairMethods", "____________________")),
|
| 104 |
+
("Materials Used", g("materialsUsed", "____________________")),
|
| 105 |
+
("Labor Hours", g("laborHours", "________")),
|
| 106 |
+
])
|
| 107 |
+
|
| 108 |
+
# ββ Crew (from the form) βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 109 |
+
section(doc, "Crew")
|
| 110 |
+
info_grid(doc, [
|
| 111 |
+
("Crew Name", g("crewName", "____________________")),
|
| 112 |
+
("Crew Contact Number", g("crewContact", "____________________")),
|
| 113 |
+
], cols=2)
|
| 114 |
+
|
| 115 |
+
# ββ Reported photo (the citizen's pre-repair photo, so the crew sees what to fix) ββ
|
| 116 |
+
reported = s.get("reportedPhotoPath")
|
| 117 |
+
if reported and os.path.exists(str(reported)):
|
| 118 |
+
section(doc, "Reported Photo (Pre-Repair)")
|
| 119 |
+
try:
|
| 120 |
+
doc.add_picture(reported, width=Inches(4.2))
|
| 121 |
+
except Exception:
|
| 122 |
+
pass
|
| 123 |
+
|
| 124 |
+
# ββ After photo (from the form) ββββββββββββββββββββββββββββββββββββββββββ
|
| 125 |
+
section(doc, "After Photo")
|
| 126 |
+
after = s.get("afterPhotoPath") or g("afterPhoto")
|
| 127 |
+
if after and os.path.exists(str(after)):
|
| 128 |
+
try:
|
| 129 |
+
doc.add_picture(after, width=Inches(4.2))
|
| 130 |
+
except Exception:
|
| 131 |
+
run(doc.add_paragraph(), str(after), size=9.5, color=NAVY)
|
| 132 |
+
elif after:
|
| 133 |
+
run(doc.add_paragraph(), str(after), size=9.5, color=NAVY)
|
| 134 |
+
else:
|
| 135 |
+
run(doc.add_paragraph(), "β photo attached to closeout form", size=9.5, color=GREY)
|
| 136 |
+
|
| 137 |
+
# ββ Sign-off βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 138 |
+
section(doc, "Sign-Off")
|
| 139 |
+
sign_block(doc, ["Crew Foreman", "Supervisor"])
|
| 140 |
+
|
| 141 |
+
foot = doc.add_paragraph(); foot.paragraph_format.space_before = Pt(10)
|
| 142 |
+
run(foot, "Generated by PotholeIQ" + (" Β· " + g("completedDate") if g("completedDate") else ""), size=8, italic=True, color=LIGHT)
|
| 143 |
+
|
| 144 |
+
doc.save(out_path)
|
| 145 |
+
print(json.dumps({"ok": True, "output": out_path}))
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
if __name__ == "__main__":
|
| 149 |
+
main()
|
ai/gen_doc.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
"""Generate a styled .docx (Review Sheet or Work Order) with the pothole photo embedded.
|
| 3 |
+
|
| 4 |
+
Box Doc Gen cannot reliably embed an image-from-URL, so we render the document here
|
| 5 |
+
with python-docx (the image embeds cleanly and Box previews .docx natively).
|
| 6 |
+
|
| 7 |
+
Usage: python gen_doc.py <spec.json> <output.docx>
|
| 8 |
+
|
| 9 |
+
spec.json:
|
| 10 |
+
{
|
| 11 |
+
"title": "...", "subtitle": "...",
|
| 12 |
+
"meta": [["Label","Value"], ...], # small lines under the title (optional)
|
| 13 |
+
"sections": [ {"heading":"...", "fields":[["Label","Value"], ...]}, ... ],
|
| 14 |
+
"imagePath": "/abs/path/photo.jpg" | null,
|
| 15 |
+
"imageCaption": "..." | null,
|
| 16 |
+
"notes": "..." | null,
|
| 17 |
+
"accent": "0061D5" # hex, optional (Box blue default)
|
| 18 |
+
}
|
| 19 |
+
"""
|
| 20 |
+
import sys
|
| 21 |
+
import os
|
| 22 |
+
import json
|
| 23 |
+
from docx import Document
|
| 24 |
+
from docx.shared import Pt, Inches, RGBColor
|
| 25 |
+
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def hex_rgb(h, default=(0x00, 0x61, 0xD5)):
|
| 29 |
+
try:
|
| 30 |
+
h = (h or "").lstrip("#")
|
| 31 |
+
return RGBColor(int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))
|
| 32 |
+
except Exception:
|
| 33 |
+
return RGBColor(*default)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
GREY = RGBColor(0x5B, 0x64, 0x76)
|
| 37 |
+
DARK = RGBColor(0x17, 0x20, 0x33)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def add_title(doc, title, subtitle, accent):
|
| 41 |
+
p = doc.add_paragraph()
|
| 42 |
+
r = p.add_run(title or "")
|
| 43 |
+
r.bold = True
|
| 44 |
+
r.font.size = Pt(22)
|
| 45 |
+
r.font.color.rgb = accent
|
| 46 |
+
if subtitle:
|
| 47 |
+
p2 = doc.add_paragraph()
|
| 48 |
+
r2 = p2.add_run(subtitle)
|
| 49 |
+
r2.font.size = Pt(11)
|
| 50 |
+
r2.font.color.rgb = GREY
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def add_meta(doc, meta):
|
| 54 |
+
if not meta:
|
| 55 |
+
return
|
| 56 |
+
for label, value in meta:
|
| 57 |
+
p = doc.add_paragraph()
|
| 58 |
+
p.paragraph_format.space_after = Pt(0)
|
| 59 |
+
rl = p.add_run(f"{label}: ")
|
| 60 |
+
rl.bold = True
|
| 61 |
+
rl.font.size = Pt(9)
|
| 62 |
+
rl.font.color.rgb = GREY
|
| 63 |
+
rv = p.add_run("" if value is None else str(value))
|
| 64 |
+
rv.font.size = Pt(9)
|
| 65 |
+
rv.font.color.rgb = DARK
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def add_heading(doc, text, accent):
|
| 69 |
+
p = doc.add_paragraph()
|
| 70 |
+
p.paragraph_format.space_before = Pt(12)
|
| 71 |
+
r = p.add_run((text or "").upper())
|
| 72 |
+
r.bold = True
|
| 73 |
+
r.font.size = Pt(11)
|
| 74 |
+
r.font.color.rgb = accent
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def add_images_grid(doc, images, accent):
|
| 78 |
+
"""Lay out a list of {path, caption} images. Two images -> side-by-side
|
| 79 |
+
before/after; otherwise stacked. Each gets its caption underneath."""
|
| 80 |
+
valid = [im for im in images if im.get("path") and os.path.exists(im["path"])]
|
| 81 |
+
if not valid:
|
| 82 |
+
return
|
| 83 |
+
add_heading(doc, "Before / After Photos", accent)
|
| 84 |
+
if len(valid) == 2:
|
| 85 |
+
table = doc.add_table(rows=1, cols=2)
|
| 86 |
+
for cell, im in zip(table.rows[0].cells, valid):
|
| 87 |
+
p = cell.paragraphs[0]
|
| 88 |
+
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
| 89 |
+
try:
|
| 90 |
+
p.add_run().add_picture(im["path"], width=Inches(3.05))
|
| 91 |
+
except Exception as e: # noqa: BLE001
|
| 92 |
+
p.add_run(f"[photo could not be embedded: {e}]").font.size = Pt(9)
|
| 93 |
+
cap = cell.add_paragraph()
|
| 94 |
+
cap.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
| 95 |
+
rc = cap.add_run(im.get("caption") or "")
|
| 96 |
+
rc.bold = True
|
| 97 |
+
rc.font.size = Pt(9)
|
| 98 |
+
rc.font.color.rgb = GREY
|
| 99 |
+
return
|
| 100 |
+
for im in valid:
|
| 101 |
+
try:
|
| 102 |
+
doc.add_picture(im["path"], width=Inches(4.6))
|
| 103 |
+
doc.paragraphs[-1].alignment = WD_ALIGN_PARAGRAPH.LEFT
|
| 104 |
+
except Exception as e: # noqa: BLE001
|
| 105 |
+
doc.add_paragraph().add_run(f"[photo could not be embedded: {e}]").font.size = Pt(9)
|
| 106 |
+
cap = im.get("caption")
|
| 107 |
+
if cap:
|
| 108 |
+
pc = doc.add_paragraph()
|
| 109 |
+
rc = pc.add_run(cap)
|
| 110 |
+
rc.italic = True
|
| 111 |
+
rc.font.size = Pt(9)
|
| 112 |
+
rc.font.color.rgb = GREY
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def add_kv_table(doc, fields):
|
| 116 |
+
table = doc.add_table(rows=0, cols=2)
|
| 117 |
+
table.style = "Light List Accent 1"
|
| 118 |
+
for label, value in fields:
|
| 119 |
+
cells = table.add_row().cells
|
| 120 |
+
rl = cells[0].paragraphs[0].add_run(str(label))
|
| 121 |
+
rl.bold = True
|
| 122 |
+
rl.font.size = Pt(9)
|
| 123 |
+
rl.font.color.rgb = GREY
|
| 124 |
+
val = "β" if value in (None, "") else str(value)
|
| 125 |
+
rv = cells[1].paragraphs[0].add_run(val)
|
| 126 |
+
rv.font.size = Pt(11)
|
| 127 |
+
rv.font.color.rgb = DARK
|
| 128 |
+
for row in table.rows:
|
| 129 |
+
row.cells[0].width = Inches(2.3)
|
| 130 |
+
row.cells[1].width = Inches(4.0)
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def main():
|
| 134 |
+
if len(sys.argv) < 3:
|
| 135 |
+
print(json.dumps({"error": "usage: gen_doc.py <spec.json> <output.docx>"}))
|
| 136 |
+
return
|
| 137 |
+
spec_path, out_path = sys.argv[1], sys.argv[2]
|
| 138 |
+
with open(spec_path, "r", encoding="utf-8") as f:
|
| 139 |
+
spec = json.load(f)
|
| 140 |
+
|
| 141 |
+
accent = hex_rgb(spec.get("accent"))
|
| 142 |
+
doc = Document()
|
| 143 |
+
for s in doc.sections:
|
| 144 |
+
s.left_margin = s.right_margin = Inches(0.8)
|
| 145 |
+
s.top_margin = s.bottom_margin = Inches(0.7)
|
| 146 |
+
|
| 147 |
+
add_title(doc, spec.get("title"), spec.get("subtitle"), accent)
|
| 148 |
+
add_meta(doc, spec.get("meta") or [])
|
| 149 |
+
|
| 150 |
+
for section in spec.get("sections") or []:
|
| 151 |
+
add_heading(doc, section.get("heading"), accent)
|
| 152 |
+
add_kv_table(doc, section.get("fields") or [])
|
| 153 |
+
|
| 154 |
+
# Multi-image (e.g. before/after) takes precedence over a single site photo.
|
| 155 |
+
images = spec.get("images")
|
| 156 |
+
if isinstance(images, list) and images:
|
| 157 |
+
add_images_grid(doc, images, accent)
|
| 158 |
+
|
| 159 |
+
image_path = spec.get("imagePath")
|
| 160 |
+
if not (isinstance(images, list) and images) and image_path and os.path.exists(image_path):
|
| 161 |
+
add_heading(doc, "Site Photo", accent)
|
| 162 |
+
try:
|
| 163 |
+
doc.add_picture(image_path, width=Inches(4.6))
|
| 164 |
+
doc.paragraphs[-1].alignment = WD_ALIGN_PARAGRAPH.LEFT
|
| 165 |
+
cap = spec.get("imageCaption")
|
| 166 |
+
if cap:
|
| 167 |
+
pc = doc.add_paragraph()
|
| 168 |
+
rc = pc.add_run(cap)
|
| 169 |
+
rc.italic = True
|
| 170 |
+
rc.font.size = Pt(9)
|
| 171 |
+
rc.font.color.rgb = GREY
|
| 172 |
+
except Exception as e: # noqa: BLE001
|
| 173 |
+
pe = doc.add_paragraph()
|
| 174 |
+
pe.add_run(f"[photo could not be embedded: {e}]").font.size = Pt(9)
|
| 175 |
+
|
| 176 |
+
notes = spec.get("notes")
|
| 177 |
+
if notes:
|
| 178 |
+
add_heading(doc, "Notes", accent)
|
| 179 |
+
doc.add_paragraph(str(notes))
|
| 180 |
+
|
| 181 |
+
doc.save(out_path)
|
| 182 |
+
print(json.dumps({"ok": True, "output": out_path}))
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
if __name__ == "__main__":
|
| 186 |
+
main()
|
ai/gen_workorder.py
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
"""Generate a clean, professional municipal Pothole Repair WORK ORDER (.docx).
|
| 3 |
+
|
| 4 |
+
Modeled on standard public-works work order forms. Known data is filled in;
|
| 5 |
+
crew-completed sections are blank lines / checkboxes.
|
| 6 |
+
|
| 7 |
+
Usage: python gen_workorder.py <spec.json> <output.docx>
|
| 8 |
+
(Same spec keys as before β works for live docs AND as a Box Doc Gen template
|
| 9 |
+
when values are passed as {{tags}}.)
|
| 10 |
+
"""
|
| 11 |
+
import sys
|
| 12 |
+
import os
|
| 13 |
+
import json
|
| 14 |
+
from docx import Document
|
| 15 |
+
from docx.shared import Pt, Inches, RGBColor
|
| 16 |
+
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
| 17 |
+
from docx.enum.table import WD_ALIGN_VERTICAL
|
| 18 |
+
from docx.oxml.ns import qn
|
| 19 |
+
from docx.oxml import OxmlElement
|
| 20 |
+
|
| 21 |
+
NAVY = RGBColor(0x0B, 0x3D, 0x91)
|
| 22 |
+
GREY = RGBColor(0x6B, 0x72, 0x80)
|
| 23 |
+
LIGHT = RGBColor(0x9A, 0xA1, 0xAD)
|
| 24 |
+
DARK = RGBColor(0x1A, 0x1F, 0x2B)
|
| 25 |
+
WHITE = RGBColor(0xFF, 0xFF, 0xFF)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# ββ low-level helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 29 |
+
def shade(cell, hex_fill):
|
| 30 |
+
tcPr = cell._tc.get_or_add_tcPr()
|
| 31 |
+
shd = OxmlElement("w:shd")
|
| 32 |
+
shd.set(qn("w:val"), "clear")
|
| 33 |
+
shd.set(qn("w:color"), "auto")
|
| 34 |
+
shd.set(qn("w:fill"), hex_fill)
|
| 35 |
+
tcPr.append(shd)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def no_borders(table):
|
| 39 |
+
borders = OxmlElement("w:tblBorders")
|
| 40 |
+
for edge in ("top", "left", "bottom", "right", "insideH", "insideV"):
|
| 41 |
+
e = OxmlElement(f"w:{edge}")
|
| 42 |
+
e.set(qn("w:val"), "none")
|
| 43 |
+
borders.append(e)
|
| 44 |
+
table._tbl.tblPr.append(borders)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def hairlines(table, color="E3E7EE"):
|
| 48 |
+
"""Subtle horizontal rules only β clean ledger look."""
|
| 49 |
+
borders = OxmlElement("w:tblBorders")
|
| 50 |
+
for edge in ("top", "left", "right", "insideV"):
|
| 51 |
+
e = OxmlElement(f"w:{edge}")
|
| 52 |
+
e.set(qn("w:val"), "none")
|
| 53 |
+
borders.append(e)
|
| 54 |
+
for edge in ("bottom", "insideH"):
|
| 55 |
+
e = OxmlElement(f"w:{edge}")
|
| 56 |
+
e.set(qn("w:val"), "single")
|
| 57 |
+
e.set(qn("w:sz"), "4")
|
| 58 |
+
e.set(qn("w:color"), color)
|
| 59 |
+
borders.append(e)
|
| 60 |
+
table._tbl.tblPr.append(borders)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def grid(table, color="D7DCE5"):
|
| 64 |
+
borders = OxmlElement("w:tblBorders")
|
| 65 |
+
for edge in ("top", "left", "bottom", "right", "insideH", "insideV"):
|
| 66 |
+
e = OxmlElement(f"w:{edge}")
|
| 67 |
+
e.set(qn("w:val"), "single")
|
| 68 |
+
e.set(qn("w:sz"), "4")
|
| 69 |
+
e.set(qn("w:color"), color)
|
| 70 |
+
borders.append(e)
|
| 71 |
+
table._tbl.tblPr.append(borders)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def cell_pad(table, top=60, bottom=60, left=100, right=100):
|
| 75 |
+
mar = OxmlElement("w:tblCellMar")
|
| 76 |
+
for side, val in (("top", top), ("bottom", bottom), ("left", left), ("right", right)):
|
| 77 |
+
e = OxmlElement(f"w:{side}")
|
| 78 |
+
e.set(qn("w:w"), str(val))
|
| 79 |
+
e.set(qn("w:type"), "dxa")
|
| 80 |
+
mar.append(e)
|
| 81 |
+
table._tbl.tblPr.append(mar)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def widths(table, ws):
|
| 85 |
+
for row in table.rows:
|
| 86 |
+
for i, w in enumerate(ws):
|
| 87 |
+
if i < len(row.cells):
|
| 88 |
+
row.cells[i].width = Inches(w)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def run(p, text, size=10, bold=False, color=DARK, italic=False, caps=False):
|
| 92 |
+
r = p.add_run(text)
|
| 93 |
+
r.bold = bold
|
| 94 |
+
r.italic = italic
|
| 95 |
+
r.font.size = Pt(size)
|
| 96 |
+
r.font.color.rgb = color
|
| 97 |
+
if caps:
|
| 98 |
+
rPr = r._element.get_or_add_rPr()
|
| 99 |
+
c = OxmlElement("w:caps")
|
| 100 |
+
c.set(qn("w:val"), "true")
|
| 101 |
+
rPr.append(c)
|
| 102 |
+
return r
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def spacer(doc, pt=6):
|
| 106 |
+
p = doc.add_paragraph()
|
| 107 |
+
p.paragraph_format.space_after = Pt(0)
|
| 108 |
+
p.paragraph_format.space_before = Pt(0)
|
| 109 |
+
p.add_run("").font.size = Pt(pt)
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
# ββ building blocks ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 113 |
+
def section(doc, text):
|
| 114 |
+
spacer(doc, 4)
|
| 115 |
+
t = doc.add_table(rows=1, cols=1)
|
| 116 |
+
no_borders(t)
|
| 117 |
+
cell_pad(t, top=50, bottom=50, left=120, right=120)
|
| 118 |
+
c = t.cell(0, 0)
|
| 119 |
+
shade(c, "0B3D91")
|
| 120 |
+
p = c.paragraphs[0]
|
| 121 |
+
run(p, text, size=9.5, bold=True, color=WHITE, caps=True)
|
| 122 |
+
spacer(doc, 2)
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def info_grid(doc, pairs, cols=2):
|
| 126 |
+
rows = (len(pairs) + cols - 1) // cols
|
| 127 |
+
t = doc.add_table(rows=rows, cols=cols)
|
| 128 |
+
hairlines(t)
|
| 129 |
+
cell_pad(t, top=70, bottom=70, left=40, right=160)
|
| 130 |
+
for i, (label, value) in enumerate(pairs):
|
| 131 |
+
cell = t.cell(i // cols, i % cols)
|
| 132 |
+
cell.vertical_alignment = WD_ALIGN_VERTICAL.CENTER
|
| 133 |
+
p = cell.paragraphs[0]
|
| 134 |
+
p.paragraph_format.space_after = Pt(1)
|
| 135 |
+
run(p, label, size=7.5, bold=True, color=LIGHT, caps=True)
|
| 136 |
+
p2 = cell.add_paragraph()
|
| 137 |
+
p2.paragraph_format.space_before = Pt(0)
|
| 138 |
+
run(p2, "β" if value in (None, "") else str(value), size=10.5, color=DARK)
|
| 139 |
+
widths(t, [3.45] * cols)
|
| 140 |
+
return t
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def checklist(doc, title, items, per_row=3):
|
| 144 |
+
p = doc.add_paragraph()
|
| 145 |
+
p.paragraph_format.space_after = Pt(3)
|
| 146 |
+
run(p, title, size=8.5, bold=True, color=GREY, caps=True)
|
| 147 |
+
rows = (len(items) + per_row - 1) // per_row
|
| 148 |
+
t = doc.add_table(rows=rows, cols=per_row)
|
| 149 |
+
no_borders(t)
|
| 150 |
+
cell_pad(t, top=30, bottom=30, left=20, right=40)
|
| 151 |
+
for i, item in enumerate(items):
|
| 152 |
+
run(t.cell(i // per_row, i % per_row).paragraphs[0], "β " + item, size=9.5, color=DARK)
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def sign_block(doc, roles):
|
| 156 |
+
t = doc.add_table(rows=len(roles), cols=3)
|
| 157 |
+
no_borders(t)
|
| 158 |
+
cell_pad(t, top=130, bottom=40, left=0, right=80)
|
| 159 |
+
for i, role in enumerate(roles):
|
| 160 |
+
run(t.cell(i, 0).paragraphs[0], role, size=9, bold=True, color=GREY, caps=True)
|
| 161 |
+
run(t.cell(i, 1).paragraphs[0], "β" * 26, size=10, color=RGBColor(0xC2, 0xC8, 0xD2))
|
| 162 |
+
run(t.cell(i, 2).paragraphs[0], "Date: " + "β" * 10, size=9, color=GREY)
|
| 163 |
+
widths(t, [1.7, 3.2, 1.4])
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
# ββ main βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 167 |
+
def main():
|
| 168 |
+
if len(sys.argv) < 3:
|
| 169 |
+
print(json.dumps({"error": "usage: gen_workorder.py <spec.json> <output.docx>"}))
|
| 170 |
+
return
|
| 171 |
+
with open(sys.argv[1], "r", encoding="utf-8") as f:
|
| 172 |
+
s = json.load(f)
|
| 173 |
+
out_path = sys.argv[2]
|
| 174 |
+
g = lambda k, d="": s.get(k) if s.get(k) not in (None, "") else d # noqa: E731
|
| 175 |
+
|
| 176 |
+
doc = Document()
|
| 177 |
+
normal = doc.styles["Normal"]
|
| 178 |
+
normal.font.name = "Calibri"
|
| 179 |
+
normal.font.size = Pt(10)
|
| 180 |
+
normal.font.color.rgb = DARK
|
| 181 |
+
for sec in doc.sections:
|
| 182 |
+
sec.left_margin = sec.right_margin = Inches(0.75)
|
| 183 |
+
sec.top_margin = sec.bottom_margin = Inches(0.6)
|
| 184 |
+
|
| 185 |
+
# ββ Header band ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 186 |
+
head = doc.add_table(rows=1, cols=2)
|
| 187 |
+
no_borders(head)
|
| 188 |
+
cell_pad(head, top=0, bottom=0, left=0, right=0)
|
| 189 |
+
L = head.cell(0, 0).paragraphs[0]
|
| 190 |
+
run(L, g("city", "City of Philadelphia"), size=9, bold=True, color=NAVY)
|
| 191 |
+
L.add_run("\n")
|
| 192 |
+
run(L, g("department", "Department of Streets Β· Road Maintenance Division"), size=8.5, color=GREY)
|
| 193 |
+
L.add_run("\n\n")
|
| 194 |
+
run(L, "Pothole Repair Work Order", size=19, bold=True, color=DARK)
|
| 195 |
+
|
| 196 |
+
R = head.cell(0, 1)
|
| 197 |
+
R.vertical_alignment = WD_ALIGN_VERTICAL.TOP
|
| 198 |
+
rp = R.paragraphs[0]
|
| 199 |
+
rp.alignment = WD_ALIGN_PARAGRAPH.RIGHT
|
| 200 |
+
run(rp, "WORK ORDER NO.", size=7.5, bold=True, color=LIGHT, caps=True)
|
| 201 |
+
rp2 = R.add_paragraph()
|
| 202 |
+
rp2.alignment = WD_ALIGN_PARAGRAPH.RIGHT
|
| 203 |
+
run(rp2, g("workOrderId", "WO-________"), size=15, bold=True, color=NAVY)
|
| 204 |
+
rp3 = R.add_paragraph()
|
| 205 |
+
rp3.alignment = WD_ALIGN_PARAGRAPH.RIGHT
|
| 206 |
+
run(rp3, "Issued ", size=9, color=GREY)
|
| 207 |
+
run(rp3, g("issuedDate", "____________"), size=9, bold=True, color=DARK)
|
| 208 |
+
rp4 = R.add_paragraph()
|
| 209 |
+
rp4.alignment = WD_ALIGN_PARAGRAPH.RIGHT
|
| 210 |
+
run(rp4, "Priority ", size=9, color=GREY)
|
| 211 |
+
run(rp4, g("priority", "________"), size=9, bold=True, color=RGBColor(0xC2, 0x41, 0x0C))
|
| 212 |
+
run(rp4, " Status ", size=9, color=GREY)
|
| 213 |
+
run(rp4, g("status", "OPEN"), size=9, bold=True, color=DARK)
|
| 214 |
+
widths(head, [4.0, 3.0])
|
| 215 |
+
|
| 216 |
+
# navy rule
|
| 217 |
+
rule = doc.add_paragraph()
|
| 218 |
+
rule.paragraph_format.space_before = Pt(4)
|
| 219 |
+
rule.paragraph_format.space_after = Pt(2)
|
| 220 |
+
pbdr = OxmlElement("w:pBdr")
|
| 221 |
+
b = OxmlElement("w:bottom")
|
| 222 |
+
b.set(qn("w:val"), "single")
|
| 223 |
+
b.set(qn("w:sz"), "18")
|
| 224 |
+
b.set(qn("w:color"), "0B3D91")
|
| 225 |
+
pbdr.append(b)
|
| 226 |
+
rule._p.get_or_add_pPr().append(pbdr)
|
| 227 |
+
|
| 228 |
+
# ββ Sections βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 229 |
+
section(doc, "Request Information")
|
| 230 |
+
info_grid(doc, [
|
| 231 |
+
("Case / Request ID", g("caseId")),
|
| 232 |
+
("Date Reported", g("reportedDate")),
|
| 233 |
+
("Reported By", g("reportedBy", "Citizen")),
|
| 234 |
+
("Source", g("source", "Citizen Portal (PotholeIQ)")),
|
| 235 |
+
("AI Review Status", g("aiReviewStatus")),
|
| 236 |
+
("Duplicate Status", g("duplicateStatus")),
|
| 237 |
+
])
|
| 238 |
+
|
| 239 |
+
section(doc, "Location")
|
| 240 |
+
info_grid(doc, [
|
| 241 |
+
("Address", g("address")),
|
| 242 |
+
("GPS Coordinates", g("coords")),
|
| 243 |
+
("Ward", g("ward")),
|
| 244 |
+
("District", g("district")),
|
| 245 |
+
("Maintenance Zone", g("zone")),
|
| 246 |
+
("Cross Streets", g("crossStreets", "β")),
|
| 247 |
+
])
|
| 248 |
+
|
| 249 |
+
section(doc, "Defect Description")
|
| 250 |
+
info_grid(doc, [
|
| 251 |
+
("Defect Type", g("defectType", "Pothole")),
|
| 252 |
+
("Pothole Size (AI)", g("size")),
|
| 253 |
+
("Severity Score", g("severityScore")),
|
| 254 |
+
("Severity Level", g("severityLevel")),
|
| 255 |
+
("Road Surface", g("roadSurface", "Asphalt")),
|
| 256 |
+
("Weather Risk", g("weatherRisk")),
|
| 257 |
+
])
|
| 258 |
+
|
| 259 |
+
if s.get("summary"):
|
| 260 |
+
section(doc, "AI Case Summary")
|
| 261 |
+
run(doc.add_paragraph(), s.get("summary"), size=10, color=DARK)
|
| 262 |
+
|
| 263 |
+
img = s.get("imagePath")
|
| 264 |
+
if img and os.path.exists(img):
|
| 265 |
+
section(doc, "Site Photo (Pre-Repair)")
|
| 266 |
+
try:
|
| 267 |
+
doc.add_picture(img, width=Inches(4.4))
|
| 268 |
+
run(doc.add_paragraph(), "Citizen-submitted, AI-classified photo. Verify extent on arrival.", size=8.5, italic=True, color=GREY)
|
| 269 |
+
except Exception as e: # noqa: BLE001
|
| 270 |
+
run(doc.add_paragraph(), f"[photo not embedded: {e}]", size=9, color=GREY)
|
| 271 |
+
elif s.get("photoUrl"):
|
| 272 |
+
section(doc, "Site Photo (Pre-Repair)")
|
| 273 |
+
p = doc.add_paragraph()
|
| 274 |
+
run(p, "View photo: ", size=9.5, bold=True, color=GREY)
|
| 275 |
+
run(p, s.get("photoUrl"), size=9.5, color=NAVY)
|
| 276 |
+
|
| 277 |
+
section(doc, "Assignment & Scheduling")
|
| 278 |
+
info_grid(doc, [
|
| 279 |
+
("Assigned Crew / Foreman", g("assignedCrew", "____________________")),
|
| 280 |
+
("Crew ID", g("crewId", "____________")),
|
| 281 |
+
("Scheduled Date", g("scheduledDate", "____________")),
|
| 282 |
+
("Target Completion (SLA)", g("targetDate", "____________")),
|
| 283 |
+
("Estimated Labor Hours", g("estHours", "________")),
|
| 284 |
+
("Authorized By", g("authorizedBy", "____________________")),
|
| 285 |
+
])
|
| 286 |
+
|
| 287 |
+
section(doc, "Materials & Equipment")
|
| 288 |
+
checklist(doc, "Materials (check + enter quantity)", [
|
| 289 |
+
"Cold Mix Asphalt ____ t", "Hot Mix Asphalt ____ t", "Tack Coat",
|
| 290 |
+
"Crack Sealant", "Aggregate / Gravel ____", "Other: __________",
|
| 291 |
+
])
|
| 292 |
+
spacer(doc, 3)
|
| 293 |
+
checklist(doc, "Equipment", [
|
| 294 |
+
"Asphalt Roller", "Plate Compactor", "Dump Truck",
|
| 295 |
+
"Air Compressor", "Saw Cutter", "Hand Tools",
|
| 296 |
+
])
|
| 297 |
+
|
| 298 |
+
section(doc, "Labor & Cost")
|
| 299 |
+
cost = doc.add_table(rows=5, cols=5)
|
| 300 |
+
grid(cost)
|
| 301 |
+
cell_pad(cost, top=50, bottom=50, left=90, right=90)
|
| 302 |
+
for j, h in enumerate(["Crew Member", "Role", "Hours", "Rate ($)", "Cost ($)"]):
|
| 303 |
+
c = cost.cell(0, j)
|
| 304 |
+
shade(c, "EEF2F9")
|
| 305 |
+
run(c.paragraphs[0], h, size=8.5, bold=True, color=NAVY)
|
| 306 |
+
widths(cost, [2.4, 1.4, 0.9, 1.0, 1.2])
|
| 307 |
+
tot = doc.add_paragraph()
|
| 308 |
+
tot.alignment = WD_ALIGN_PARAGRAPH.RIGHT
|
| 309 |
+
tot.paragraph_format.space_before = Pt(4)
|
| 310 |
+
run(tot, "Labor $ ______ Materials $ ______ Equipment $ ______ ", size=9.5, color=GREY)
|
| 311 |
+
run(tot, "TOTAL $ __________", size=10, bold=True, color=DARK)
|
| 312 |
+
|
| 313 |
+
section(doc, "Completion & Sign-Off")
|
| 314 |
+
run(doc.add_paragraph(), "Work Performed", size=8.5, bold=True, color=GREY, caps=True)
|
| 315 |
+
for _ in range(2):
|
| 316 |
+
p = doc.add_paragraph()
|
| 317 |
+
run(p, "β" * 78, size=10, color=RGBColor(0xD7, 0xDC, 0xE5))
|
| 318 |
+
spacer(doc, 2)
|
| 319 |
+
info_grid(doc, [
|
| 320 |
+
("Date Started", "____________"),
|
| 321 |
+
("Date Completed", "____________"),
|
| 322 |
+
("Actual Labor Hours", "________"),
|
| 323 |
+
("After Photo Attached", "β Yes β No"),
|
| 324 |
+
])
|
| 325 |
+
spacer(doc, 4)
|
| 326 |
+
sign_block(doc, ["Crew Foreman", "Supervisor Approval", "Inspector Sign-Off"])
|
| 327 |
+
|
| 328 |
+
section(doc, "Safety / Traffic Control")
|
| 329 |
+
checklist(doc, "Measures used", [
|
| 330 |
+
"Cones / Barricades", "Flagger", "Lane Closure",
|
| 331 |
+
"Advance Warning Signage", "Arrow Board", "None Required",
|
| 332 |
+
])
|
| 333 |
+
|
| 334 |
+
foot = doc.add_paragraph()
|
| 335 |
+
foot.paragraph_format.space_before = Pt(10)
|
| 336 |
+
run(foot, "Generated by PotholeIQ" + (" Β· " + g("issuedDate") if g("issuedDate") else ""), size=8, italic=True, color=LIGHT)
|
| 337 |
+
|
| 338 |
+
doc.save(out_path)
|
| 339 |
+
print(json.dumps({"ok": True, "output": out_path}))
|
| 340 |
+
|
| 341 |
+
|
| 342 |
+
if __name__ == "__main__":
|
| 343 |
+
main()
|
ai/pothole-yolov8.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:af2ac6ce7bfec72e71643659ac946caf80ced84869e526a60135c457abfbb200
|
| 3 |
+
size 22524266
|
ai/pothole_server.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
"""Persistent YOLOv8 pothole classifier worker (real-time).
|
| 3 |
+
|
| 4 |
+
Loads the model ONCE at startup, then serves one request per line from stdin
|
| 5 |
+
and writes one JSON result per line to stdout. This avoids the multi-second
|
| 6 |
+
cold start of loading torch + the model on every request.
|
| 7 |
+
|
| 8 |
+
Protocol:
|
| 9 |
+
startup -> emits {"ready": true} (or {"error": "..."} and exits)
|
| 10 |
+
stdin -> one image file path per line ("__quit__" to stop)
|
| 11 |
+
stdout -> one JSON result per line:
|
| 12 |
+
{"isPothole":bool,"confidence":float,"size":"Small|Medium|Large"|null,
|
| 13 |
+
"count":int,"largestAreaFraction":float,"boxes":[...],"model":"..."}
|
| 14 |
+
or {"error":"..."} for that request.
|
| 15 |
+
"""
|
| 16 |
+
import sys
|
| 17 |
+
import os
|
| 18 |
+
import json
|
| 19 |
+
|
| 20 |
+
# Size = area (as a fraction of the whole frame) of the largest *confident* pothole
|
| 21 |
+
# detection. A single 2D photo has no true scale, so box area is a heuristic proxy.
|
| 22 |
+
# >= SIZE_LARGE -> Large
|
| 23 |
+
# >= SIZE_MEDIUM -> Medium
|
| 24 |
+
# else -> Small
|
| 25 |
+
# Thresholds are calibrated to the ground-truth pothole box-area distribution
|
| 26 |
+
# (3,625 annotated boxes): SIZE_MEDIUM ~= 40th percentile, SIZE_LARGE ~= 82nd
|
| 27 |
+
# percentile. That yields a sensible operational split of roughly 40% Small /
|
| 28 |
+
# 42% Medium / 18% Large instead of over-calling everything "Small".
|
| 29 |
+
SIZE_LARGE = 0.09
|
| 30 |
+
SIZE_MEDIUM = 0.013
|
| 31 |
+
# CONF_THRESHOLD: minimum confidence for a box to count as a detection at all.
|
| 32 |
+
CONF_THRESHOLD = 0.25
|
| 33 |
+
# SIZE_CONF_FLOOR: a box must clear this higher bar before its area is allowed to
|
| 34 |
+
# drive the size label. This stops low-confidence, sprawling false boxes (e.g. a
|
| 35 |
+
# shadow spanning the frame) from inflating a real, smaller pothole to "Large".
|
| 36 |
+
SIZE_CONF_FLOOR = 0.45
|
| 37 |
+
MODEL_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "pothole-yolov8.pt")
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def emit(obj):
|
| 41 |
+
sys.stdout.write(json.dumps(obj) + "\n")
|
| 42 |
+
sys.stdout.flush()
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def classify(model, image_path):
|
| 46 |
+
if not os.path.exists(image_path):
|
| 47 |
+
return {"error": "image not found: " + image_path}
|
| 48 |
+
try:
|
| 49 |
+
result = model.predict(image_path, conf=CONF_THRESHOLD, verbose=False)[0]
|
| 50 |
+
h, w = result.orig_shape
|
| 51 |
+
frame_area = float(h * w) if h and w else 1.0
|
| 52 |
+
|
| 53 |
+
boxes = []
|
| 54 |
+
max_frac = 0.0 # largest box area (any confidence) β reported for transparency
|
| 55 |
+
max_conf = 0.0 # best detection confidence
|
| 56 |
+
size_frac = 0.0 # area that actually drives the size label (confident boxes only)
|
| 57 |
+
best_conf_frac = 0.0 # area of the single highest-confidence box (fallback)
|
| 58 |
+
best_conf = -1.0
|
| 59 |
+
for b in result.boxes:
|
| 60 |
+
x1, y1, x2, y2 = (float(v) for v in b.xyxy[0].tolist())
|
| 61 |
+
conf = float(b.conf[0])
|
| 62 |
+
frac = ((x2 - x1) * (y2 - y1)) / frame_area
|
| 63 |
+
boxes.append({
|
| 64 |
+
"x1": round(x1), "y1": round(y1), "x2": round(x2), "y2": round(y2),
|
| 65 |
+
"conf": round(conf, 3), "areaFraction": round(frac, 4),
|
| 66 |
+
})
|
| 67 |
+
max_frac = max(max_frac, frac)
|
| 68 |
+
max_conf = max(max_conf, conf)
|
| 69 |
+
if conf >= SIZE_CONF_FLOOR:
|
| 70 |
+
size_frac = max(size_frac, frac)
|
| 71 |
+
if conf > best_conf:
|
| 72 |
+
best_conf = conf
|
| 73 |
+
best_conf_frac = frac
|
| 74 |
+
|
| 75 |
+
is_pothole = len(boxes) > 0
|
| 76 |
+
# Prefer the largest confident pothole; if none clear the floor, fall back to
|
| 77 |
+
# the area of the single most-confident detection (never a noisy wide box).
|
| 78 |
+
sizing_frac = size_frac if size_frac > 0 else best_conf_frac
|
| 79 |
+
if not is_pothole:
|
| 80 |
+
size = None
|
| 81 |
+
elif sizing_frac >= SIZE_LARGE:
|
| 82 |
+
size = "Large"
|
| 83 |
+
elif sizing_frac >= SIZE_MEDIUM:
|
| 84 |
+
size = "Medium"
|
| 85 |
+
else:
|
| 86 |
+
size = "Small"
|
| 87 |
+
|
| 88 |
+
return {
|
| 89 |
+
"isPothole": is_pothole,
|
| 90 |
+
"confidence": round(max_conf, 3),
|
| 91 |
+
"size": size,
|
| 92 |
+
"count": len(boxes),
|
| 93 |
+
"largestAreaFraction": round(max_frac, 4),
|
| 94 |
+
"sizeAreaFraction": round(sizing_frac, 4),
|
| 95 |
+
"boxes": boxes,
|
| 96 |
+
"model": "peterhdd/pothole-detection-yolov8",
|
| 97 |
+
}
|
| 98 |
+
except Exception as e: # noqa: BLE001
|
| 99 |
+
return {"error": str(e)}
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def main():
|
| 103 |
+
if not os.path.exists(MODEL_FILE):
|
| 104 |
+
emit({"error": "model file missing: " + MODEL_FILE})
|
| 105 |
+
return
|
| 106 |
+
try:
|
| 107 |
+
from ultralytics import YOLO
|
| 108 |
+
except Exception as e: # noqa: BLE001
|
| 109 |
+
emit({"error": "ultralytics not installed: " + str(e)})
|
| 110 |
+
return
|
| 111 |
+
try:
|
| 112 |
+
model = YOLO(MODEL_FILE)
|
| 113 |
+
except Exception as e: # noqa: BLE001
|
| 114 |
+
emit({"error": "model load failed: " + str(e)})
|
| 115 |
+
return
|
| 116 |
+
|
| 117 |
+
emit({"ready": True})
|
| 118 |
+
|
| 119 |
+
for line in sys.stdin:
|
| 120 |
+
path = line.strip()
|
| 121 |
+
if not path:
|
| 122 |
+
continue
|
| 123 |
+
if path == "__quit__":
|
| 124 |
+
break
|
| 125 |
+
emit(classify(model, path))
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
if __name__ == "__main__":
|
| 129 |
+
main()
|
authority-routing.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"default": {
|
| 3 |
+
"authorityName": "Department of Streets β Road Maintenance Division",
|
| 4 |
+
"authorityEmails": [
|
| 5 |
+
"road.maintenance@city.example.gov"
|
| 6 |
+
],
|
| 7 |
+
"dispatchEmails": [
|
| 8 |
+
"field.dispatch@city.example.gov"
|
| 9 |
+
]
|
| 10 |
+
},
|
| 11 |
+
"districts": {},
|
| 12 |
+
"zones": {}
|
| 13 |
+
}
|
backend-api.js
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use strict';
|
| 2 |
+
|
| 3 |
+
(function initPotholeBackend(globalScope) {
|
| 4 |
+
const API_PREFIX = '/api';
|
| 5 |
+
|
| 6 |
+
function isPlainObject(value) {
|
| 7 |
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
function deepMerge(baseValue, incomingValue) {
|
| 11 |
+
if (!isPlainObject(baseValue) || !isPlainObject(incomingValue)) {
|
| 12 |
+
return incomingValue === undefined ? baseValue : incomingValue;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
const result = { ...baseValue };
|
| 16 |
+
for (const [key, incoming] of Object.entries(incomingValue)) {
|
| 17 |
+
const existing = result[key];
|
| 18 |
+
if (Array.isArray(incoming)) {
|
| 19 |
+
result[key] = incoming.slice();
|
| 20 |
+
continue;
|
| 21 |
+
}
|
| 22 |
+
if (isPlainObject(existing) && isPlainObject(incoming)) {
|
| 23 |
+
result[key] = deepMerge(existing, incoming);
|
| 24 |
+
continue;
|
| 25 |
+
}
|
| 26 |
+
if (incoming !== undefined) {
|
| 27 |
+
result[key] = incoming;
|
| 28 |
+
}
|
| 29 |
+
}
|
| 30 |
+
return result;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
function stripPhotoDataUrl(value) {
|
| 34 |
+
if (Array.isArray(value)) {
|
| 35 |
+
return value.map((item) => stripPhotoDataUrl(item));
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
if (!isPlainObject(value)) {
|
| 39 |
+
return value;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
const clone = {};
|
| 43 |
+
for (const [key, child] of Object.entries(value)) {
|
| 44 |
+
if (key === 'photoDataUrl') continue;
|
| 45 |
+
clone[key] = stripPhotoDataUrl(child);
|
| 46 |
+
}
|
| 47 |
+
return clone;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
function toTimestamp(value) {
|
| 51 |
+
const parsed = Date.parse(String(value || ''));
|
| 52 |
+
return Number.isFinite(parsed) ? parsed : 0;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
function caseFreshness(record) {
|
| 56 |
+
if (!record || typeof record !== 'object') return 0;
|
| 57 |
+
const report = record.report || {};
|
| 58 |
+
return Math.max(
|
| 59 |
+
toTimestamp(record.updatedAt),
|
| 60 |
+
toTimestamp(record.statusUpdatedAt),
|
| 61 |
+
toTimestamp(record.ts),
|
| 62 |
+
toTimestamp(record.timestamp),
|
| 63 |
+
toTimestamp(report.workflowLastUpdatedAt),
|
| 64 |
+
toTimestamp(report.resolvedAt),
|
| 65 |
+
toTimestamp(report.supervisor?.at),
|
| 66 |
+
toTimestamp(report.enrichment?.enrichedAt),
|
| 67 |
+
toTimestamp(report.submittedAt)
|
| 68 |
+
);
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
function mergeCaseLists(localCases, remoteCases) {
|
| 72 |
+
const merged = new Map();
|
| 73 |
+
const allCases = [...(Array.isArray(localCases) ? localCases : []), ...(Array.isArray(remoteCases) ? remoteCases : [])];
|
| 74 |
+
|
| 75 |
+
allCases.forEach((record) => {
|
| 76 |
+
if (!record || !record.caseId) return;
|
| 77 |
+
const existing = merged.get(record.caseId);
|
| 78 |
+
if (!existing) {
|
| 79 |
+
merged.set(record.caseId, record);
|
| 80 |
+
return;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
const existingFreshness = caseFreshness(existing);
|
| 84 |
+
const nextFreshness = caseFreshness(record);
|
| 85 |
+
const nextPreferred = nextFreshness >= existingFreshness;
|
| 86 |
+
merged.set(
|
| 87 |
+
record.caseId,
|
| 88 |
+
nextPreferred ? deepMerge(existing, record) : deepMerge(record, existing)
|
| 89 |
+
);
|
| 90 |
+
});
|
| 91 |
+
|
| 92 |
+
return Array.from(merged.values()).sort((a, b) => {
|
| 93 |
+
const aTs = caseFreshness(a);
|
| 94 |
+
const bTs = caseFreshness(b);
|
| 95 |
+
if (aTs !== bTs) return aTs - bTs;
|
| 96 |
+
return String(a.caseId || '').localeCompare(String(b.caseId || ''));
|
| 97 |
+
});
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
async function request(path, options = {}) {
|
| 101 |
+
const response = await fetch(`${API_PREFIX}${path}`, {
|
| 102 |
+
headers: {
|
| 103 |
+
'Content-Type': 'application/json',
|
| 104 |
+
...(options.headers || {}),
|
| 105 |
+
},
|
| 106 |
+
...options,
|
| 107 |
+
});
|
| 108 |
+
|
| 109 |
+
let payload = null;
|
| 110 |
+
try {
|
| 111 |
+
payload = await response.json();
|
| 112 |
+
} catch {
|
| 113 |
+
payload = null;
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
if (!response.ok) {
|
| 117 |
+
const errorMessage = payload?.error || `Request failed with HTTP ${response.status}`;
|
| 118 |
+
throw new Error(errorMessage);
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
return payload;
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
async function health() {
|
| 125 |
+
const payload = await request('/health', { method: 'GET' });
|
| 126 |
+
return payload?.ok === true;
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
async function getCases() {
|
| 130 |
+
const payload = await request('/cases', { method: 'GET' });
|
| 131 |
+
return Array.isArray(payload?.cases) ? payload.cases : [];
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
async function getCase(caseId) {
|
| 135 |
+
if (!caseId) return null;
|
| 136 |
+
const payload = await request(`/cases/${encodeURIComponent(caseId)}`, { method: 'GET' });
|
| 137 |
+
return payload?.case || null;
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
async function upsertCase(record) {
|
| 141 |
+
const payload = await request('/cases/upsert', {
|
| 142 |
+
method: 'POST',
|
| 143 |
+
body: JSON.stringify(stripPhotoDataUrl(record || {})),
|
| 144 |
+
});
|
| 145 |
+
return payload?.case || null;
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
async function bulkUpsertCases(cases) {
|
| 149 |
+
const payload = await request('/cases/bulk-upsert', {
|
| 150 |
+
method: 'POST',
|
| 151 |
+
body: JSON.stringify({
|
| 152 |
+
cases: Array.isArray(cases) ? cases.map((record) => stripPhotoDataUrl(record || {})) : [],
|
| 153 |
+
}),
|
| 154 |
+
});
|
| 155 |
+
return Array.isArray(payload?.cases) ? payload.cases : [];
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
async function patchCase(caseId, patch) {
|
| 159 |
+
if (!caseId) throw new Error('caseId is required.');
|
| 160 |
+
const payload = await request(`/cases/${encodeURIComponent(caseId)}/patch`, {
|
| 161 |
+
method: 'POST',
|
| 162 |
+
body: JSON.stringify(stripPhotoDataUrl(patch || {})),
|
| 163 |
+
});
|
| 164 |
+
return payload?.case || null;
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
async function submitSupervisorDecision(caseId, decisionPayload = {}) {
|
| 168 |
+
if (!caseId) throw new Error('caseId is required.');
|
| 169 |
+
const payload = await request('/workflow/supervisor-decision', {
|
| 170 |
+
method: 'POST',
|
| 171 |
+
body: JSON.stringify(stripPhotoDataUrl({
|
| 172 |
+
caseId,
|
| 173 |
+
...decisionPayload,
|
| 174 |
+
})),
|
| 175 |
+
});
|
| 176 |
+
return payload || null;
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
async function submitCrewCloseout(caseId, closeoutPayload = {}) {
|
| 180 |
+
if (!caseId) throw new Error('caseId is required.');
|
| 181 |
+
const payload = await request('/workflow/crew-closeout', {
|
| 182 |
+
method: 'POST',
|
| 183 |
+
body: JSON.stringify(stripPhotoDataUrl({
|
| 184 |
+
caseId,
|
| 185 |
+
...closeoutPayload,
|
| 186 |
+
})),
|
| 187 |
+
});
|
| 188 |
+
return payload || null;
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
async function submitDispatchPlan(dispatchPayload = {}) {
|
| 192 |
+
const payload = await request('/workflow/dispatch-plan', {
|
| 193 |
+
method: 'POST',
|
| 194 |
+
body: JSON.stringify(stripPhotoDataUrl(dispatchPayload || {})),
|
| 195 |
+
});
|
| 196 |
+
return payload || null;
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
async function resolveCase(caseId, resolvePayload = {}) {
|
| 200 |
+
if (!caseId) throw new Error('caseId is required.');
|
| 201 |
+
const payload = await request('/workflow/resolve-case', {
|
| 202 |
+
method: 'POST',
|
| 203 |
+
body: JSON.stringify(stripPhotoDataUrl({
|
| 204 |
+
caseId,
|
| 205 |
+
...resolvePayload,
|
| 206 |
+
})),
|
| 207 |
+
});
|
| 208 |
+
return payload || null;
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
async function uploadCaseFile(caseId, fileName, fileBlob, contentType = '') {
|
| 212 |
+
if (!caseId) throw new Error('caseId is required.');
|
| 213 |
+
if (!fileName) throw new Error('fileName is required.');
|
| 214 |
+
if (!(fileBlob instanceof Blob)) {
|
| 215 |
+
throw new Error('fileBlob must be a Blob or File.');
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
const response = await fetch(
|
| 219 |
+
`${API_PREFIX}/box/upload-case-file?caseId=${encodeURIComponent(caseId)}&fileName=${encodeURIComponent(fileName)}`,
|
| 220 |
+
{
|
| 221 |
+
method: 'POST',
|
| 222 |
+
headers: contentType ? { 'Content-Type': contentType } : {},
|
| 223 |
+
body: fileBlob,
|
| 224 |
+
}
|
| 225 |
+
);
|
| 226 |
+
|
| 227 |
+
let payload = null;
|
| 228 |
+
try {
|
| 229 |
+
payload = await response.json();
|
| 230 |
+
} catch {
|
| 231 |
+
payload = null;
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
if (!response.ok) {
|
| 235 |
+
throw new Error(payload?.error || `Request failed with HTTP ${response.status}`);
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
return payload?.upload || null;
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
async function hydrateCasesIntoLocalStorage(storageKey = 'submitted_cases') {
|
| 242 |
+
if (typeof localStorage === 'undefined') return null;
|
| 243 |
+
|
| 244 |
+
let localCases = [];
|
| 245 |
+
try {
|
| 246 |
+
localCases = JSON.parse(localStorage.getItem(storageKey) || '[]');
|
| 247 |
+
} catch {
|
| 248 |
+
localCases = [];
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
const remoteCases = await getCases();
|
| 252 |
+
const mergedCases = mergeCaseLists(localCases, remoteCases);
|
| 253 |
+
localStorage.setItem(storageKey, JSON.stringify(mergedCases));
|
| 254 |
+
|
| 255 |
+
const remoteIds = new Set(remoteCases.map((item) => item?.caseId).filter(Boolean));
|
| 256 |
+
const unsyncedLocal = localCases.filter((item) => item?.caseId && !remoteIds.has(item.caseId));
|
| 257 |
+
if (unsyncedLocal.length) {
|
| 258 |
+
bulkUpsertCases(unsyncedLocal).catch((err) => {
|
| 259 |
+
console.warn('[PotholeBackend] could not sync local-only cases:', err);
|
| 260 |
+
});
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
return mergedCases;
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
async function syncLocalStorageCases(storageKey = 'submitted_cases') {
|
| 267 |
+
if (typeof localStorage === 'undefined') return [];
|
| 268 |
+
try {
|
| 269 |
+
const cases = JSON.parse(localStorage.getItem(storageKey) || '[]');
|
| 270 |
+
return bulkUpsertCases(cases);
|
| 271 |
+
} catch (err) {
|
| 272 |
+
console.warn('[PotholeBackend] could not read local cases for sync:', err);
|
| 273 |
+
return [];
|
| 274 |
+
}
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
globalScope.PotholeBackend = {
|
| 278 |
+
health,
|
| 279 |
+
getCases,
|
| 280 |
+
getCase,
|
| 281 |
+
upsertCase,
|
| 282 |
+
bulkUpsertCases,
|
| 283 |
+
patchCase,
|
| 284 |
+
submitSupervisorDecision,
|
| 285 |
+
submitDispatchPlan,
|
| 286 |
+
submitCrewCloseout,
|
| 287 |
+
resolveCase,
|
| 288 |
+
uploadCaseFile,
|
| 289 |
+
hydrateCasesIntoLocalStorage,
|
| 290 |
+
syncLocalStorageCases,
|
| 291 |
+
mergeCaseLists,
|
| 292 |
+
};
|
| 293 |
+
})(window);
|
box-build/BOX_APP_CONSOLE.md
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Box App β Pothole Operations Console Build Guide
|
| 2 |
+
|
| 3 |
+
This builds the operational dashboard + review console as a **Box App** (Box's no-code app
|
| 4 |
+
builder over your content + metadata), replacing the custom [ops-review.html](../ops-review.html).
|
| 5 |
+
|
| 6 |
+
> **Accuracy note:** the Box Apps no-code builder's exact control names evolve. This guide is
|
| 7 |
+
> correct at the structure/concept level (data source β views β fields β actions β roles).
|
| 8 |
+
> Confirm the equivalent control as you build, and tell me what you see if a step doesn't match β
|
| 9 |
+
> I'll adjust.
|
| 10 |
+
|
| 11 |
+
> **Hard dependency:** the app reads/writes the `potholeCase` metadata template
|
| 12 |
+
> ([metadata-template.json](metadata-template.json)). Build that template first, and have at
|
| 13 |
+
> least a few enriched cases in `Pothole Operations/Cases/` so the views show real data.
|
| 14 |
+
|
| 15 |
+
---
|
| 16 |
+
|
| 17 |
+
## App: "Pothole Operations Console"
|
| 18 |
+
|
| 19 |
+
**Data source:** the `potholeCase` metadata template, scoped to the
|
| 20 |
+
`Pothole Operations/Cases/` folder tree. Each case folder = one record; the case photo +
|
| 21 |
+
generated PDFs are the folder's files.
|
| 22 |
+
|
| 23 |
+
---
|
| 24 |
+
|
| 25 |
+
### View 1 β Operations Queue (default landing)
|
| 26 |
+
|
| 27 |
+
A filterable table of all cases.
|
| 28 |
+
|
| 29 |
+
- **Columns:** `caseId`, `status`, `severityLevel`, `ward`, `address`, `duplicateStatus`,
|
| 30 |
+
`submittedAt`, `assignedCrew`.
|
| 31 |
+
- **Default sort:** `severityScore` descending (Critical at top).
|
| 32 |
+
- **Saved filters / tabs:**
|
| 33 |
+
- *Needs Review* β `status = Under Review`
|
| 34 |
+
- *Critical* β `severityLevel = Critical`
|
| 35 |
+
- *Assigned* β `status = Assigned`
|
| 36 |
+
- *Resolved* β `status = Resolved`
|
| 37 |
+
- **Color rules:** Critical = red, High = orange, Medium = amber, Low = grey
|
| 38 |
+
(match the colors already in your data's `enrichment.severityColor`).
|
| 39 |
+
|
| 40 |
+
### View 2 β Case Detail
|
| 41 |
+
|
| 42 |
+
Opens when a row is clicked.
|
| 43 |
+
|
| 44 |
+
- **Header:** `caseId` + `status` badge.
|
| 45 |
+
- **Photo:** preview the case photo from the folder.
|
| 46 |
+
- **AI summary panel:** the Box AI-generated case summary (set up in AI Studio) +
|
| 47 |
+
`aiReviewStatus`, `potholeSize`.
|
| 48 |
+
- **Severity breakdown:** `severityScore`, `severityLevel`, `weatherRisk`
|
| 49 |
+
(the prior/traffic/damage/weather detail lives in the backend `enrichment.severityBreakdown`
|
| 50 |
+
β surface it via the AI summary or an embedded note).
|
| 51 |
+
- **Location:** `address`, `ward`, `district`, `maintenanceZone`, `latitude`/`longitude`
|
| 52 |
+
(link out to a map).
|
| 53 |
+
- **Duplicate panel:** `duplicateStatus`, `duplicateCount`, `linkedCaseIds`.
|
| 54 |
+
- **Generated docs:** list the work order PDF + audit report from the folder.
|
| 55 |
+
|
| 56 |
+
### View 3 β Dashboard (management)
|
| 57 |
+
|
| 58 |
+
Charts over the same data source:
|
| 59 |
+
|
| 60 |
+
- Cases by `status` (funnel: Submitted β Under Review β Assigned β In Progress β Resolved).
|
| 61 |
+
- Cases by `ward` (bar).
|
| 62 |
+
- `severityLevel` distribution (donut).
|
| 63 |
+
- Open cases older than N days (SLA watch).
|
| 64 |
+
|
| 65 |
+
### View 4 (optional) β Citizen Status (read-only, externally shareable)
|
| 66 |
+
|
| 67 |
+
- Single-record lookup by `caseId`.
|
| 68 |
+
- Show **only**: `status`, `submittedAt`, `ward`, `resolvedAt`. Hide reporter contact,
|
| 69 |
+
internal notes, severity internals.
|
| 70 |
+
|
| 71 |
+
---
|
| 72 |
+
|
| 73 |
+
## Actions (buttons that drive the workflow)
|
| 74 |
+
|
| 75 |
+
Wire these on the Case Detail view. Each either writes metadata (which a Relay flow reacts to)
|
| 76 |
+
or calls your backend:
|
| 77 |
+
|
| 78 |
+
| Button | What it does |
|
| 79 |
+
|-------------------|------------------------------------------------------------------------------|
|
| 80 |
+
| **Approve** | Set `status = Assigned`, `supervisor = {current user}` β Flow 2/3 fire. |
|
| 81 |
+
| **Reject** | Set `status = Rejected` (prompt for reason). |
|
| 82 |
+
| **Adjust severity** | Edit `severityLevel` before approving. |
|
| 83 |
+
| **Generate work order** | Trigger Box Doc Gen (or it auto-fires from the status change in Flow 3).|
|
| 84 |
+
| **Open in city system** | (Optional) HTTPS call to the external work-order system. |
|
| 85 |
+
|
| 86 |
+
> If the Box App builder in your tenant can't call outgoing webhooks directly, do it the
|
| 87 |
+
> metadata-driven way: the button just sets a metadata field, and **Box Automate (Relay)**
|
| 88 |
+
> watches that field and performs the action. This is the more robust pattern anyway.
|
| 89 |
+
|
| 90 |
+
---
|
| 91 |
+
|
| 92 |
+
## Roles & permissions
|
| 93 |
+
|
| 94 |
+
| Role | Sees / can do |
|
| 95 |
+
|------------------|----------------------------------------------------------------------|
|
| 96 |
+
| Ops reviewer | All cases; Approve/Reject/Adjust. |
|
| 97 |
+
| Ward engineer | Cases for their `ward`; Approve/Reject (Relay assigns the approval). |
|
| 98 |
+
| Crew | Assigned cases only; closeout via the **Box Form**, not this app. |
|
| 99 |
+
| Management | Dashboard view; read-only. |
|
| 100 |
+
| Citizen (public) | View 4 only, via a shared link. |
|
| 101 |
+
|
| 102 |
+
---
|
| 103 |
+
|
| 104 |
+
## Build order
|
| 105 |
+
|
| 106 |
+
1. Confirm `potholeCase` template exists and a few enriched cases are present.
|
| 107 |
+
2. Create the app β point its data source at `Cases/` + the template.
|
| 108 |
+
3. Build View 1 (Queue) β verify real cases appear.
|
| 109 |
+
4. Add View 2 (Detail) + the Approve/Reject buttons β test one approval end-to-end with
|
| 110 |
+
[BOX_AUTOMATE_WORKFLOW.md](BOX_AUTOMATE_WORKFLOW.md) Flow 2 watching the field.
|
| 111 |
+
5. Add View 3 (Dashboard) and View 4 (Citizen) last.
|
| 112 |
+
|
| 113 |
+
---
|
| 114 |
+
|
| 115 |
+
## How this maps to the old custom app
|
| 116 |
+
|
| 117 |
+
| Old custom page | New Box-native home |
|
| 118 |
+
|-------------------------------------|----------------------------------|
|
| 119 |
+
| [ops-review.html](../ops-review.html) | Box App Views 1β3 + actions |
|
| 120 |
+
| [deploy-crew.html](../deploy-crew.html) | Box App action + Relay Flow 3 |
|
| 121 |
+
| [crew-closeout.html](../crew-closeout.html) | Box Form + Box Sign (Flow 4) |
|
| 122 |
+
| [track.html](../track.html) | Box App View 4 (citizen) |
|
| 123 |
+
| [index.html](../index.html) intake | Box Form intake (AI dashcam stays as optional smart client) |
|
box-build/BOX_APP_DASHBOARD.md
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# PotholeIQ β Box App Dashboard Build (complete)
|
| 2 |
+
|
| 3 |
+
Mirrors the ops-review console: hero metrics β overview charts β case lists β case
|
| 4 |
+
detail with severity breakdown + photo. All blocks read the `Pothole Case`
|
| 5 |
+
(`enterprise/potholeCase`) metadata template.
|
| 6 |
+
|
| 7 |
+
**Block types available:** View (filtered metadata list), Chart, Item List, File or Folder, Form, App, AutoFiler, Shortcut.
|
| 8 |
+
**A "View" block = one saved Box metadata view** (its own columns/filter/sort, edited in the Box metadata view editor).
|
| 9 |
+
|
| 10 |
+
> β οΈ Filters must use EXACT enum values:
|
| 11 |
+
> `status`: submitted Β· under review Β· assigned Β· `In progress` Β· resolved Β· rejected
|
| 12 |
+
> `severityLevel`: low Β· medium Β· high Β· critical
|
| 13 |
+
> `duplicateStatus`: new Β· `duplicate ` *(trailing space)* Β· pending
|
| 14 |
+
|
| 15 |
+
---
|
| 16 |
+
|
| 17 |
+
## PAGE 1 β "Operations Overview" (the landing page)
|
| 18 |
+
|
| 19 |
+
### Section A β KPI row (Section type: **Grid**)
|
| 20 |
+
Four **Chart** blocks. Use chart type **Number/Metric** if available; else a small Donut.
|
| 21 |
+
| Block | Source | Measure | Filter |
|
| 22 |
+
|---|---|---|---|
|
| 23 |
+
| Cases In Queue | Pothole Case | Count | `status` = under review |
|
| 24 |
+
| Needs Attention | Pothole Case | Count | `status` β resolved AND β rejected |
|
| 25 |
+
| Priority Queue | Pothole Case | Count | `severityLevel` = critical OR high |
|
| 26 |
+
| Resolved | Pothole Case | Count | `status` = resolved |
|
| 27 |
+
|
| 28 |
+
### Section B β distribution (Grid, 2 columns)
|
| 29 |
+
| Block | Type | Dimension (group by) | Measure |
|
| 30 |
+
|---|---|---|---|
|
| 31 |
+
| Cases by Severity | Donut | `severityLevel` | Count |
|
| 32 |
+
| Cases by Ward | Column | `ward` | Count |
|
| 33 |
+
|
| 34 |
+
### Section C β the breakdown (Grid, 2 columns)
|
| 35 |
+
| Block | Type | Detail |
|
| 36 |
+
|---|---|---|
|
| 37 |
+
| **What Drives Severity** | Bar (grouped) | Measures = Average of `priorNoticeScore`, `trafficScore`, `damageScore`, `weatherScore`. *(If only one measure is allowed, make 4 small bar charts, one per factor avg.)* |
|
| 38 |
+
| Cases by Status | Column | group by `status`, Count (the pipeline) |
|
| 39 |
+
|
| 40 |
+
---
|
| 41 |
+
|
| 42 |
+
## PAGE 2 β "Case Queue" (the working lists)
|
| 43 |
+
One **View** block per saved view (create each, name it, pick Pothole Case, then set columns/filter/sort in the metadata view editor):
|
| 44 |
+
|
| 45 |
+
| View block | Filter | Sort | Columns |
|
| 46 |
+
|---|---|---|---|
|
| 47 |
+
| **All Cases** | none | submittedAt β | caseId Β· status Β· severityLevel Β· severityScore0100 Β· ward Β· address Β· submittedAt |
|
| 48 |
+
| **Triage Queue** | status = under review | severityScore0100 β | caseId Β· severityScore0100 Β· severityLevel Β· ward Β· address Β· priorNoticeScore Β· trafficScore Β· damageScore Β· weatherScore |
|
| 49 |
+
| **Duplicates** | duplicateStatus = `duplicate ` | β | caseId Β· linkedCaseIds Β· duplicateCount Β· severityScore0100 Β· ward |
|
| 50 |
+
| **In Progress** | status = assigned / In progress | β | caseId Β· assignedCrew Β· workOrderId Β· severityLevel Β· ward |
|
| 51 |
+
| **Resolved** | status = resolved | resolvedAt β | caseId Β· resolvedAt Β· assignedCrew Β· severityLevel Β· ward |
|
| 52 |
+
|
| 53 |
+
---
|
| 54 |
+
|
| 55 |
+
## PAGE 3 β "Case Detail & Evidence"
|
| 56 |
+
- **File or Folder block** β point at the `Potholes/` tree. Opening a case folder previews the
|
| 57 |
+
**initial pothole photo** + work order / closeout / audit docs inline.
|
| 58 |
+
- **The severity breakdown panel is native:** open any case folder β Box's **metadata sidebar**
|
| 59 |
+
shows all `Pothole Case` fields, including the 4 breakdown **Scores + Details** and the total.
|
| 60 |
+
No extra block needed β that's the per-case breakdown.
|
| 61 |
+
|
| 62 |
+
Breakdown reads as: Prior Notice _/30 Β· Traffic _/25 Β· Damage _/25 Β· Weather _/10 β Total /100 β Level.
|
| 63 |
+
|
| 64 |
+
---
|
| 65 |
+
|
| 66 |
+
## Notes
|
| 67 |
+
- **No email from the App** β any dispatch/notify routes through Relay or the backend.
|
| 68 |
+
- **Views/charts are empty until cases carry `potholeCase` metadata** β backend seeds that.
|
| 69 |
+
- Build order: Page 1 charts β Page 2 views β Page 3 evidence. Then Save & Preview.
|
box-build/BOX_AUTOMATE_WORKFLOW.md
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Box Automate (Relay) β Pothole Workflow Build Guide
|
| 2 |
+
|
| 3 |
+
This guide builds the end-to-end pothole workflow as **Box Relay** flows (the no-code
|
| 4 |
+
workflow tool your setup calls "Box Automate"). It is written click-by-click but exact
|
| 5 |
+
button labels vary slightly by Box release β confirm the equivalent control in your tenant.
|
| 6 |
+
|
| 7 |
+
> **Division of labor:** Relay flows are built in the Box web console (Relay app). The code
|
| 8 |
+
> side (metadata template, enrichment Skill, Doc Gen template, backend callback) is in this
|
| 9 |
+
> repo. Each flow below names exactly which code asset it touches.
|
| 10 |
+
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
## Prerequisites (do these first, in order)
|
| 14 |
+
|
| 15 |
+
1. **CCG connection live.** The service account must be able to read/write the case folders.
|
| 16 |
+
(Still pending your Box **Enterprise ID** so we can finish this in `data/config.json`.)
|
| 17 |
+
2. **Metadata template created.** Create `potholeCase` from
|
| 18 |
+
[metadata-template.json](metadata-template.json) β Admin Console β **Content** β
|
| 19 |
+
**Metadata** β New Template (or POST it to `/2.0/metadata_templates/schema`).
|
| 20 |
+
3. **Folder structure exists:**
|
| 21 |
+
- `Pothole Operations/Incoming Reports/` β intake drop folder (your current intake
|
| 22 |
+
folder id `386202381022`)
|
| 23 |
+
- `Pothole Operations/Cases/` β case folders get created here as `Ward-XYZ/Case-<id>/`
|
| 24 |
+
- `Pothole Operations/Reports/` β Doc Gen output + audit reports
|
| 25 |
+
4. **Backend reachable from Box cloud.** Relay's *HTTPS Request* action calls your backend.
|
| 26 |
+
Expose it with a tunnel (`ngrok http 3030` or `cloudflared`) and note the public URL,
|
| 27 |
+
e.g. `https://abc123.ngrok.io`. Your backend already exposes the callback endpoint
|
| 28 |
+
`POST /api/box/automate-update` (see [BACKEND_SETUP.md](../BACKEND_SETUP.md)).
|
| 29 |
+
|
| 30 |
+
---
|
| 31 |
+
|
| 32 |
+
## Flow 1 β Intake & Enrichment
|
| 33 |
+
|
| 34 |
+
**Goal:** when a new report lands, enrich it (severity, ward, duplicate, weather) and stamp
|
| 35 |
+
metadata. The hard math (GIS ward mapping, 50β100 m duplicate radius, deterministic severity,
|
| 36 |
+
weather) is NOT something Box AI can do β it runs in your enrichment endpoint, which Relay calls.
|
| 37 |
+
|
| 38 |
+
- **Trigger:** *When a file is uploaded* to `Pothole Operations/Incoming Reports`
|
| 39 |
+
(fire on the `*.json` sidecar so you enrich once per case, not once per photo).
|
| 40 |
+
- **Action 1 β HTTPS Request (enrichment):**
|
| 41 |
+
- Method: `POST`
|
| 42 |
+
- URL: `https://<your-public-url>/api/box/enrich` *(endpoint I will add to the backend)*
|
| 43 |
+
- Body (JSON): `{ "caseId": "{file name without extension}", "sidecarFileId": "{file id}" }`
|
| 44 |
+
- The endpoint computes severityScore/Level, ward/district/zone, duplicateStatus +
|
| 45 |
+
linkedCaseIds, weatherRisk and **writes them straight onto the `potholeCase` metadata**.
|
| 46 |
+
- **Action 2 β Apply Metadata** (`potholeCase`): set `status = Under Review` if not already set.
|
| 47 |
+
- **Action 3 β Create/Move:** move the photo + sidecar into `Cases/Ward-{ward}/Case-{caseId}/`.
|
| 48 |
+
*(Relay folder-from-metadata naming is limited; the enrichment endpoint can create the case
|
| 49 |
+
folder via API instead β your [box-folders.js](../box-folders.js) already does this.)*
|
| 50 |
+
|
| 51 |
+
> **Alternative (more native):** instead of the Relay HTTPS Request, register the enrichment
|
| 52 |
+
> endpoint as a **custom Box Skill** on the Incoming folder. Then enrichment fires
|
| 53 |
+
> automatically on upload and writes metadata cards β no Relay step needed for Flow 1.
|
| 54 |
+
|
| 55 |
+
---
|
| 56 |
+
|
| 57 |
+
## Flow 2 β Review & Approval Routing
|
| 58 |
+
|
| 59 |
+
**Goal:** notify the right ward engineer and capture approve / reject / adjust.
|
| 60 |
+
|
| 61 |
+
- **Trigger:** *When metadata field updates* β `potholeCase.status` becomes `Under Review`.
|
| 62 |
+
- **Condition (severity routing):**
|
| 63 |
+
- If `severityLevel = Critical` β **immediate** path.
|
| 64 |
+
- Else β standard path (optionally a scheduled digest flow).
|
| 65 |
+
- **Action β Assign Approval** to the ward engineer:
|
| 66 |
+
- Route by `ward` (use a Relay condition per ward β assignee email, or a single group).
|
| 67 |
+
- Approver opens the case in Box web/mobile, reviews the photo + metadata + AI summary,
|
| 68 |
+
and can edit `severityLevel` (that's just editing a metadata field) before deciding.
|
| 69 |
+
- **On Approve:**
|
| 70 |
+
- Set `status = Assigned`, `supervisor = {approver}`.
|
| 71 |
+
- HTTPS Request β `POST /api/box/automate-update` with
|
| 72 |
+
`{ "caseId": "...", "workflowStatus": "Assigned", "workflowStage": "crew_dispatch" }`
|
| 73 |
+
so your backend DB stays in sync.
|
| 74 |
+
- **On Reject:** set `status = Rejected`; HTTPS Request with `workflowStatus: "Rejected"`.
|
| 75 |
+
|
| 76 |
+
---
|
| 77 |
+
|
| 78 |
+
## Flow 3 β Work Order Generation (Box Doc Gen)
|
| 79 |
+
|
| 80 |
+
**Goal:** on approval, produce the work order / job sheet PDF into the case folder.
|
| 81 |
+
|
| 82 |
+
- **Trigger:** `potholeCase.status` becomes `Assigned`.
|
| 83 |
+
- **Action β Generate Document (Box Doc Gen):**
|
| 84 |
+
- Template: the Doc Gen template I'll create from [workorder_template.html](../workorder_template.html).
|
| 85 |
+
- Data source: the `potholeCase` metadata on the case folder (merge tags map 1:1 to the
|
| 86 |
+
field keys β see `04` mapping doc once created).
|
| 87 |
+
- Output: `Cases/Ward-{ward}/Case-{caseId}/Work-Order-{workOrderId}.pdf`.
|
| 88 |
+
- **Action β Apply Metadata:** write `workOrderId`.
|
| 89 |
+
- *(Optional)* HTTPS Request to push the work order into the city system of record.
|
| 90 |
+
|
| 91 |
+
---
|
| 92 |
+
|
| 93 |
+
## Flow 4 β Crew Closeout, Sign & Audit
|
| 94 |
+
|
| 95 |
+
**Goal:** field crew submits proof, signs, case is archived audit-ready.
|
| 96 |
+
|
| 97 |
+
- **Trigger:** a **Box Form** submission (crew closeout β built separately) lands a closeout
|
| 98 |
+
record / after-photo in the case folder, OR `status` becomes `In Progress` then closeout.
|
| 99 |
+
- **Action β Request Signature (Box Sign):** send the completion certificate for the crew
|
| 100 |
+
lead's e-signature (replaces the current typed-name field).
|
| 101 |
+
- **On signed:** set `status = Resolved`, `resolvedAt = {now}`; HTTPS Request β
|
| 102 |
+
`/api/box/automate-update` with `workflowStatus: "Resolved"` (your backend then auto-builds
|
| 103 |
+
the before/after + audit report β see `generateAndUploadAuditReport` in [server.js](../server.js)).
|
| 104 |
+
- **Action β Apply Governance:** apply a **retention policy / legal hold** to the case folder
|
| 105 |
+
(Box Governance) so the record is audit-ready with full version history.
|
| 106 |
+
|
| 107 |
+
---
|
| 108 |
+
|
| 109 |
+
## Field reference (Relay conditions & HTTPS payloads)
|
| 110 |
+
|
| 111 |
+
These are the `potholeCase` keys Relay reads/writes, and the matching `report.*` keys your
|
| 112 |
+
backend already uses (so the callback payload to `/api/box/automate-update` lines up):
|
| 113 |
+
|
| 114 |
+
| Metadata key (Box) | Backend `report.*` key | Used in flow |
|
| 115 |
+
|----------------------|-------------------------------|--------------|
|
| 116 |
+
| `status` | `workflowStatus` | all |
|
| 117 |
+
| `severityLevel` | `enrichment.severityLevel` | 1, 2 |
|
| 118 |
+
| `severityScore` | `enrichment.severityScore` | 1, 2 |
|
| 119 |
+
| `ward` / `district` | `ward` / `district` | 1, 2, 3 |
|
| 120 |
+
| `duplicateStatus` | `enrichment.duplicate.*` | 1 |
|
| 121 |
+
| `weatherRisk` | `enrichment.weather.riskLevel`| 1 |
|
| 122 |
+
| `assignedCrew` | `dispatchPlan.crewName` | 3, 4 |
|
| 123 |
+
| `workOrderId` | `workOrderId` | 3 |
|
| 124 |
+
| `resolvedAt` | `resolvedAt` | 4 |
|
| 125 |
+
|
| 126 |
+
**Backend callback contract** (already implemented): `POST /api/box/automate-update`
|
| 127 |
+
```json
|
| 128 |
+
{
|
| 129 |
+
"caseId": "POT-20260604-Q2H0GR",
|
| 130 |
+
"workflowStage": "crew_dispatch",
|
| 131 |
+
"workflowStatus": "Assigned",
|
| 132 |
+
"assignedTo": "ward-12-crew@city.gov"
|
| 133 |
+
}
|
| 134 |
+
```
|
| 135 |
+
If you set `BOX_AUTOMATE_SECRET` on the server, add the same value as an
|
| 136 |
+
`X-Box-Automate-Secret` header on every Relay HTTPS Request action.
|
| 137 |
+
|
| 138 |
+
---
|
| 139 |
+
|
| 140 |
+
## What I still owe you (code side)
|
| 141 |
+
|
| 142 |
+
- [ ] `POST /api/box/enrich` endpoint (wraps existing [enrichment.js](../enrichment.js) +
|
| 143 |
+
[gis.js](../gis.js)) that writes `potholeCase` metadata directly β for Flow 1.
|
| 144 |
+
- [ ] Box Doc Gen template + field-mapping doc β for Flow 3.
|
| 145 |
+
- [ ] (Optional) custom Box Skill manifest if you prefer the auto-on-upload enrichment path.
|
box-build/REVISED_WORKFLOW.md
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Pothole Operations β Revised Workflow (Recommended)
|
| 2 |
+
|
| 3 |
+
This is the recommended end-to-end design after reviewing the codebase, the Box Relay
|
| 4 |
+
build, and the platform constraints. It is optimized to be **robust** (works even if Box AI
|
| 5 |
+
is disabled), **demoable now** (no tunnel, no external city system required), and **honest**
|
| 6 |
+
(every step does what it claims).
|
| 7 |
+
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
## Design decisions (the "why")
|
| 11 |
+
|
| 12 |
+
1. **The backend computes enrichment AND writes Box metadata directly at upload.**
|
| 13 |
+
Severity, GPSβward, duplicate, and weather are computed in our service the moment a report
|
| 14 |
+
is submitted, written into the JSON, and **also applied as Box metadata via the Box API
|
| 15 |
+
during upload.** This is the single most important change.
|
| 16 |
+
- β
The workflow does **not depend on Box AI / Box Extract being enabled** (which the Relay
|
| 17 |
+
screenshots showed as "no longer enabled"). If Extract is on, it's a confirming bonus.
|
| 18 |
+
- β
**No tunnel required** β the backend *pushes* complete data to Box; Box never calls back
|
| 19 |
+
just to enrich.
|
| 20 |
+
- β
Relay, the Box App, Doc Gen, and Sign all run natively on clean metadata.
|
| 21 |
+
|
| 22 |
+
2. **Metadata is the contract.** Box's no-code tools branch and route on **metadata**, never on
|
| 23 |
+
raw file contents. The `potholeCase` template ([metadata-template.json](metadata-template.json))
|
| 24 |
+
is the shared schema everything reads.
|
| 25 |
+
|
| 26 |
+
3. **Source of truth = backend; Box = content + metadata + workflow + audit.** The backend owns
|
| 27 |
+
live state; Box owns the authoritative files, the metadata mirror that drives Relay/App, and
|
| 28 |
+
the audit record. Every Box-side action (approval, closeout) calls back to the backend so the
|
| 29 |
+
two never drift.
|
| 30 |
+
|
| 31 |
+
4. **Work order is generated in Box (Doc Gen), not an external city system.** Removes the only
|
| 32 |
+
hard external dependency. "Push to city system via REST" stays as an *optional future* step.
|
| 33 |
+
|
| 34 |
+
5. **Citizens track status in the portal, never in Box.** No Box login for the public.
|
| 35 |
+
|
| 36 |
+
6. **Six statuses only:** `Submitted` Β· `Under Review` Β· `Assigned` Β· `In Progress` Β· `Resolved` Β· `Rejected`.
|
| 37 |
+
|
| 38 |
+
---
|
| 39 |
+
|
| 40 |
+
## End-to-end flow
|
| 41 |
+
|
| 42 |
+
```
|
| 43 |
+
CITIZEN PORTAL (web/mobile)
|
| 44 |
+
β photo(s) + location + reporter info + on-device AI size detection
|
| 45 |
+
βΌ
|
| 46 |
+
BACKEND ββ enrich at submission ββββββββββββββββββββββββββββββββ
|
| 47 |
+
β β’ GPS β ward / district / zone (point-in-polygon GeoJSON)
|
| 48 |
+
β β’ severity score + level (weighted formula)
|
| 49 |
+
β β’ duplicate check 50β100 m (radius across open cases)
|
| 50 |
+
β β’ weather risk (OpenWeather)
|
| 51 |
+
β β’ assemble COMPLETE JSON
|
| 52 |
+
βΌ
|
| 53 |
+
BOX ββ backend uploads photo + complete JSON + applies metadata β
|
| 54 |
+
β Incoming Reports/ (+ potholeCase metadata set on the file)
|
| 55 |
+
βΌ
|
| 56 |
+
BOX RELAY (triggers on upload; runs on metadata)
|
| 57 |
+
β
|
| 58 |
+
βββ duplicate? βββΊ link to existing case Β· boost severity Β· notify owner Β· STOP
|
| 59 |
+
β
|
| 60 |
+
βββ new βββΊ create Cases/Ward-{ward}/Case-{caseId}/ Β· status = Under Review
|
| 61 |
+
β
|
| 62 |
+
βΌ
|
| 63 |
+
REVIEW ββ notify ward engineer (folder link Β· photo Β· AI summary Β· nearby cases)
|
| 64 |
+
β engineer approves / adjusts severity / rejects (Box web or mobile)
|
| 65 |
+
β β callback to backend (keep state in sync)
|
| 66 |
+
βΌ
|
| 67 |
+
APPROVED ββ backend stamps workOrderId Β· BOX DOC GEN β Work Order PDF
|
| 68 |
+
β status = Assigned
|
| 69 |
+
βΌ
|
| 70 |
+
FIELD ββ crew gets job sheet + mobile BOX FORM
|
| 71 |
+
β repair method Β· materials Β· labor hours Β· after-photo(s)
|
| 72 |
+
β status = In Progress
|
| 73 |
+
βΌ
|
| 74 |
+
SIGN-OFF ββ BOX SIGN (crew lead signature)
|
| 75 |
+
β status = Resolved Β· notify supervisor + risk mgmt
|
| 76 |
+
β backend generates before/after + audit report β case folder
|
| 77 |
+
βΌ
|
| 78 |
+
AUDIT ββ Box Governance (retention / legal hold) + native version history
|
| 79 |
+
β
|
| 80 |
+
CITIZEN PORTAL ββ status synced from backend (track.html) β no Box login
|
| 81 |
+
```
|
| 82 |
+
|
| 83 |
+
---
|
| 84 |
+
|
| 85 |
+
## Phase table
|
| 86 |
+
|
| 87 |
+
| # | Phase | Owner | Key actions | Output | Status |
|
| 88 |
+
|---|-------|-------|-------------|--------|--------|
|
| 89 |
+
| 1 | Intake | Portal | Photo, location (EXIF/device/manual), reporter, on-device AI size | Report posted | `Submitted` |
|
| 90 |
+
| 2 | Enrich | **Backend** | ward, severity, duplicate, weather β complete JSON | Complete JSON | `Submitted` |
|
| 91 |
+
| 3 | Store | **Backend β Box** | Upload photo + JSON; **apply `potholeCase` metadata** | Files + metadata in Box | `Submitted` |
|
| 92 |
+
| 4 | Route | Box Relay | Conditional Split on `duplicateStatus`; create case folder | Case folder / dup link | `Under Review` |
|
| 93 |
+
| 5 | Review | Ward engineer (Box) | Notify + approve/adjust/reject; callback to backend | Decision | `Under Review` β `Rejected`/approved |
|
| 94 |
+
| 6 | Work order | Box Doc Gen | Stamp `workOrderId`; generate Job Sheet PDF | PDF in folder | `Assigned` |
|
| 95 |
+
| 7 | Field | Crew (Box Form) | Method, materials, labor, after-photo | Closeout record | `In Progress` |
|
| 96 |
+
| 8 | Sign-off | Box Sign | Signature; notify supervisor/risk; audit report | Signed cert + reports | `Resolved` |
|
| 97 |
+
| 9 | Transparency | Portal | Citizen looks up case | Status view | (read) |
|
| 98 |
+
| 10 | Audit | Box Governance | Retention, version history, access control | Audit-ready folder | (retained) |
|
| 99 |
+
|
| 100 |
+
---
|
| 101 |
+
|
| 102 |
+
## What runs where
|
| 103 |
+
|
| 104 |
+
**Backend (our service)** β the things Box can't do:
|
| 105 |
+
- GPSβward (geometry), severity (scoring), duplicate (geo-radius), weather (external API)
|
| 106 |
+
- Assemble complete JSON, upload to Box, **apply Box metadata**
|
| 107 |
+
- Generate work order PDF source + before/after + audit reports (already implemented)
|
| 108 |
+
- Hold authoritative live state; expose status to the citizen portal
|
| 109 |
+
|
| 110 |
+
**Box (native, no custom code)** β configured in the console:
|
| 111 |
+
- Relay: trigger, duplicate split, folder creation, approval routing, notifications
|
| 112 |
+
- Doc Gen: Work Order / Job Sheet PDF
|
| 113 |
+
- Box Form: crew closeout Β· Box Sign: signature
|
| 114 |
+
- Governance + version history: audit
|
| 115 |
+
- (Optional) Box App: ops console + dashboards Β· Box AI: image summary if/when enabled
|
| 116 |
+
|
| 117 |
+
---
|
| 118 |
+
|
| 119 |
+
## Why this is the advisable version
|
| 120 |
+
|
| 121 |
+
- **Resilient to the disabled-AI problem** β the backend writes metadata directly, so Relay
|
| 122 |
+
works whether or not Box Extract/Agent is enabled.
|
| 123 |
+
- **No tunnel, no external system** β fully demoable as "files + metadata land in Box and Box
|
| 124 |
+
runs the workflow."
|
| 125 |
+
- **No drift** β single source of truth + callback on every Box action.
|
| 126 |
+
- **Honest on the call** β Box genuinely runs content, workflow, Doc Gen, Sign, and audit; our
|
| 127 |
+
service genuinely runs the geospatial scoring it feeds Box.
|
| 128 |
+
|
| 129 |
+
---
|
| 130 |
+
|
| 131 |
+
## Build checklist
|
| 132 |
+
|
| 133 |
+
**Done**
|
| 134 |
+
- [x] `potholeCase` metadata schema β [metadata-template.json](metadata-template.json)
|
| 135 |
+
- [x] GIS endpoint no longer 500s; serves local ward GeoJSON if present β [server.js](../server.js)
|
| 136 |
+
- [x] Credible authority routing defaults β [authority-routing.json](../authority-routing.json)
|
| 137 |
+
|
| 138 |
+
**Next (backend β I can build)**
|
| 139 |
+
- [ ] Enrich-at-submission: run ward + severity + duplicate + weather before the sidecar uploads
|
| 140 |
+
- [ ] Apply `potholeCase` Box metadata during upload (uses existing metadataScope/templateKey config)
|
| 141 |
+
- [ ] Work-order/approval endpoint for the Doc Gen + status sync
|
| 142 |
+
- [ ] Box Doc Gen template + field map from [workorder_template.html](../workorder_template.html)
|
| 143 |
+
|
| 144 |
+
**Next (Box console β your team)**
|
| 145 |
+
- [ ] Connect CCG (needs Enterprise ID + share intake folder with service account)
|
| 146 |
+
- [ ] Build the Relay flow per [BOX_AUTOMATE_WORKFLOW.md](BOX_AUTOMATE_WORKFLOW.md) (metadata-driven)
|
| 147 |
+
- [ ] Doc Gen template, Box Form, Box Sign, Governance policy
|
| 148 |
+
- [ ] (Optional) Box App console per [BOX_APP_CONSOLE.md](BOX_APP_CONSOLE.md)
|
| 149 |
+
- [ ] (Optional) drop real ward GeoJSON at `data/ward-boundaries.geojson`
|
box-build/WORKFLOW_ONE_BUILD.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Workflow One β Intake β Dispatch (Relay build walkthrough)
|
| 2 |
+
|
| 3 |
+
Build this in the Box Relay canvas, top to bottom. After each node, click **+** and pick the
|
| 4 |
+
palette item named in **bold**. Workflow name: **Workflow One**.
|
| 5 |
+
|
| 6 |
+
**Prereqs:** `Pothole Case` metadata template exists Β· folders `Incoming Detections`,
|
| 7 |
+
`Cases/`, `Cases/Duplicates/`, `Cases/Rejected/` exist.
|
| 8 |
+
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
## TRIGGER β Flow Triggers β File Uploaded
|
| 12 |
+
- **Folder:** `Incoming Detections`
|
| 13 |
+
- **Filter (recommended):** file name *contains* `_metadata.json`
|
| 14 |
+
β fires once per case on the JSON sidecar, not the photo.
|
| 15 |
+
|
| 16 |
+
## 1. AI Agents β Extract Agent
|
| 17 |
+
- **Input file:** the triggering file.
|
| 18 |
+
- **Map to `Pothole Case` metadata:** `Case ID`, `Latitude`, `Longitude`, `Address`, `Ward`,
|
| 19 |
+
`District`, `Pothole Size`, `Severity Score`, `Severity Level`, `Duplicate Status`,
|
| 20 |
+
`Weather Risk`, `AI Review Status`.
|
| 21 |
+
|
| 22 |
+
## 2. AI Agents β Box Agent
|
| 23 |
+
- **Instruction:** "Read the case file and metadata; write a short plain-language case summary
|
| 24 |
+
and confirm the severity level."
|
| 25 |
+
- **Output to metadata:** `AI Summary`.
|
| 26 |
+
|
| 27 |
+
## 3. Branching β Conditional Split *(name it "Duplicate?")*
|
| 28 |
+
- **Condition:** `Pothole Case` βΊ `Duplicate Status` **is** `duplicate`
|
| 29 |
+
- Creates two paths: **Duplicate** (true) and **New** (Else).
|
| 30 |
+
|
| 31 |
+
---
|
| 32 |
+
|
| 33 |
+
### βΈ DUPLICATE path
|
| 34 |
+
**4a. Outcomes β File Action β Move** β `Cases/Duplicates/`
|
| 35 |
+
**5a. Outcomes β Add Metadata** β `Status = Under Review`
|
| 36 |
+
β *path ends (no new work order; it's linked via `Linked Case IDs`).*
|
| 37 |
+
|
| 38 |
+
---
|
| 39 |
+
|
| 40 |
+
### βΈ NEW path
|
| 41 |
+
**4b. Outcomes β Folder Action β Create Folder**
|
| 42 |
+
- **Name (use variables):** `Case-{Case ID}`
|
| 43 |
+
- **Parent path:** `Cases / Ward-{Ward}`
|
| 44 |
+
|
| 45 |
+
**5b. Outcomes β File Action β Move** β into the new `Case-{Case ID}` folder.
|
| 46 |
+
|
| 47 |
+
**6. Outcomes β Add Metadata** β `Status = Under Review`.
|
| 48 |
+
|
| 49 |
+
**7. Outcomes β Send Notification**
|
| 50 |
+
- **To:** ward engineer (route by `Ward`, or a fixed group for the demo).
|
| 51 |
+
- **Message:** include `{AI Summary}`, `{Severity Level}`, and the case folder link.
|
| 52 |
+
|
| 53 |
+
**8. Outcomes β Task Action** *(approval)*
|
| 54 |
+
- **Title:** `Pothole Repair Work Order β {Case ID}`
|
| 55 |
+
- **Assignee:** ward engineer
|
| 56 |
+
- **Instructions:** "Approve, adjust severity, or reject."
|
| 57 |
+
|
| 58 |
+
**9. Branching β Conditional Split** on the task result β **Approved** / **Rejected**
|
| 59 |
+
|
| 60 |
+
#### βΉ APPROVED
|
| 61 |
+
**10. Outcomes β HTTPS Request**
|
| 62 |
+
- **Method:** POST
|
| 63 |
+
- **URL:** `https://<backend-url>/api/box/workorder` *(endpoint I'll provide)*
|
| 64 |
+
- **Body:** `{ "caseId": "{Case ID}" }`
|
| 65 |
+
- Backend stamps `Work Order ID` + generates the **Box Doc Gen** Job Sheet PDF into the folder.
|
| 66 |
+
|
| 67 |
+
**11. Outcomes β Add Metadata** β `Status = Assigned`, `Work Order ID = {returned id}`.
|
| 68 |
+
β **End of Workflow One.**
|
| 69 |
+
|
| 70 |
+
#### βΉ REJECTED
|
| 71 |
+
**10r. Outcomes β Add Metadata** β `Status = Rejected`
|
| 72 |
+
**11r. Outcomes β File Action β Move** β `Cases/Rejected/`
|
| 73 |
+
**12r. Outcomes β Send Notification** β reporter (if contact on file).
|
| 74 |
+
|
| 75 |
+
---
|
| 76 |
+
|
| 77 |
+
## Notes
|
| 78 |
+
- **Doc Gen is not a Relay action** in your palette β that's why step 10 is an **HTTPS Request**
|
| 79 |
+
to the backend (which runs Doc Gen). It needs the backend reachable from Box (a tunnel for the
|
| 80 |
+
demo). If the backend isn't up yet, **stop Workflow One at step 11 (`Status = Assigned`)** and
|
| 81 |
+
generate the work order later β the flow is still complete and demoable.
|
| 82 |
+
- **Guard against loops:** the Add Metadata steps write `Status`; make sure no trigger in this
|
| 83 |
+
flow is "metadata updated," or it can re-fire. This flow triggers on *file upload* only, so
|
| 84 |
+
you're safe.
|
| 85 |
+
- **Crew closeout, Box Sign, resolve** all live in **Workflow Two** (triggered by the Box Form),
|
| 86 |
+
not here.
|
box-build/WORKFLOW_TWO_BUILD.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Workflow Two β Crew Reporting & Closeout
|
| 2 |
+
|
| 3 |
+
Triggered by the crew **Box Form** submission. Build the Form first (it's the trigger), then the flow.
|
| 4 |
+
|
| 5 |
+
**Prereqs:** Box Form "Crew Closeout" built Β· folder `Cases/Closeouts/` (where the form saves) Β·
|
| 6 |
+
Box Sign enabled Β· Box Governance retention policy on `Cases/` (set once, separately).
|
| 7 |
+
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
## Step A β Box Form: "Crew Closeout" (build this first)
|
| 11 |
+
|
| 12 |
+
Fields:
|
| 13 |
+
| Field | Type | Notes |
|
| 14 |
+
|---|---|---|
|
| 15 |
+
| Case ID | Text (required) | crew enters/scans the case it belongs to |
|
| 16 |
+
| Repair Method | Dropdown | Cold patch / Hot mix / Full-depth |
|
| 17 |
+
| Materials Used | Text | |
|
| 18 |
+
| Labor Hours | Number | |
|
| 19 |
+
| After Photo | File upload (required) | the "after" proof |
|
| 20 |
+
| Crew Name | Text | |
|
| 21 |
+
| Notes | Long text | optional |
|
| 22 |
+
|
| 23 |
+
- **Save destination:** `Cases/Closeouts/` (a single intake folder is simplest).
|
| 24 |
+
- If the Form can **map answers to metadata**, map them to the matching Pothole Case fields.
|
| 25 |
+
|
| 26 |
+
---
|
| 27 |
+
|
| 28 |
+
## Step B β Workflow Two (Relay)
|
| 29 |
+
|
| 30 |
+
**TRIGGER:** Box Form submitted β "Crew Closeout"
|
| 31 |
+
*(If there's no native Form trigger, use **File Uploaded β `Cases/Closeouts/`** instead.)*
|
| 32 |
+
|
| 33 |
+
**1. AI Agents β Extract Agent** *(only if the Form didn't write metadata directly)*
|
| 34 |
+
- Pull form values β Pothole Case metadata: `repairMethod`, `materialsUsed`, `laborHours`, `crewName`, `afterPhotoFileId`.
|
| 35 |
+
|
| 36 |
+
**2. Outcomes β Add Metadata**
|
| 37 |
+
- `Status = In Progress` + the closeout fields above.
|
| 38 |
+
|
| 39 |
+
**3. Outcomes β Request Signature (Box Sign)**
|
| 40 |
+
- Sign the **completion certificate / closeout report**.
|
| 41 |
+
- β οΈ Box Sign needs an actual file to sign. The nice version is the **Doc Gen closeout report** (backend-generated). Until that's wired, point Sign at the **work order PDF** or a simple completion doc.
|
| 42 |
+
|
| 43 |
+
**4. Branch on the signature outcome β Completed / Declined / Expired / Cancelled**
|
| 44 |
+
|
| 45 |
+
### βΉ Completed
|
| 46 |
+
- **Add Metadata** β `Status = Resolved`, `resolvedAt = {now}`
|
| 47 |
+
- **HTTPS Request β backend** β generate **Closeout Report (Doc Gen)** + audit report into the case folder *(backend step β deferred until backend is up; for now skip and just set Resolved)*
|
| 48 |
+
- **Send Notification** β supervisor + risk management (Box users)
|
| 49 |
+
- *(Optional)* **File Action β Apply Classification** β "Closed β Retain" (Box Shield/Governance)
|
| 50 |
+
|
| 51 |
+
### βΉ Declined
|
| 52 |
+
- **Send Notification** β re-route / fix and resubmit.
|
| 53 |
+
|
| 54 |
+
### βΉ Expired
|
| 55 |
+
- **Send Notification** β remind the signer.
|
| 56 |
+
|
| 57 |
+
### βΉ Cancelled
|
| 58 |
+
- **Add Metadata** β hold / note.
|
| 59 |
+
|
| 60 |
+
---
|
| 61 |
+
|
| 62 |
+
## Notes
|
| 63 |
+
- **Box Governance** (retention / legal hold) is a **policy set once on the `Cases/` folder** β it auto-applies, it's not a Relay node.
|
| 64 |
+
- **Citizen** sees `Resolved` via the **portal** (no Box notification to them).
|
| 65 |
+
- **Two pieces wait on the backend:** the Doc Gen **closeout report** fill, and (ideally) the document Box Sign signs. Everything else is native Relay and buildable now.
|
box-build/add-breakdown-fields.sh
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# Adds the 4 severity sub-score floats + 4 detail strings to the potholeCase
|
| 3 |
+
# metadata template so the breakdown can be shown in the Box App dashboard.
|
| 4 |
+
# Additive only β does not touch existing fields or Relay flows.
|
| 5 |
+
# Usage: bash add-breakdown-fields.sh <FRESH_DEV_TOKEN>
|
| 6 |
+
set -euo pipefail
|
| 7 |
+
TOKEN="${1:?Pass a fresh Box dev token as the first argument}"
|
| 8 |
+
|
| 9 |
+
curl -s -w "\nHTTP %{http_code}\n" \
|
| 10 |
+
-X PUT "https://api.box.com/2.0/metadata_templates/enterprise/potholeCase/schema" \
|
| 11 |
+
-H "Authorization: Bearer $TOKEN" \
|
| 12 |
+
-H "Content-Type: application/json-patch+json" \
|
| 13 |
+
-d '[
|
| 14 |
+
{"op":"addField","data":{"type":"float","key":"priorNoticeScore","displayName":"Prior Notice Score (0-30)"}},
|
| 15 |
+
{"op":"addField","data":{"type":"float","key":"trafficScore","displayName":"Traffic Score (0-25)"}},
|
| 16 |
+
{"op":"addField","data":{"type":"float","key":"damageScore","displayName":"Damage Score (0-25)"}},
|
| 17 |
+
{"op":"addField","data":{"type":"float","key":"weatherScore","displayName":"Weather Score (0-10)"}},
|
| 18 |
+
{"op":"addField","data":{"type":"string","key":"priorNoticeDetail","displayName":"Prior Notice Detail"}},
|
| 19 |
+
{"op":"addField","data":{"type":"string","key":"trafficDetail","displayName":"Traffic Detail"}},
|
| 20 |
+
{"op":"addField","data":{"type":"string","key":"damageDetail","displayName":"Damage Detail"}},
|
| 21 |
+
{"op":"addField","data":{"type":"string","key":"weatherDetail","displayName":"Weather Detail"}}
|
| 22 |
+
]'
|
box-build/metadata-template.json
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"_comment": "Box metadata template for a pothole case. This is the single shared schema that Box Automate (Relay) conditions, Box Doc Gen merge fields, the Box App console, and the enrichment Skill all read and write. Create it once via the Box Admin Console (Content > Metadata) or POST this payload to https://api.box.com/2.0/metadata_templates/schema. Field keys match the report.* fields the backend already produces in data/cases.json.",
|
| 3 |
+
"scope": "enterprise",
|
| 4 |
+
"templateKey": "potholeCase",
|
| 5 |
+
"displayName": "Pothole Case",
|
| 6 |
+
"hidden": false,
|
| 7 |
+
"copyInstanceOnItemCopy": true,
|
| 8 |
+
"fields": [
|
| 9 |
+
{ "type": "string", "key": "caseId", "displayName": "Case ID" },
|
| 10 |
+
{
|
| 11 |
+
"type": "enum",
|
| 12 |
+
"key": "status",
|
| 13 |
+
"displayName": "Status",
|
| 14 |
+
"options": [
|
| 15 |
+
{ "key": "Submitted" },
|
| 16 |
+
{ "key": "Under Review" },
|
| 17 |
+
{ "key": "Assigned" },
|
| 18 |
+
{ "key": "In Progress" },
|
| 19 |
+
{ "key": "Resolved" },
|
| 20 |
+
{ "key": "Rejected" }
|
| 21 |
+
]
|
| 22 |
+
},
|
| 23 |
+
{ "type": "date", "key": "submittedAt", "displayName": "Submitted At" },
|
| 24 |
+
{ "type": "string", "key": "address", "displayName": "Address" },
|
| 25 |
+
{ "type": "string", "key": "ward", "displayName": "Ward" },
|
| 26 |
+
{ "type": "string", "key": "district", "displayName": "District" },
|
| 27 |
+
{ "type": "string", "key": "maintenanceZone", "displayName": "Maintenance Zone" },
|
| 28 |
+
{ "type": "float", "key": "latitude", "displayName": "Latitude" },
|
| 29 |
+
{ "type": "float", "key": "longitude", "displayName": "Longitude" },
|
| 30 |
+
{ "type": "float", "key": "severityScore", "displayName": "Severity Score (0-100)" },
|
| 31 |
+
{
|
| 32 |
+
"type": "enum",
|
| 33 |
+
"key": "severityLevel",
|
| 34 |
+
"displayName": "Severity Level",
|
| 35 |
+
"options": [
|
| 36 |
+
{ "key": "Low" },
|
| 37 |
+
{ "key": "Medium" },
|
| 38 |
+
{ "key": "High" },
|
| 39 |
+
{ "key": "Critical" }
|
| 40 |
+
]
|
| 41 |
+
},
|
| 42 |
+
{
|
| 43 |
+
"type": "enum",
|
| 44 |
+
"key": "potholeSize",
|
| 45 |
+
"displayName": "Pothole Size",
|
| 46 |
+
"options": [
|
| 47 |
+
{ "key": "Small" },
|
| 48 |
+
{ "key": "Medium" },
|
| 49 |
+
{ "key": "Large" }
|
| 50 |
+
]
|
| 51 |
+
},
|
| 52 |
+
{
|
| 53 |
+
"type": "enum",
|
| 54 |
+
"key": "duplicateStatus",
|
| 55 |
+
"displayName": "Duplicate Status",
|
| 56 |
+
"options": [
|
| 57 |
+
{ "key": "new" },
|
| 58 |
+
{ "key": "duplicate" },
|
| 59 |
+
{ "key": "pending" }
|
| 60 |
+
]
|
| 61 |
+
},
|
| 62 |
+
{ "type": "float", "key": "duplicateCount", "displayName": "Duplicate Count" },
|
| 63 |
+
{ "type": "string", "key": "linkedCaseIds", "displayName": "Linked Case IDs" },
|
| 64 |
+
{
|
| 65 |
+
"type": "enum",
|
| 66 |
+
"key": "weatherRisk",
|
| 67 |
+
"displayName": "Weather Risk",
|
| 68 |
+
"options": [
|
| 69 |
+
{ "key": "low" },
|
| 70 |
+
{ "key": "medium" },
|
| 71 |
+
{ "key": "high" }
|
| 72 |
+
]
|
| 73 |
+
},
|
| 74 |
+
{
|
| 75 |
+
"type": "enum",
|
| 76 |
+
"key": "aiReviewStatus",
|
| 77 |
+
"displayName": "AI Review Status",
|
| 78 |
+
"options": [
|
| 79 |
+
{ "key": "auto_confirmed" },
|
| 80 |
+
{ "key": "manual_review" },
|
| 81 |
+
{ "key": "manual_confirmed" },
|
| 82 |
+
{ "key": "manual_rejected" }
|
| 83 |
+
]
|
| 84 |
+
},
|
| 85 |
+
{ "type": "string", "key": "aiSummary", "displayName": "AI Summary" },
|
| 86 |
+
{ "type": "string", "key": "assignedCrew", "displayName": "Assigned Crew" },
|
| 87 |
+
{ "type": "string", "key": "workOrderId", "displayName": "Work Order ID" },
|
| 88 |
+
{ "type": "string", "key": "supervisor", "displayName": "Supervisor / Ward Engineer" },
|
| 89 |
+
{ "type": "date", "key": "resolvedAt", "displayName": "Resolved At" },
|
| 90 |
+
{ "type": "string", "key": "reporterContact", "displayName": "Reporter Contact" },
|
| 91 |
+
{ "type": "string", "key": "boxCaseFolderId", "displayName": "Case Folder ID" }
|
| 92 |
+
]
|
| 93 |
+
}
|
box-build/relay-flow-diagram.html
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>Pothole Portal β Box Automate (Relay) Flows</title>
|
| 7 |
+
<style>
|
| 8 |
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
| 9 |
+
body {
|
| 10 |
+
font-family: 'Segoe UI', Inter, system-ui, Arial, sans-serif;
|
| 11 |
+
color: #1f2733;
|
| 12 |
+
background-color: #fbfbfc;
|
| 13 |
+
background-image: radial-gradient(#e3e6ee 1px, transparent 1px);
|
| 14 |
+
background-size: 22px 22px;
|
| 15 |
+
padding: 36px 16px 70px;
|
| 16 |
+
}
|
| 17 |
+
.wrap { max-width: 760px; margin: 0 auto; }
|
| 18 |
+
h1 { font-size: 22px; font-weight: 800; text-align: center; color: #0b1f3a; }
|
| 19 |
+
.wname { text-align:center; font-size:12px; color:#69728a; margin-top:6px; }
|
| 20 |
+
.wname b { color:#0b1f3a; }
|
| 21 |
+
|
| 22 |
+
.banner {
|
| 23 |
+
margin: 16px auto 10px; max-width: 620px; background: #eaf2fd; border: 1px solid #b6d3f5;
|
| 24 |
+
color: #134a8e; border-radius: 9px; padding: 9px 14px; font-size: 12px; text-align: center;
|
| 25 |
+
}
|
| 26 |
+
.banner b { color:#0b3c78; }
|
| 27 |
+
|
| 28 |
+
.flowhdr {
|
| 29 |
+
display:flex; align-items:center; gap:10px; justify-content:center; margin: 30px auto 16px;
|
| 30 |
+
}
|
| 31 |
+
.flowhdr .pill {
|
| 32 |
+
font-size: 12px; font-weight: 800; letter-spacing:.04em; color:#0b1f3a;
|
| 33 |
+
background:#fff; border:1px solid #d3d9e6; border-radius: 24px; padding: 6px 16px;
|
| 34 |
+
box-shadow: 0 1px 3px rgba(15,30,60,.06);
|
| 35 |
+
}
|
| 36 |
+
.flowhdr .trig { font-size: 11px; color:#69728a; }
|
| 37 |
+
|
| 38 |
+
.col { display: flex; flex-direction: column; align-items: center; }
|
| 39 |
+
|
| 40 |
+
.node {
|
| 41 |
+
width: 380px; max-width: 100%; background: #fff; border: 1px solid #e4e7ef;
|
| 42 |
+
border-radius: 11px; box-shadow: 0 1px 3px rgba(15,30,60,.07);
|
| 43 |
+
padding: 11px 13px; display: flex; gap: 11px; align-items: flex-start;
|
| 44 |
+
}
|
| 45 |
+
.ico {
|
| 46 |
+
flex: 0 0 30px; width: 30px; height: 30px; border-radius: 8px; color: #fff;
|
| 47 |
+
display: flex; align-items: center; justify-content: center; font-size: 15px; font-weight: 700;
|
| 48 |
+
}
|
| 49 |
+
.node .title { font-size: 13.5px; font-weight: 700; line-height: 1.25; }
|
| 50 |
+
.node .kind { font-size: 10.5px; color:#969db0; font-weight:600; margin-top:1px; }
|
| 51 |
+
.node .desc { font-size: 11.5px; color:#5d6678; margin-top: 5px; line-height: 1.35; }
|
| 52 |
+
.aibadge { display:inline-block; margin-left:6px; font-size:9px; font-weight:800; vertical-align:middle;
|
| 53 |
+
color:#7c2bd6; background:#f3e8ff; border:1px solid #ddbdf7; border-radius:10px; padding:0 6px; letter-spacing:.05em; }
|
| 54 |
+
.note { display:inline-block; margin-top:6px; font-size:10px; font-weight:600; color:#8a5a0a;
|
| 55 |
+
background:#fff6e3; border:1px solid #f1c878; border-radius:8px; padding:2px 8px; }
|
| 56 |
+
.repl { display:inline-block; margin-top:6px; font-size:10px; font-weight:700; color:#0c6b46;
|
| 57 |
+
background:#e7f7ef; border:1px solid #b6e3cd; border-radius:20px; padding:1px 8px; }
|
| 58 |
+
|
| 59 |
+
/* icon colors */
|
| 60 |
+
.c-trigger { background:#0061d5; }
|
| 61 |
+
.c-extract { background:#a855f7; }
|
| 62 |
+
.c-agent { background:#d946ef; }
|
| 63 |
+
.c-split { background:#64748b; }
|
| 64 |
+
.c-folder { background:#0a73e0; }
|
| 65 |
+
.c-file { background:#3b8ee8; }
|
| 66 |
+
.c-meta { background:#7c3aed; }
|
| 67 |
+
.c-http { background:#0e9f6e; }
|
| 68 |
+
.c-notify { background:#e8920c; }
|
| 69 |
+
.c-task { background:#2563eb; }
|
| 70 |
+
.c-form { background:#0891b2; }
|
| 71 |
+
.c-sign { background:#9333ea; }
|
| 72 |
+
.c-gov { background:#475569; }
|
| 73 |
+
.c-end { background:#94a3b8; }
|
| 74 |
+
|
| 75 |
+
.conn { width: 2px; height: 22px; background: #c4cad8; }
|
| 76 |
+
.plus {
|
| 77 |
+
width: 18px; height: 18px; border-radius: 50%; border: 1.5px solid #c4cad8; color:#9aa2b4;
|
| 78 |
+
display:flex; align-items:center; justify-content:center; font-size:13px; line-height:1; background:#fff;
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
.branchbar { width: 100%; }
|
| 82 |
+
.branchbar .hline { height: 2px; background: #c4cad8; margin: 0 auto; }
|
| 83 |
+
.cols { display: grid; gap: 14px; }
|
| 84 |
+
.cols.two { grid-template-columns: 1fr 1fr; }
|
| 85 |
+
.cols.four { grid-template-columns: repeat(4, 1fr); }
|
| 86 |
+
.bcol { display:flex; flex-direction:column; align-items:center; }
|
| 87 |
+
.tick { width:2px; height:14px; background:#c4cad8; }
|
| 88 |
+
.blabel {
|
| 89 |
+
font-size: 10.5px; font-weight: 700; letter-spacing:.03em; color:#54607a;
|
| 90 |
+
background:#eef1f7; border:1px solid #d8deea; border-radius: 20px; padding: 2px 11px; margin-bottom: 8px;
|
| 91 |
+
}
|
| 92 |
+
.blabel.yes { color:#0c6b46; background:#e7f7ef; border-color:#b6e3cd; }
|
| 93 |
+
.blabel.no { color:#ad2222; background:#fdeaea; border-color:#f1bcbc; }
|
| 94 |
+
|
| 95 |
+
.node.sm { width: 100%; padding: 9px 10px; }
|
| 96 |
+
.node.sm .ico { flex-basis:24px; width:24px; height:24px; font-size:12px; border-radius:6px; }
|
| 97 |
+
.node.sm .title { font-size: 12px; }
|
| 98 |
+
.node.sm .desc { font-size: 10.5px; margin-top:3px; }
|
| 99 |
+
|
| 100 |
+
.endpill { font-size:10.5px; font-weight:700; color:#64748b; background:#eef1f7;
|
| 101 |
+
border:1px solid #d8deea; border-radius:20px; padding:3px 12px; margin-top:8px; }
|
| 102 |
+
|
| 103 |
+
.divider { display:flex; align-items:center; gap:12px; width:100%; max-width:560px; margin:8px auto; color:#8a93a6; font-size:11.5px; font-weight:600; }
|
| 104 |
+
.divider::before, .divider::after { content:""; flex:1; height:1px; background:#d3d9e6; }
|
| 105 |
+
</style>
|
| 106 |
+
</head>
|
| 107 |
+
<body>
|
| 108 |
+
<div class="wrap">
|
| 109 |
+
<h1>Box Automate (Relay) β Pothole Portal</h1>
|
| 110 |
+
<div class="wname">Two flows Β· <b>Box Extract</b> + <b>Box Agent</b> enabled Β· backend supplies the GIS & weather data AI can't compute</div>
|
| 111 |
+
<div class="banner"><b>Full Box-native build:</b> AI agents do extraction, scoring & summary; a lightweight data lookup provides GPSβward & weather inputs.</div>
|
| 112 |
+
|
| 113 |
+
<!-- ============ FLOW 1 ============ -->
|
| 114 |
+
<div class="flowhdr">
|
| 115 |
+
<span class="pill">FLOW 1 β Intake β Dispatch</span>
|
| 116 |
+
<span class="trig">triggered by file upload</span>
|
| 117 |
+
</div>
|
| 118 |
+
|
| 119 |
+
<div class="col">
|
| 120 |
+
|
| 121 |
+
<div class="node">
|
| 122 |
+
<div class="ico c-trigger">β‘</div>
|
| 123 |
+
<div><div class="title">File Uploaded</div>
|
| 124 |
+
<div class="kind">TRIGGER Β· Incoming Reports</div>
|
| 125 |
+
<div class="desc">Citizen report lands: photo + <b>_metadata.json</b>.</div></div>
|
| 126 |
+
</div>
|
| 127 |
+
<div class="conn"></div>
|
| 128 |
+
|
| 129 |
+
<div class="node">
|
| 130 |
+
<div class="ico c-extract">β</div>
|
| 131 |
+
<div><div class="title">Extract Agent <span class="aibadge">AI</span></div>
|
| 132 |
+
<div class="kind">BOX EXTRACT Β· file β metadata</div>
|
| 133 |
+
<div class="desc">Reads the JSON and writes the <b>potholeCase</b> metadata: caseId, location, AI size, road score.</div></div>
|
| 134 |
+
</div>
|
| 135 |
+
<div class="conn"></div>
|
| 136 |
+
|
| 137 |
+
<div class="node">
|
| 138 |
+
<div class="ico c-agent">β¦</div>
|
| 139 |
+
<div><div class="title">Box Agent β Enrichment & Scoring <span class="aibadge">AI</span></div>
|
| 140 |
+
<div class="kind">BOX AGENT Β· reasoning</div>
|
| 141 |
+
<div class="desc">Evaluates severity score & level, writes a case summary, and reasons over duplicate likelihood.</div>
|
| 142 |
+
<span class="note">GPSβward & weather supplied via HTTPS data lookup (AI can't compute geometry)</span></div>
|
| 143 |
+
</div>
|
| 144 |
+
<div class="conn"></div>
|
| 145 |
+
|
| 146 |
+
<div class="node">
|
| 147 |
+
<div class="ico c-split">β</div>
|
| 148 |
+
<div><div class="title">Duplicate Check</div>
|
| 149 |
+
<div class="kind">CONDITIONAL SPLIT Β· metadata: duplicateStatus</div>
|
| 150 |
+
<div class="desc">Within 50β100 m of an open case?</div></div>
|
| 151 |
+
</div>
|
| 152 |
+
|
| 153 |
+
<div class="branchbar">
|
| 154 |
+
<div class="hline" style="width:70%;"></div>
|
| 155 |
+
<div class="cols two">
|
| 156 |
+
<div class="bcol">
|
| 157 |
+
<div class="tick"></div><div class="blabel">DUPLICATE</div>
|
| 158 |
+
<div class="node sm"><div class="ico c-file">β€</div>
|
| 159 |
+
<div><div class="title">Link to Parent Case</div><div class="kind">FILE ACTION + ADD METADATA</div>
|
| 160 |
+
<div class="desc">Move to Duplicates Β· boost severity.</div></div></div>
|
| 161 |
+
<div class="endpill">END Β· no new work order</div>
|
| 162 |
+
</div>
|
| 163 |
+
<div class="bcol">
|
| 164 |
+
<div class="tick"></div><div class="blabel">NEW REPORT</div>
|
| 165 |
+
<div class="node sm"><div class="ico c-folder">β£</div>
|
| 166 |
+
<div><div class="title">Create Case Folder</div><div class="kind">FOLDER ACTION</div>
|
| 167 |
+
<div class="desc">Potholes / Ward-{ward} / Case-{caseId}</div></div></div>
|
| 168 |
+
</div>
|
| 169 |
+
</div>
|
| 170 |
+
</div>
|
| 171 |
+
|
| 172 |
+
<div class="conn"></div>
|
| 173 |
+
<div class="node"><div class="ico c-meta">M</div>
|
| 174 |
+
<div><div class="title">Set Status & Move Into Folder</div>
|
| 175 |
+
<div class="kind">ADD METADATA + FILE ACTION</div>
|
| 176 |
+
<div class="desc">status = <b>Under Review</b>.</div></div></div>
|
| 177 |
+
<div class="conn"></div>
|
| 178 |
+
|
| 179 |
+
<div class="node"><div class="ico c-notify">β</div>
|
| 180 |
+
<div><div class="title">Notify Ward Engineer</div>
|
| 181 |
+
<div class="kind">SEND NOTIFICATION</div>
|
| 182 |
+
<div class="desc">Folder link Β· photo Β· AI summary Β· severity Β· nearby cases.</div></div></div>
|
| 183 |
+
<div class="conn"></div>
|
| 184 |
+
|
| 185 |
+
<div class="node"><div class="ico c-task">β</div>
|
| 186 |
+
<div><div class="title">Review & Approval</div>
|
| 187 |
+
<div class="kind">TASK ACTION Β· Approval from authority</div>
|
| 188 |
+
<div class="desc">Approve Β· adjust severity Β· or reject (web / mobile).</div></div></div>
|
| 189 |
+
|
| 190 |
+
<div class="branchbar">
|
| 191 |
+
<div class="hline" style="width:70%;"></div>
|
| 192 |
+
<div class="cols two">
|
| 193 |
+
<div class="bcol">
|
| 194 |
+
<div class="tick"></div><div class="blabel no">REJECTED</div>
|
| 195 |
+
<div class="node sm"><div class="ico c-meta">M</div>
|
| 196 |
+
<div><div class="title">Reject & Archive</div><div class="kind">ADD METADATA + FILE ACTION</div>
|
| 197 |
+
<div class="desc">status = Rejected Β· notify reporter.</div></div></div>
|
| 198 |
+
<div class="endpill">END</div>
|
| 199 |
+
</div>
|
| 200 |
+
<div class="bcol">
|
| 201 |
+
<div class="tick"></div><div class="blabel yes">APPROVED</div>
|
| 202 |
+
<div class="node sm"><div class="ico c-http">β</div>
|
| 203 |
+
<div><div class="title">Generate Work Order</div><div class="kind">HTTPS β backend β Box Doc Gen</div>
|
| 204 |
+
<div class="desc">Stamp workOrderId Β· Job Sheet PDF into folder.</div>
|
| 205 |
+
<span class="repl">No city-system API</span></div></div>
|
| 206 |
+
<div class="tick"></div>
|
| 207 |
+
<div class="node sm"><div class="ico c-meta">M</div>
|
| 208 |
+
<div><div class="title">status = Assigned</div><div class="kind">ADD METADATA</div></div></div>
|
| 209 |
+
</div>
|
| 210 |
+
</div>
|
| 211 |
+
</div>
|
| 212 |
+
</div>
|
| 213 |
+
|
| 214 |
+
<div class="flowhdr" style="margin-top:18px;"><span class="trig">β job sheet sent Β· crew repairs on site β</span></div>
|
| 215 |
+
|
| 216 |
+
<!-- ============ FLOW 2 ============ -->
|
| 217 |
+
<div class="flowhdr">
|
| 218 |
+
<span class="pill">FLOW 2 β Crew Reporting & Closeout</span>
|
| 219 |
+
<span class="trig">triggered by Box Form submission</span>
|
| 220 |
+
</div>
|
| 221 |
+
|
| 222 |
+
<div class="col">
|
| 223 |
+
|
| 224 |
+
<div class="node">
|
| 225 |
+
<div class="ico c-form">β¦</div>
|
| 226 |
+
<div><div class="title">Crew Submits Box Form (mobile)</div>
|
| 227 |
+
<div class="kind">TRIGGER Β· Box Form Β· on-site</div>
|
| 228 |
+
<div class="desc">Repair method (cold patch / hot mix / full-depth) Β· materials Β· labor hours Β· <b>after-photo(s)</b> β case folder.</div></div>
|
| 229 |
+
</div>
|
| 230 |
+
<div class="conn"></div>
|
| 231 |
+
|
| 232 |
+
<div class="node"><div class="ico c-meta">M</div>
|
| 233 |
+
<div><div class="title">Record Closeout Data</div>
|
| 234 |
+
<div class="kind">ADD METADATA</div>
|
| 235 |
+
<div class="desc">status = <b>In Progress</b> Β· materials, labor, after-photo IDs written to the case.</div></div></div>
|
| 236 |
+
<div class="conn"></div>
|
| 237 |
+
|
| 238 |
+
<div class="node"><div class="ico c-sign">β</div>
|
| 239 |
+
<div><div class="title">Request Signature</div>
|
| 240 |
+
<div class="kind">REQUEST SIGNATURE Β· Box Sign</div>
|
| 241 |
+
<div class="desc">Crew lead signs the completion certificate.</div></div></div>
|
| 242 |
+
|
| 243 |
+
<div class="branchbar">
|
| 244 |
+
<div class="hline" style="width:86%;"></div>
|
| 245 |
+
<div class="cols four">
|
| 246 |
+
<div class="bcol"><div class="tick"></div><div class="blabel yes">COMPLETED</div>
|
| 247 |
+
<div class="node sm"><div class="ico c-http">β</div>
|
| 248 |
+
<div><div class="title">Resolve Case</div><div class="kind">HTTPS β backend</div>
|
| 249 |
+
<div class="desc">status = Resolved Β· audit + before/after report.</div></div></div></div>
|
| 250 |
+
<div class="bcol"><div class="tick"></div><div class="blabel no">DECLINED</div>
|
| 251 |
+
<div class="node sm"><div class="ico c-notify">β</div>
|
| 252 |
+
<div><div class="title">Re-route</div><div class="kind">SEND NOTIFICATION</div></div></div></div>
|
| 253 |
+
<div class="bcol"><div class="tick"></div><div class="blabel">EXPIRED</div>
|
| 254 |
+
<div class="node sm"><div class="ico c-notify">β</div>
|
| 255 |
+
<div><div class="title">Remind</div><div class="kind">SEND NOTIFICATION</div></div></div></div>
|
| 256 |
+
<div class="bcol"><div class="tick"></div><div class="blabel">CANCELLED</div>
|
| 257 |
+
<div class="node sm"><div class="ico c-meta">M</div>
|
| 258 |
+
<div><div class="title">Hold</div><div class="kind">ADD METADATA</div></div></div></div>
|
| 259 |
+
</div>
|
| 260 |
+
</div>
|
| 261 |
+
|
| 262 |
+
<div class="conn"></div>
|
| 263 |
+
<div class="node"><div class="ico c-notify">β</div>
|
| 264 |
+
<div><div class="title">Notify Supervisor + Risk Management</div>
|
| 265 |
+
<div class="kind">SEND NOTIFICATION</div>
|
| 266 |
+
<div class="desc">Citizen portal status updated to βResolvedβ.</div></div></div>
|
| 267 |
+
<div class="conn"></div>
|
| 268 |
+
|
| 269 |
+
<div class="node"><div class="ico c-gov">G</div>
|
| 270 |
+
<div><div class="title">Audit-Ready Case Record</div>
|
| 271 |
+
<div class="kind">BOX GOVERNANCE</div>
|
| 272 |
+
<div class="desc">Retention / legal hold Β· full version history Β· activity log Β· access control.</div></div></div>
|
| 273 |
+
|
| 274 |
+
</div>
|
| 275 |
+
</div>
|
| 276 |
+
</body>
|
| 277 |
+
</html>
|
box-build/revised-workflow-diagram.html
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>Pothole Operations β Revised Workflow</title>
|
| 7 |
+
<style>
|
| 8 |
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
| 9 |
+
body {
|
| 10 |
+
font-family: 'Segoe UI', Inter, system-ui, Arial, sans-serif;
|
| 11 |
+
background: #f4f6fb;
|
| 12 |
+
color: #1f2733;
|
| 13 |
+
padding: 40px 16px 64px;
|
| 14 |
+
line-height: 1.4;
|
| 15 |
+
}
|
| 16 |
+
.wrap { max-width: 860px; margin: 0 auto; }
|
| 17 |
+
h1 { font-size: 26px; font-weight: 800; text-align: center; color: #0b1f3a; }
|
| 18 |
+
.subtitle { text-align: center; color: #5b6679; font-size: 14px; margin-top: 6px; }
|
| 19 |
+
|
| 20 |
+
.legend { display: flex; gap: 18px; justify-content: center; margin: 18px 0 28px; flex-wrap: wrap; }
|
| 21 |
+
.legend span { display: inline-flex; align-items: center; gap: 7px; font-size: 12.5px; color: #44506a; }
|
| 22 |
+
.dot { width: 12px; height: 12px; border-radius: 3px; display: inline-block; }
|
| 23 |
+
|
| 24 |
+
.phase {
|
| 25 |
+
text-align: center; font-size: 12px; font-weight: 700; letter-spacing: .08em;
|
| 26 |
+
text-transform: uppercase; color: #7a8499; margin: 26px 0 14px;
|
| 27 |
+
border-top: 1px dashed #c4ccda; padding-top: 12px;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
.row { display: grid; grid-template-columns: 1fr minmax(280px, 360px) 1fr; align-items: center; gap: 12px; }
|
| 31 |
+
.row .center { grid-column: 2; }
|
| 32 |
+
|
| 33 |
+
.node {
|
| 34 |
+
border-radius: 12px; padding: 14px 18px; text-align: center;
|
| 35 |
+
border: 1px solid; box-shadow: 0 2px 6px rgba(15,30,60,.06);
|
| 36 |
+
}
|
| 37 |
+
.node .t { font-weight: 700; font-size: 15px; }
|
| 38 |
+
.node .d { font-size: 12.5px; margin-top: 4px; opacity: .92; }
|
| 39 |
+
|
| 40 |
+
.portal { background: #e7f7ef; border-color: #9ae0c0; color: #0c6b46; }
|
| 41 |
+
.backend { background: #f1ecfe; border-color: #c3b1f5; color: #4c2aa3; }
|
| 42 |
+
.box { background: #e9f2fd; border-color: #a9cdf6; color: #0a4a9c; }
|
| 43 |
+
.review { background: #e7f7ef; border-color: #9ae0c0; color: #0c6b46; }
|
| 44 |
+
.crew { background: #fdeaea; border-color: #f4b4b4; color: #ad2222; }
|
| 45 |
+
.audit { background: #eef2f8; border-color: #ccd6e6; color: #3b475e; }
|
| 46 |
+
.decision {
|
| 47 |
+
background: #fff6e3; border: 1px solid #f1c878; color: #92600c;
|
| 48 |
+
border-radius: 12px; padding: 14px 18px; text-align: center; font-weight: 700; font-size: 15px;
|
| 49 |
+
box-shadow: 0 2px 6px rgba(15,30,60,.06);
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
.note {
|
| 53 |
+
background: #fff; border: 1px solid #e1e7f0; border-radius: 9px;
|
| 54 |
+
padding: 9px 11px; font-size: 11.5px; color: #5b6679; box-shadow: 0 1px 3px rgba(15,30,60,.05);
|
| 55 |
+
}
|
| 56 |
+
.note b { color: #34405a; display: block; margin-bottom: 3px; font-size: 11px; }
|
| 57 |
+
.note.left { justify-self: end; } .note.right { justify-self: start; }
|
| 58 |
+
|
| 59 |
+
.status {
|
| 60 |
+
display: inline-block; margin-top: 8px; font-size: 11px; font-weight: 700;
|
| 61 |
+
background: rgba(255,255,255,.6); border-radius: 20px; padding: 2px 10px; letter-spacing: .03em;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
.conn { width: 2px; height: 26px; background: #b9c3d4; margin: 4px auto; }
|
| 65 |
+
.conn.sm { height: 16px; }
|
| 66 |
+
|
| 67 |
+
.branch { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; max-width: 720px; margin: 0 auto; }
|
| 68 |
+
.branch .lbl { text-align: center; font-size: 11px; font-weight: 700; color: #7a8499; margin-bottom: 6px; letter-spacing:.05em; }
|
| 69 |
+
.tag-yes { color: #ad7100; } .tag-no { color: #0a4a9c; }
|
| 70 |
+
|
| 71 |
+
.footer-note {
|
| 72 |
+
margin: 26px auto 0; max-width: 720px; background: #fff; border: 1px dashed #b9c3d4;
|
| 73 |
+
border-radius: 10px; padding: 12px 16px; font-size: 12.5px; color: #44506a; text-align: center;
|
| 74 |
+
}
|
| 75 |
+
.footer-note b { color: #0b1f3a; }
|
| 76 |
+
</style>
|
| 77 |
+
</head>
|
| 78 |
+
<body>
|
| 79 |
+
<div class="wrap">
|
| 80 |
+
<h1>Pothole Operations β Revised Workflow</h1>
|
| 81 |
+
<div class="subtitle">Backend computes the geospatial scoring Β· Box runs content, workflow, documents, signature & audit</div>
|
| 82 |
+
|
| 83 |
+
<div class="legend">
|
| 84 |
+
<span><i class="dot" style="background:#0c6b46"></i> Citizen portal</span>
|
| 85 |
+
<span><i class="dot" style="background:#4c2aa3"></i> Our backend service</span>
|
| 86 |
+
<span><i class="dot" style="background:#0a4a9c"></i> Box (native)</span>
|
| 87 |
+
<span><i class="dot" style="background:#92600c"></i> Decision</span>
|
| 88 |
+
<span><i class="dot" style="background:#ad2222"></i> Field crew</span>
|
| 89 |
+
</div>
|
| 90 |
+
|
| 91 |
+
<!-- PHASE 1 -->
|
| 92 |
+
<div class="phase">Phase 1 β Citizen Intake</div>
|
| 93 |
+
<div class="row">
|
| 94 |
+
<div class="note left"><b>Form fields</b>Name / contact Β· Location or address Β· Photo upload(s)</div>
|
| 95 |
+
<div class="center node portal">
|
| 96 |
+
<div class="t">Citizen Submission Portal</div>
|
| 97 |
+
<div class="d">Web form Β· mobile app Β· 311 integration</div>
|
| 98 |
+
<span class="status">Submitted</span>
|
| 99 |
+
</div>
|
| 100 |
+
<div class="note right"><b>Auto-captured</b>GPS coordinates Β· Timestamp (UTC) Β· On-device AI size detection</div>
|
| 101 |
+
</div>
|
| 102 |
+
|
| 103 |
+
<div class="conn"></div>
|
| 104 |
+
|
| 105 |
+
<!-- PHASE 2 -->
|
| 106 |
+
<div class="phase">Phase 2 β Backend Enrichment (at submission)</div>
|
| 107 |
+
<div class="row">
|
| 108 |
+
<div class="note left"><b>Severity inputs</b>Damage size / depth Β· Road classification Β· Prior 311 notices Β· Weather forecast</div>
|
| 109 |
+
<div class="center node backend">
|
| 110 |
+
<div class="t">Backend Service β Enrich & Score</div>
|
| 111 |
+
<div class="d">GPS β ward / district Β· Severity score Β· Weather risk Β· builds the <b>complete JSON</b></div>
|
| 112 |
+
</div>
|
| 113 |
+
<div class="note right"><b>Why backend</b>Geometry, scoring & weather aren't AI tasks β computed here, then handed to Box ready-to-use</div>
|
| 114 |
+
</div>
|
| 115 |
+
|
| 116 |
+
<div class="conn"></div>
|
| 117 |
+
<div class="decision">Duplicate within 50β100 m radius?</div>
|
| 118 |
+
<div class="conn sm"></div>
|
| 119 |
+
|
| 120 |
+
<div class="branch">
|
| 121 |
+
<div>
|
| 122 |
+
<div class="lbl tag-yes">YES</div>
|
| 123 |
+
<div class="node backend">
|
| 124 |
+
<div class="t">Link to existing case</div>
|
| 125 |
+
<div class="d">Boost severity Β· no new work order</div>
|
| 126 |
+
</div>
|
| 127 |
+
</div>
|
| 128 |
+
<div>
|
| 129 |
+
<div class="lbl tag-no">NO</div>
|
| 130 |
+
<div class="node backend">
|
| 131 |
+
<div class="t">Create new case</div>
|
| 132 |
+
<div class="d">Fresh case ID</div>
|
| 133 |
+
</div>
|
| 134 |
+
</div>
|
| 135 |
+
</div>
|
| 136 |
+
|
| 137 |
+
<div class="conn"></div>
|
| 138 |
+
|
| 139 |
+
<!-- PHASE 3 -->
|
| 140 |
+
<div class="phase">Phase 3 β Box Storage & Routing</div>
|
| 141 |
+
<div class="row">
|
| 142 |
+
<div class="note left"><b>Lands in Box</b>Photo + complete JSON β Incoming Reports folder</div>
|
| 143 |
+
<div class="center node box">
|
| 144 |
+
<div class="t">Upload to Box + Apply Metadata</div>
|
| 145 |
+
<div class="d">Backend writes <b>potholeCase</b> metadata directly β works even if Box AI / Extract is off</div>
|
| 146 |
+
</div>
|
| 147 |
+
<div class="note right"><b>Box Extract</b>Confirms / maps JSON β metadata when enabled (optional, not required)</div>
|
| 148 |
+
</div>
|
| 149 |
+
|
| 150 |
+
<div class="conn"></div>
|
| 151 |
+
<div class="row">
|
| 152 |
+
<div class="center node box">
|
| 153 |
+
<div class="t">Case Folder Created + Metadata Applied</div>
|
| 154 |
+
<div class="d">Box Relay Β· Potholes / Ward-XYZ / Case-ABC123</div>
|
| 155 |
+
<span class="status">Under Review</span>
|
| 156 |
+
</div>
|
| 157 |
+
</div>
|
| 158 |
+
|
| 159 |
+
<div class="conn"></div>
|
| 160 |
+
|
| 161 |
+
<!-- PHASE 4 -->
|
| 162 |
+
<div class="phase">Phase 4 β Human Review & Dispatch</div>
|
| 163 |
+
<div class="row">
|
| 164 |
+
<div class="center node review">
|
| 165 |
+
<div class="t">Ward Engineer Notified (Box Relay)</div>
|
| 166 |
+
<div class="d">Folder link Β· photo Β· AI summary Β· nearby cases β approve / adjust severity / reject</div>
|
| 167 |
+
</div>
|
| 168 |
+
</div>
|
| 169 |
+
<div class="conn sm"></div>
|
| 170 |
+
<div class="row">
|
| 171 |
+
<div class="center node box">
|
| 172 |
+
<div class="t">Work Order Generated (Box Doc Gen)</div>
|
| 173 |
+
<div class="d">Job Sheet PDF from case metadata β no external system needed</div>
|
| 174 |
+
<span class="status">Assigned</span>
|
| 175 |
+
</div>
|
| 176 |
+
</div>
|
| 177 |
+
<div class="conn sm"></div>
|
| 178 |
+
<div class="row">
|
| 179 |
+
<div class="center node crew">
|
| 180 |
+
<div class="t">Crew Completes Box Form (mobile)</div>
|
| 181 |
+
<div class="d">Repair method Β· materials Β· labor hours Β· after-photo Β· <b>Box Sign</b></div>
|
| 182 |
+
<span class="status">In Progress</span>
|
| 183 |
+
</div>
|
| 184 |
+
</div>
|
| 185 |
+
|
| 186 |
+
<div class="conn"></div>
|
| 187 |
+
|
| 188 |
+
<!-- PHASE 5 -->
|
| 189 |
+
<div class="phase">Phase 5 β Closeout & Audit Record</div>
|
| 190 |
+
<div class="row">
|
| 191 |
+
<div class="note left"><b>Notifications</b>Supervisor + risk management Β· citizen portal updated to βResolvedβ</div>
|
| 192 |
+
<div class="center node audit">
|
| 193 |
+
<div class="t">Audit-Ready Case Record</div>
|
| 194 |
+
<div class="d">Box Governance β retention / legal hold Β· full version history Β· activity log Β· access control</div>
|
| 195 |
+
<span class="status">Resolved</span>
|
| 196 |
+
</div>
|
| 197 |
+
<div class="note right"><b>Citizen view</b>Status checked in the portal β no Box login required</div>
|
| 198 |
+
</div>
|
| 199 |
+
|
| 200 |
+
<div class="footer-note">
|
| 201 |
+
<b>Two external dependencies are removed:</b> work orders are generated in Box (Doc Gen), and enrichment runs in our backend β
|
| 202 |
+
so the workflow needs <b>no tunnel</b> and <b>no city-system API</b> to demo. The city work-order REST push and live 311 sync remain optional future integrations.
|
| 203 |
+
</div>
|
| 204 |
+
</div>
|
| 205 |
+
</body>
|
| 206 |
+
</html>
|
box-build/seed-demo-cases.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Seed demo pothole cases into Box for the PotholeIQ dashboard / Command Center.
|
| 3 |
+
* Runs the real backend pipeline (folders + files + potholeCase metadata on the case
|
| 4 |
+
* FOLDER only, incl. severity breakdown), then sets each case's lifecycle status.
|
| 5 |
+
*
|
| 6 |
+
* Usage: node box-build/seed-demo-cases.js <FRESH_DEV_TOKEN> [intakeFolderId]
|
| 7 |
+
*/
|
| 8 |
+
'use strict';
|
| 9 |
+
const fs = require('fs');
|
| 10 |
+
const path = require('path');
|
| 11 |
+
const bf = require(path.join(__dirname, '..', 'box-folders.js'));
|
| 12 |
+
|
| 13 |
+
const TOKEN = process.argv[2];
|
| 14 |
+
const INTAKE = process.argv[3] || '386202381022';
|
| 15 |
+
if (!TOKEN) { console.error('Pass a fresh Box dev token as arg 1.'); process.exit(1); }
|
| 16 |
+
|
| 17 |
+
const tmpl = { scope: 'enterprise', templateKey: 'potholeCase' };
|
| 18 |
+
const now = () => new Date().toISOString();
|
| 19 |
+
const daysAgo = (d) => new Date(Date.now() - d * 864e5).toISOString();
|
| 20 |
+
|
| 21 |
+
// ---- ward lookup from the bundled Philadelphia GeoJSON (point-in-polygon) ----
|
| 22 |
+
const geo = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'data', 'ward-boundaries.geojson'), 'utf8'));
|
| 23 |
+
function pip(pt, poly){let [x,y]=pt,inside=false,n=poly.length,j=n-1;for(let i=0;i<n;i++){const[xi,yi]=poly[i],[xj,yj]=poly[j];if(((yi>y)!=(yj>y))&&(x<(xj-xi)*(y-yi)/(yj-yi)+xi))inside=!inside;j=i;}return inside;}
|
| 24 |
+
function wardOf(lat,lng){for(const f of geo.features){const g=f.geometry,polys=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];for(const mp of polys){if(pip([lng,lat],mp[0]))return f.properties.ward_num||f.properties.ward||'?';}}return 'Unknown';}
|
| 25 |
+
|
| 26 |
+
// ---- factor detail text (mirrors enrichment.js) ----
|
| 27 |
+
const PN = s => s>=30?'3+ prior reports β high prior notice / strong legal weight':s>=22?'2 prior reports β notable prior notice':s>=12?'1 prior report found within 80 m':'No prior reports at this location';
|
| 28 |
+
const TR = s => s>=25?'Critical infrastructure / high-traffic route (school, hospital, arterial)':s>=18?'Major arterial or collector road β high traffic volume':s>=12?'Local collector street β moderate traffic':'Residential / low-traffic street';
|
| 29 |
+
const DM = s => s>=25?'Large β bowl-shaped, severe damage affecting ride and safety':s>=15?'Medium β noticeable damage, not yet vehicle-threatening':'Small β initial / emerging surface defect';
|
| 30 |
+
const WE = s => s>=10?'Freeze-thaw + heavy precipitation β extreme expansion risk':s>=8?'Freeze-thaw cycle predicted β high expansion risk':s>=7?'Heavy rain/snow forecast β accelerated deterioration':s>=4?'Moderate precipitation expected β moderate risk':'Dry / stable forecast β minimal weather risk';
|
| 31 |
+
const sizeFromDamage = s => s>=25?'Large':s>=15?'Medium':'Small';
|
| 32 |
+
const riskFromWeather = s => s>=7?'high':s>=4?'medium':'low';
|
| 33 |
+
const levelFromScore = s => s>=76?'Critical':s>=56?'High':s>=31?'Medium':'Low';
|
| 34 |
+
// prior-notice score derived from the duplicate count, so "N prior reports" always == duplicateCount
|
| 35 |
+
const pnFromDup = d => d>=3?30:d>=2?22:d>=1?12:0;
|
| 36 |
+
// real demo photos (so the dashboard shows actual images, not text)
|
| 37 |
+
const DEMO_IMAGES = fs.readdirSync(path.join(__dirname, '..', 'demo pothole images')).filter(f => /\.jpe?g$/i.test(f));
|
| 38 |
+
|
| 39 |
+
// [name, lat, lng, prior, traffic, damage, weather, status, crew, dupCount]
|
| 40 |
+
const RAW = [
|
| 41 |
+
['City Hall', 39.9526,-75.1652, 22,25,25,10, 'under review','', 2],
|
| 42 |
+
['Kensington Ave', 39.9905,-75.1190, 30,18,25, 8, 'assigned','Crew Alpha', 3],
|
| 43 |
+
['Broad & Erie', 40.0090,-75.1490, 22,25,25, 7, 'in progress','Crew Bravo', 0],
|
| 44 |
+
['Frankford Ave', 39.9690,-75.1300, 22,18,15, 7, 'under review','', 1],
|
| 45 |
+
['Germantown Ave', 40.0330,-75.1730, 22,18,15, 6, 'assigned','Crew Charlie', 0],
|
| 46 |
+
['Fairmount Ave', 39.9670,-75.1750, 12,25,15, 7, 'resolved','Crew Alpha', 0],
|
| 47 |
+
['Frankford NE', 40.0220,-75.0840, 14,18,15,10, 'under review','', 0],
|
| 48 |
+
['Lancaster Ave', 39.9612,-75.2090, 12,18, 6, 4, 'under review','', 0],
|
| 49 |
+
['Manayunk Main St', 40.0260,-75.2230, 0,18,15, 4, 'assigned','Crew Delta', 0],
|
| 50 |
+
['Point Breeze Ave', 39.9320,-75.1830, 12,12, 6, 7, 'resolved','Crew Bravo', 0],
|
| 51 |
+
['Mount Airy', 40.0560,-75.1880, 0,18,15, 0, 'in progress','Crew Charlie',0],
|
| 52 |
+
['Old City 2nd St', 39.9510,-75.1430, 12,12, 6, 4, 'under review','', 0],
|
| 53 |
+
['Roxborough Ridge', 40.0440,-75.2230, 0,12, 6, 4, 'resolved','Crew Delta', 0],
|
| 54 |
+
['Snyder Ave', 39.9210,-75.1700, 0, 6, 6, 4, 'rejected','', 0],
|
| 55 |
+
['Bustleton Ave', 40.0600,-75.0500, 0, 6, 6, 0, 'rejected','', 0],
|
| 56 |
+
];
|
| 57 |
+
|
| 58 |
+
const CASES = RAW.map(([name,lat,lng,pn0,tr,dm,we,status,crew,dup],i)=>{
|
| 59 |
+
const pn = pnFromDup(dup); // keep prior-notice consistent with duplicate count
|
| 60 |
+
const score = pn+tr+dm+we;
|
| 61 |
+
return {
|
| 62 |
+
suffix: String(i+1).padStart(2,'0'), name, lat, lng, status, crew,
|
| 63 |
+
ward: String(wardOf(lat,lng)),
|
| 64 |
+
address: `${name}, Philadelphia, PA`,
|
| 65 |
+
size: sizeFromDamage(dm), score, level: levelFromScore(score),
|
| 66 |
+
dupCount: dup, linked: dup>0 ? [`PHX-LEGACY-${100+i}`,`PHX-LEGACY-${200+i}`].slice(0,dup) : [],
|
| 67 |
+
submittedAt: daysAgo((i%10)+1),
|
| 68 |
+
bd: {
|
| 69 |
+
priorNotice:{score:pn,detail:PN(pn)}, traffic:{score:tr,detail:TR(tr)},
|
| 70 |
+
damage:{score:dm,detail:DM(dm)}, weather:{score:we,detail:WE(we),riskLevel:riskFromWeather(we)},
|
| 71 |
+
},
|
| 72 |
+
};
|
| 73 |
+
});
|
| 74 |
+
|
| 75 |
+
async function uploadFile(name, bytes){
|
| 76 |
+
const fd=new FormData();
|
| 77 |
+
fd.append('attributes',JSON.stringify({name,parent:{id:INTAKE}}));
|
| 78 |
+
fd.append('file',new Blob([bytes]),name);
|
| 79 |
+
const r=await fetch('https://upload.box.com/api/2.0/files/content',{method:'POST',headers:{Authorization:`Bearer ${TOKEN}`},body:fd}).then(x=>x.json());
|
| 80 |
+
return r.entries[0].id;
|
| 81 |
+
}
|
| 82 |
+
const md = (id) => fetch(`https://api.box.com/2.0/folders/${id}/metadata/enterprise/potholeCase`,{headers:{Authorization:`Bearer ${TOKEN}`}}).then(r=>r.json());
|
| 83 |
+
|
| 84 |
+
(async () => {
|
| 85 |
+
const me = await fetch('https://api.box.com/2.0/users/me?fields=name,login',{headers:{Authorization:`Bearer ${TOKEN}`}}).then(r=>r.json());
|
| 86 |
+
if(!me.login){console.error('Token invalid:',JSON.stringify(me));process.exit(1);}
|
| 87 |
+
console.log('Auth OK:', me.name);
|
| 88 |
+
|
| 89 |
+
let dist={Critical:0,High:0,Medium:0,Low:0};
|
| 90 |
+
for(const c of CASES){
|
| 91 |
+
const caseId=`PHX-DEMO-${c.suffix}`;
|
| 92 |
+
const report={caseId,submittedAt:c.submittedAt,address:c.address,location:{lat:c.lat,lng:c.lng,source:'gps'},aiResult:c.size,aiPrediction:c.size,aiConfidence:0.9,aiReviewStatus:'auto_confirmed'};
|
| 93 |
+
const enrichment={severityScore:c.score,severityLevel:c.level,enrichedAt:c.submittedAt,
|
| 94 |
+
district:{district:`District ${c.ward}`,ward:c.ward,maintenanceZone:`Zone-${c.ward}`},
|
| 95 |
+
duplicate:{isDuplicate:c.dupCount>0,duplicateCount:c.dupCount,linkedCaseIds:c.linked,effectiveSeverityBoost:c.dupCount*4},
|
| 96 |
+
severityBreakdown:{priorNotice:{...c.bd.priorNotice,max:30},traffic:{...c.bd.traffic,max:25},damage:{...c.bd.damage,max:25},weather:{...c.bd.weather,max:10}},
|
| 97 |
+
report};
|
| 98 |
+
const imgBytes=fs.readFileSync(path.join(__dirname,'..','demo pothole images',DEMO_IMAGES[(parseInt(c.suffix,10)-1)%DEMO_IMAGES.length]));
|
| 99 |
+
const photoFileId=await uploadFile(`${caseId}_photo.jpg`,imgBytes);
|
| 100 |
+
const sidecarFileId=await uploadFile(`${caseId}_metadata.json`,JSON.stringify(report,null,2));
|
| 101 |
+
const res=await bf.organizeInBox({caseId,ward:c.ward,district:`District ${c.ward}`,photoFileId,sidecarFileId,enrichment,reportPayload:report,rootFolderId:INTAKE,metadataTemplate:tmpl,token:TOKEN,onStep:()=>{}});
|
| 102 |
+
// set lifecycle status (organizeInBox defaults to 'under review')
|
| 103 |
+
if(c.status!=='under review'){
|
| 104 |
+
const patch={status:c.status};
|
| 105 |
+
if(c.crew)patch.assignedCrew=c.crew;
|
| 106 |
+
if(c.status==='resolved'){patch.resolvedAt=now();patch.supervisorWardEngineer='Eng. R. Diaz';}
|
| 107 |
+
await bf.applyFolderMetadata(res.caseFolderId,patch,TOKEN,{template:tmpl});
|
| 108 |
+
} else if(c.crew){
|
| 109 |
+
await bf.applyFolderMetadata(res.caseFolderId,{assignedCrew:c.crew},TOKEN,{template:tmpl});
|
| 110 |
+
}
|
| 111 |
+
dist[c.level]++;
|
| 112 |
+
console.log(` ${caseId} Ward ${c.ward.padStart(2)} ${c.level.padEnd(8)} ${String(c.score).padStart(3)} ${c.status}`);
|
| 113 |
+
}
|
| 114 |
+
console.log('\nSeeded',CASES.length,'cases | severity:',JSON.stringify(dist));
|
| 115 |
+
console.log('Open: http://localhost:3030/command-center.html');
|
| 116 |
+
})().catch(e=>{console.error('ERROR',e);process.exit(1);});
|
box-build/wf1-revised-diagram.html
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>Workflow One β Revised (Intake β Dispatch)</title>
|
| 7 |
+
<style>
|
| 8 |
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
| 9 |
+
body { font-family: 'Segoe UI', Inter, system-ui, Arial, sans-serif; background: #f4f6fb; color: #1f2733; padding: 36px 16px 64px; line-height: 1.4; }
|
| 10 |
+
.wrap { max-width: 900px; margin: 0 auto; }
|
| 11 |
+
h1 { font-size: 24px; font-weight: 800; text-align: center; color: #0b1f3a; }
|
| 12 |
+
.subtitle { text-align: center; color: #5b6679; font-size: 13.5px; margin-top: 6px; }
|
| 13 |
+
.legend { display: flex; gap: 16px; justify-content: center; margin: 16px 0 26px; flex-wrap: wrap; font-size: 12.5px; color: #44506a; }
|
| 14 |
+
.legend span { display: inline-flex; align-items: center; gap: 7px; }
|
| 15 |
+
.dot { width: 12px; height: 12px; border-radius: 3px; display: inline-block; }
|
| 16 |
+
.node { border-radius: 12px; padding: 12px 16px; text-align: center; border: 1px solid; box-shadow: 0 2px 6px rgba(15,30,60,.06); max-width: 420px; margin: 0 auto; }
|
| 17 |
+
.node .t { font-weight: 700; font-size: 14.5px; }
|
| 18 |
+
.node .d { font-size: 12px; margin-top: 3px; opacity: .92; }
|
| 19 |
+
.trigger { background:#e9f2fd; border-color:#a9cdf6; color:#0a4a9c; }
|
| 20 |
+
.ai { background:#f1ecfe; border-color:#c3b1f5; color:#4c2aa3; }
|
| 21 |
+
.action { background:#e7f7ef; border-color:#9ae0c0; color:#0c6b46; }
|
| 22 |
+
.doc { background:#fff4e6; border-color:#f4c68a; color:#9a5b06; }
|
| 23 |
+
.notify { background:#fdeaf5; border-color:#f3b6da; color:#a01f72; }
|
| 24 |
+
.end { background:#eef2f8; border-color:#ccd6e6; color:#3b475e; font-size:12.5px; }
|
| 25 |
+
.removed { background:#fdeaea; border-color:#f4b4b4; color:#ad2222; text-decoration: line-through; opacity:.8; }
|
| 26 |
+
.decision { background:#fff6e3; border:1px solid #f1c878; color:#92600c; border-radius:12px; padding:11px 16px; text-align:center; font-weight:700; font-size:14px; max-width:420px; margin:0 auto; box-shadow:0 2px 6px rgba(15,30,60,.06); }
|
| 27 |
+
.conn { width:2px; height:22px; background:#b9c3d4; margin:5px auto; }
|
| 28 |
+
.conn.sm { height:14px; }
|
| 29 |
+
.branch { display:grid; grid-template-columns:1fr 1fr; gap:18px; max-width:820px; margin:0 auto; }
|
| 30 |
+
.lbl { text-align:center; font-size:11px; font-weight:700; color:#7a8499; margin-bottom:6px; letter-spacing:.05em; text-transform:uppercase; }
|
| 31 |
+
.col { display:flex; flex-direction:column; gap:0; }
|
| 32 |
+
.note { background:#fff; border:1px dashed #b9c3d4; border-radius:9px; padding:8px 12px; font-size:11.5px; color:#44506a; max-width:520px; margin:6px auto 0; text-align:center; }
|
| 33 |
+
.phase { text-align:center; font-size:11px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:#7a8499; margin:22px auto 12px; border-top:1px dashed #c4ccda; padding-top:10px; max-width:600px; }
|
| 34 |
+
.star { color:#9a5b06; font-weight:800; }
|
| 35 |
+
</style>
|
| 36 |
+
</head>
|
| 37 |
+
<body>
|
| 38 |
+
<div class="wrap">
|
| 39 |
+
<h1>Workflow One β Revised</h1>
|
| 40 |
+
<div class="subtitle">Backend owns folders + metadata Β· WF1 = human workflow only Β· <span class="star">+ Work Order added</span></div>
|
| 41 |
+
|
| 42 |
+
<div class="legend">
|
| 43 |
+
<span><i class="dot" style="background:#0a4a9c"></i> Trigger</span>
|
| 44 |
+
<span><i class="dot" style="background:#4c2aa3"></i> Box AI</span>
|
| 45 |
+
<span><i class="dot" style="background:#0c6b46"></i> Metadata / Action</span>
|
| 46 |
+
<span><i class="dot" style="background:#a01f72"></i> Notification</span>
|
| 47 |
+
<span><i class="dot" style="background:#9a5b06"></i> Doc Gen</span>
|
| 48 |
+
<span><i class="dot" style="background:#ad2222"></i> Remove</span>
|
| 49 |
+
</div>
|
| 50 |
+
|
| 51 |
+
<!-- TRIGGER -->
|
| 52 |
+
<div class="node trigger"><div class="t">β‘ Trigger β Metadata Event</div><div class="d">potholeCase Β· <b>status = under review</b> (fires after the backend applies metadata β loop-safe, no race)</div></div>
|
| 53 |
+
<div class="note">β CHANGED from "File Uploaded β Incoming Detections" (that raced the backend & fired before metadata existed)</div>
|
| 54 |
+
<div class="conn"></div>
|
| 55 |
+
|
| 56 |
+
<!-- REMOVED Extract -->
|
| 57 |
+
<div class="node removed"><div class="t">Default Standard Extract Agent</div><div class="d">DELETE β backend already wrote the metadata; this node was halting the flow</div></div>
|
| 58 |
+
<div class="conn"></div>
|
| 59 |
+
|
| 60 |
+
<!-- Box Agent -->
|
| 61 |
+
<div class="node ai"><div class="t">π€ Box Agent β AI Summary</div><div class="d">2β3 sentence case summary β write to <b>aiSummary</b></div></div>
|
| 62 |
+
<div class="conn"></div>
|
| 63 |
+
|
| 64 |
+
<!-- Decision: duplicate -->
|
| 65 |
+
<div class="decision">Conditional Split β duplicateStatus = "duplicate "?</div>
|
| 66 |
+
<div class="conn sm"></div>
|
| 67 |
+
|
| 68 |
+
<div class="branch">
|
| 69 |
+
<div class="col">
|
| 70 |
+
<div class="lbl">Branch 1 β DUPLICATE</div>
|
| 71 |
+
<div class="node removed"><div class="t">Move β duplicates/</div><div class="d">DELETE β backend already placed the file</div></div>
|
| 72 |
+
<div class="conn sm"></div>
|
| 73 |
+
<div class="node notify"><div class="t">π Send Notification</div><div class="d">Alert ward engineer: linked to existing cluster ({linkedCaseIds})</div></div>
|
| 74 |
+
<div class="conn sm"></div>
|
| 75 |
+
<div class="node end">β End β no work order (it's a duplicate)</div>
|
| 76 |
+
</div>
|
| 77 |
+
|
| 78 |
+
<div class="col">
|
| 79 |
+
<div class="lbl">Else β NEW CASE</div>
|
| 80 |
+
<div class="node removed"><div class="t">Create "Case-{caseId}" folder</div><div class="d">DELETE β backend creates Potholes/{ward}/Case-{id}</div></div>
|
| 81 |
+
<div class="conn sm"></div>
|
| 82 |
+
<div class="node notify"><div class="t">π Send Notification β ward engineer</div><div class="d">New case {caseId} Β· {severityLevel} Β· {aiSummary} + folder link</div></div>
|
| 83 |
+
<div class="conn sm"></div>
|
| 84 |
+
<div class="node action"><div class="t">β
Approval Task</div><div class="d">"Approve, adjust severity, or reject" β ward engineer</div></div>
|
| 85 |
+
</div>
|
| 86 |
+
</div>
|
| 87 |
+
|
| 88 |
+
<div class="phase">New case Β· on task result</div>
|
| 89 |
+
<div class="decision">Conditional Split β Task Approved?</div>
|
| 90 |
+
<div class="conn sm"></div>
|
| 91 |
+
|
| 92 |
+
<div class="branch">
|
| 93 |
+
<div class="col">
|
| 94 |
+
<div class="lbl">Approved</div>
|
| 95 |
+
<div class="node action"><div class="t">π·οΈ Add Metadata</div><div class="d">status = <b>assigned</b></div></div>
|
| 96 |
+
<div class="conn sm"></div>
|
| 97 |
+
<div class="node doc"><div class="t">π Generate Document β WORK ORDER β
</div><div class="d">Template: <b>Pothole Repair Work Order (DocGen)</b> Β· save PDF to case folder<br>Tags from metadata by <b>caseId</b>: caseId Β· severityLevel Β· address Β· ward Β· assignedCrew</div></div>
|
| 98 |
+
<div class="conn sm"></div>
|
| 99 |
+
<div class="node action"><div class="t">π·οΈ Add Metadata</div><div class="d">workOrderId = {generated doc name / id}</div></div>
|
| 100 |
+
<div class="conn sm"></div>
|
| 101 |
+
<div class="node end">β End β case Assigned, work order filed</div>
|
| 102 |
+
</div>
|
| 103 |
+
<div class="col">
|
| 104 |
+
<div class="lbl">Rejected</div>
|
| 105 |
+
<div class="node action"><div class="t">π·οΈ Add Metadata</div><div class="d">status = <b>rejected</b></div></div>
|
| 106 |
+
<div class="conn sm"></div>
|
| 107 |
+
<div class="node notify"><div class="t">π Send Notification</div><div class="d">"Case {caseId} ({severityLevel}) was rejected"</div></div>
|
| 108 |
+
<div class="conn sm"></div>
|
| 109 |
+
<div class="node end">β End β case Rejected</div>
|
| 110 |
+
</div>
|
| 111 |
+
</div>
|
| 112 |
+
|
| 113 |
+
<div class="note" style="margin-top:24px;border-color:#9a5b06;color:#7a4a05;">
|
| 114 |
+
<b>β
The work order you flagged:</b> add a <b>Generate Document</b> node in the Approved branch.
|
| 115 |
+
Crew may not be assigned yet at approval, so <b>assignedCrew</b> can be blank here and filled at the deploy-crew step.
|
| 116 |
+
</div>
|
| 117 |
+
</div>
|
| 118 |
+
</body>
|
| 119 |
+
</html>
|
box-folders.js
ADDED
|
@@ -0,0 +1,656 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* box-folders.js
|
| 3 |
+
* Box folder hierarchy creation, file moving, and metadata writing
|
| 4 |
+
* for the pothole operations enrichment pipeline.
|
| 5 |
+
*
|
| 6 |
+
* Creates structure:
|
| 7 |
+
* [Root Incoming Folder]
|
| 8 |
+
* \- Potholes/
|
| 9 |
+
* \- Ward-29/
|
| 10 |
+
* \- Case-PHX-1234/
|
| 11 |
+
* |- PHX-1234_photo.jpg
|
| 12 |
+
* \- PHX-1234_metadata.json
|
| 13 |
+
*
|
| 14 |
+
* Uses Box generic "properties" metadata for compatibility and can also write
|
| 15 |
+
* to a named Box metadata template when one is configured.
|
| 16 |
+
*/
|
| 17 |
+
|
| 18 |
+
'use strict';
|
| 19 |
+
|
| 20 |
+
const BOX_API = 'https://api.box.com/2.0';
|
| 21 |
+
const BOX_UPLOAD = 'https://upload.box.com/api/2.0';
|
| 22 |
+
const boxWorkflowModule = (typeof module !== 'undefined' && module.exports)
|
| 23 |
+
? require('./box-workflow.js')
|
| 24 |
+
: null;
|
| 25 |
+
|
| 26 |
+
async function boxRequest(method, path, token, body = null) {
|
| 27 |
+
const opts = {
|
| 28 |
+
method,
|
| 29 |
+
headers: {
|
| 30 |
+
Authorization: `Bearer ${token}`,
|
| 31 |
+
...(body ? { 'Content-Type': 'application/json' } : {}),
|
| 32 |
+
},
|
| 33 |
+
...(body ? { body: JSON.stringify(body) } : {}),
|
| 34 |
+
};
|
| 35 |
+
const res = await fetch(`${BOX_API}${path}`, opts);
|
| 36 |
+
const data = await res.json().catch(() => ({}));
|
| 37 |
+
return { ok: res.ok, status: res.status, data };
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
function normalizeTemplateConfig(config = {}) {
|
| 41 |
+
return {
|
| 42 |
+
scope: String(config?.scope || '').trim(),
|
| 43 |
+
templateKey: String(config?.templateKey || '').trim(),
|
| 44 |
+
};
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
function shouldApplyCustomTemplate(config = {}) {
|
| 48 |
+
const normalized = normalizeTemplateConfig(config);
|
| 49 |
+
if (!normalized.scope || !normalized.templateKey) return false;
|
| 50 |
+
return !(normalized.scope === 'global' && normalized.templateKey === 'properties');
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
// --- Structured metadata template projection -------------------------------
|
| 54 |
+
// A Box metadata template REJECTS unknown keys, wrong value types (float/date),
|
| 55 |
+
// and enum values that don't exactly match a stored option key. The backend's
|
| 56 |
+
// rich review object can't be sent as-is, so we project it onto the live schema:
|
| 57 |
+
// pick the right source value per field, coerce its type, and snap enum values
|
| 58 |
+
// to the exact option key the template stores (case/whitespace insensitive).
|
| 59 |
+
|
| 60 |
+
const __templateSchemaCache = new Map();
|
| 61 |
+
|
| 62 |
+
async function getTemplateSchema(scope, templateKey, token) {
|
| 63 |
+
const cacheKey = `${scope}/${templateKey}`;
|
| 64 |
+
if (__templateSchemaCache.has(cacheKey)) return __templateSchemaCache.get(cacheKey);
|
| 65 |
+
const { ok, data } = await boxRequest('GET', `/metadata_templates/${scope}/${templateKey}/schema`, token);
|
| 66 |
+
const schema = ok && Array.isArray(data.fields) ? data : null;
|
| 67 |
+
__templateSchemaCache.set(cacheKey, schema);
|
| 68 |
+
return schema;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
// template field key -> ordered source keys in the review kvMap (first non-empty wins)
|
| 72 |
+
const TEMPLATE_KEY_ALIASES = {
|
| 73 |
+
severityScore0100: ['severityScore0100', 'severityScore'],
|
| 74 |
+
weatherRisk: ['weatherRisk', 'weatherStatus', 'weatherRiskLevel'],
|
| 75 |
+
supervisorWardEngineer: ['supervisorWardEngineer', 'supervisor'],
|
| 76 |
+
caseFolderId: ['caseFolderId', 'boxCaseFolderId', 'boxFolderId'],
|
| 77 |
+
linkedCaseIds: ['linkedCaseIds', 'linkedCases'],
|
| 78 |
+
status: ['status', 'workflowStatus'],
|
| 79 |
+
};
|
| 80 |
+
|
| 81 |
+
// Per-field input-value remaps applied BEFORE matching against the schema options.
|
| 82 |
+
// Needed where the source vocabulary differs from the template's option set.
|
| 83 |
+
const ENUM_VALUE_ALIASES = {
|
| 84 |
+
weatherRisk: { high: 'large', severe: 'large' }, // template has no "High" option (Low/Medium/Large)
|
| 85 |
+
};
|
| 86 |
+
|
| 87 |
+
function pickSourceValue(kvMap, templateKey) {
|
| 88 |
+
const aliases = TEMPLATE_KEY_ALIASES[templateKey] || [templateKey];
|
| 89 |
+
for (const alias of aliases) {
|
| 90 |
+
const v = kvMap[alias];
|
| 91 |
+
if (v !== undefined && v !== null && String(v).trim() !== '') return v;
|
| 92 |
+
}
|
| 93 |
+
return undefined;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
function matchEnumOption(value, options = []) {
|
| 97 |
+
const norm = (s) => String(s).trim().toLowerCase();
|
| 98 |
+
const target = norm(value);
|
| 99 |
+
const opt = options.find((o) => norm(o.key) === target);
|
| 100 |
+
return opt ? opt.key : null; // return EXACT stored key (preserves casing/trailing spaces)
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
function coerceForField(field, rawValue) {
|
| 104 |
+
if (rawValue === undefined || rawValue === null) return undefined;
|
| 105 |
+
const sval = String(rawValue).trim();
|
| 106 |
+
if (sval === '') return undefined;
|
| 107 |
+
switch (field.type) {
|
| 108 |
+
case 'float': {
|
| 109 |
+
const n = Number(sval);
|
| 110 |
+
return Number.isFinite(n) ? n : undefined;
|
| 111 |
+
}
|
| 112 |
+
case 'date': {
|
| 113 |
+
const d = new Date(sval);
|
| 114 |
+
if (Number.isNaN(d.getTime())) return undefined;
|
| 115 |
+
return d.toISOString().replace(/\.\d{3}Z$/, 'Z'); // RFC3339, no milliseconds
|
| 116 |
+
}
|
| 117 |
+
case 'enum':
|
| 118 |
+
case 'multiSelect': {
|
| 119 |
+
const valAlias = (ENUM_VALUE_ALIASES[field.key] || {})[sval.toLowerCase()];
|
| 120 |
+
return matchEnumOption(valAlias || sval, field.options) || undefined;
|
| 121 |
+
}
|
| 122 |
+
default:
|
| 123 |
+
return sval;
|
| 124 |
+
}
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
function projectToTemplateSchema(kvMap, schema) {
|
| 128 |
+
const out = {};
|
| 129 |
+
for (const field of schema.fields || []) {
|
| 130 |
+
const coerced = coerceForField(field, pickSourceValue(kvMap, field.key));
|
| 131 |
+
if (coerced !== undefined) out[field.key] = coerced;
|
| 132 |
+
}
|
| 133 |
+
return out;
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
async function getOrCreateFolder(parentId, name, token) {
|
| 137 |
+
console.log(`[BoxFolders] getOrCreateFolder("${name}") in parent ${parentId}`);
|
| 138 |
+
const { ok, status, data } = await boxRequest('POST', '/folders', token, {
|
| 139 |
+
name,
|
| 140 |
+
parent: { id: parentId },
|
| 141 |
+
});
|
| 142 |
+
|
| 143 |
+
if (ok) {
|
| 144 |
+
console.log(`[BoxFolders] Folder created: "${name}" -> id=${data.id}`);
|
| 145 |
+
return data.id;
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
if (status === 409) {
|
| 149 |
+
const existingId = data.context_info?.conflicts?.[0]?.id || null;
|
| 150 |
+
if (existingId) {
|
| 151 |
+
console.log(`[BoxFolders] Folder already exists: "${name}" -> id=${existingId}`);
|
| 152 |
+
return existingId;
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
const items = await boxRequest('GET', `/folders/${parentId}/items?limit=200`, token);
|
| 156 |
+
const match = (items.data?.entries || []).find((entry) => entry.type === 'folder' && entry.name === name);
|
| 157 |
+
if (match) {
|
| 158 |
+
console.log(`[BoxFolders] Folder found via listing: "${name}" -> id=${match.id}`);
|
| 159 |
+
return match.id;
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
throw new Error(`Could not resolve existing folder "${name}" in parent ${parentId}`);
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
throw new Error(`Create folder "${name}" failed (${status}): ${data.message || JSON.stringify(data)}`);
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
async function moveFile(fileId, destFolderId, token) {
|
| 169 |
+
console.log(`[BoxFolders] moveFile fileId=${fileId} -> folder ${destFolderId}`);
|
| 170 |
+
const { ok, status, data } = await boxRequest('PUT', `/files/${fileId}`, token, {
|
| 171 |
+
parent: { id: destFolderId },
|
| 172 |
+
});
|
| 173 |
+
if (!ok) throw new Error(`Move file ${fileId} failed (${status}): ${data.message || ''}`);
|
| 174 |
+
console.log(
|
| 175 |
+
`[BoxFolders] File moved. New path: ${(data.path_collection?.entries || []).map((entry) => entry.name).join('/')}`
|
| 176 |
+
);
|
| 177 |
+
return data;
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
async function uploadGeneratedFile(caseFolderId, fileName, contents, mimeType, token) {
|
| 181 |
+
const blob = new Blob([contents], { type: mimeType });
|
| 182 |
+
const attributes = JSON.stringify({ name: fileName, parent: { id: caseFolderId } });
|
| 183 |
+
|
| 184 |
+
const form = new FormData();
|
| 185 |
+
form.append('attributes', attributes);
|
| 186 |
+
form.append('file', blob, fileName);
|
| 187 |
+
|
| 188 |
+
const res = await fetch(`${BOX_UPLOAD}/files/content`, {
|
| 189 |
+
method: 'POST',
|
| 190 |
+
headers: { Authorization: `Bearer ${token}` },
|
| 191 |
+
body: form,
|
| 192 |
+
});
|
| 193 |
+
|
| 194 |
+
if (!res.ok) {
|
| 195 |
+
const err = await res.json().catch(() => ({}));
|
| 196 |
+
throw new Error(`Generated file upload failed (${res.status}): ${err.message || ''}`);
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
const data = await res.json();
|
| 200 |
+
console.log(`[BoxFolders] Generated file uploaded: ${fileName} -> id=${data.entries?.[0]?.id}`);
|
| 201 |
+
return data.entries?.[0]?.id || null;
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
async function uploadEnrichedSidecar(caseFolderId, caseId, enrichedPayload, token) {
|
| 205 |
+
const fileName = `${caseId}_enriched_metadata.json`;
|
| 206 |
+
const contents = JSON.stringify(enrichedPayload, null, 2);
|
| 207 |
+
return uploadGeneratedFile(caseFolderId, fileName, contents, 'application/json', token);
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
function buildReviewSummary(caseId, reportPayload, enrichment, reviewMetadata, folderPath) {
|
| 211 |
+
const address = reportPayload?.address || 'Address not available';
|
| 212 |
+
const district = reviewMetadata.district || 'Unknown';
|
| 213 |
+
const ward = reviewMetadata.ward || 'Unknown';
|
| 214 |
+
const zone = reviewMetadata.maintenanceZone || 'Unknown';
|
| 215 |
+
const severity = `${reviewMetadata.severityLevel || 'Unknown'} (${reviewMetadata.severityScore || 'n/a'}/100)`;
|
| 216 |
+
const road = reviewMetadata.trafficDetail || 'Road classification unavailable';
|
| 217 |
+
const weather = reviewMetadata.weatherDetail || 'Weather risk unavailable';
|
| 218 |
+
const weatherStatus = reviewMetadata.weatherStatus || reviewMetadata.weatherRiskLevel || 'Unknown';
|
| 219 |
+
const potholeSize = reviewMetadata.potholeSize || reviewMetadata.aiResult || reviewMetadata.aiPrediction || 'Unknown';
|
| 220 |
+
const aiConfidence = reviewMetadata.aiConfidence !== ''
|
| 221 |
+
? `${reviewMetadata.aiConfidence}%`
|
| 222 |
+
: 'Unavailable';
|
| 223 |
+
const visualVerification = reviewMetadata.needsVisualVerification === 'true'
|
| 224 |
+
? 'Yes - AI was not confident this is a pothole. Send image for Box-side visual verification.'
|
| 225 |
+
: 'No - base model confidence was sufficient.';
|
| 226 |
+
const duplicate = reviewMetadata.isDuplicate === 'true'
|
| 227 |
+
? `Yes (${reviewMetadata.duplicateCount || 0} related case(s), linked: ${reviewMetadata.linkedCases || 'none'})`
|
| 228 |
+
: 'No';
|
| 229 |
+
const priorNotice = reviewMetadata.priorNoticeDetail || 'No prior notice detail';
|
| 230 |
+
const damage = reviewMetadata.damageDetail || 'Damage detail unavailable';
|
| 231 |
+
const reporterNote = reportPayload?.description || reportPayload?.notes || 'No additional citizen notes.';
|
| 232 |
+
|
| 233 |
+
return [
|
| 234 |
+
`POTHOLE REVIEW SUMMARY`,
|
| 235 |
+
``,
|
| 236 |
+
`Case ID: ${caseId}`,
|
| 237 |
+
`Address: ${address}`,
|
| 238 |
+
`Ward: ${ward}`,
|
| 239 |
+
`District: ${district}`,
|
| 240 |
+
`Maintenance Zone: ${zone}`,
|
| 241 |
+
`Box Path: ${folderPath}`,
|
| 242 |
+
``,
|
| 243 |
+
`SUPERVISOR REVIEW DATA`,
|
| 244 |
+
`Severity: ${severity}`,
|
| 245 |
+
`Pothole Size: ${potholeSize}`,
|
| 246 |
+
`Road Context: ${road}`,
|
| 247 |
+
`Road Classification: ${reviewMetadata.roadClassification || 'unknown'}`,
|
| 248 |
+
`Weather Risk: ${weather}`,
|
| 249 |
+
`Weather Status: ${weatherStatus}`,
|
| 250 |
+
`AI Confidence: ${aiConfidence}`,
|
| 251 |
+
`Needs Visual Verification: ${visualVerification}`,
|
| 252 |
+
`Duplicate Cluster: ${duplicate}`,
|
| 253 |
+
`Prior Notice: ${priorNotice}`,
|
| 254 |
+
`Damage Assessment: ${damage}`,
|
| 255 |
+
``,
|
| 256 |
+
`REPORTER NOTES`,
|
| 257 |
+
`${reporterNote}`,
|
| 258 |
+
``,
|
| 259 |
+
`REVIEW INSTRUCTION`,
|
| 260 |
+
`Check the photo, summary, severity, duplicate context, ward/district/zone, and decide the next operational step.`,
|
| 261 |
+
``,
|
| 262 |
+
`Generated: ${reviewMetadata.enrichedAt || new Date().toISOString()}`,
|
| 263 |
+
].join('\n');
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
async function uploadReviewSummary(caseFolderId, caseId, reviewSummary, token, options = {}) {
|
| 267 |
+
const fileName = options.needsVisualVerification
|
| 268 |
+
? `${caseId}_review_summary_uncertain.txt`
|
| 269 |
+
: `${caseId}_review_summary.txt`;
|
| 270 |
+
return uploadGeneratedFile(caseFolderId, fileName, reviewSummary, 'text/plain', token);
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
function buildFolderWorkflowMetadata(reportPayload, enrichment, context) {
|
| 274 |
+
if (typeof window !== 'undefined' && window.BoxWorkflow?.buildEnrichedWorkflow) {
|
| 275 |
+
return window.BoxWorkflow.buildEnrichedWorkflow(reportPayload, enrichment, context);
|
| 276 |
+
}
|
| 277 |
+
if (boxWorkflowModule?.buildEnrichedWorkflow) {
|
| 278 |
+
return boxWorkflowModule.buildEnrichedWorkflow(reportPayload, enrichment, context);
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
return {
|
| 282 |
+
template: 'pothole_box_ops_v1',
|
| 283 |
+
currentStage: 'supervisor_review',
|
| 284 |
+
currentStageLabel: 'Supervisor Review',
|
| 285 |
+
status: 'Under Review',
|
| 286 |
+
queue: 'Supervisor Review',
|
| 287 |
+
nextAction: 'Supervisor approve the case and send it to crew routing',
|
| 288 |
+
priority: 'normal',
|
| 289 |
+
boxPath: context.folderPath || '',
|
| 290 |
+
lastUpdatedAt: enrichment?.enrichedAt || new Date().toISOString(),
|
| 291 |
+
};
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
function flattenFolderWorkflow(workflow) {
|
| 295 |
+
if (typeof window !== 'undefined' && window.BoxWorkflow?.flattenWorkflowForBox) {
|
| 296 |
+
return window.BoxWorkflow.flattenWorkflowForBox(workflow);
|
| 297 |
+
}
|
| 298 |
+
if (boxWorkflowModule?.flattenWorkflowForBox) {
|
| 299 |
+
return boxWorkflowModule.flattenWorkflowForBox(workflow);
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
return {
|
| 303 |
+
workflowTemplate: workflow.template || 'pothole_box_ops_v1',
|
| 304 |
+
workflowStage: workflow.currentStage || '',
|
| 305 |
+
workflowStageLabel: workflow.currentStageLabel || '',
|
| 306 |
+
workflowStatus: workflow.status || '',
|
| 307 |
+
workflowQueue: workflow.queue || '',
|
| 308 |
+
workflowNextAction: workflow.nextAction || '',
|
| 309 |
+
workflowPriority: workflow.priority || '',
|
| 310 |
+
workflowBoxPath: workflow.boxPath || '',
|
| 311 |
+
workflowLastUpdatedAt: workflow.lastUpdatedAt || '',
|
| 312 |
+
};
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
async function writeMetadataDocument(itemType, itemId, kvMap, token, config = {}, options = {}) {
|
| 316 |
+
const { stringifyValues = true } = options;
|
| 317 |
+
const normalized = normalizeTemplateConfig(config);
|
| 318 |
+
const scope = normalized.scope || 'global';
|
| 319 |
+
const templateKey = normalized.templateKey || 'properties';
|
| 320 |
+
const path = `/${itemType}/${itemId}/metadata/${scope}/${templateKey}`;
|
| 321 |
+
const payload = stringifyValues
|
| 322 |
+
? Object.fromEntries(Object.entries(kvMap).map(([key, value]) => [key, String(value ?? '')]))
|
| 323 |
+
: { ...kvMap };
|
| 324 |
+
if (!Object.keys(payload).length) {
|
| 325 |
+
console.log(`[BoxFolders] No template-valid fields to write for ${itemType.slice(0, -1)} ${itemId} (${scope}/${templateKey})`);
|
| 326 |
+
return;
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
+
const { ok, status } = await boxRequest('POST', path, token, payload);
|
| 330 |
+
if (ok) {
|
| 331 |
+
console.log(`[BoxFolders] Metadata applied to ${itemType.slice(0, -1)} ${itemId} (${scope}/${templateKey})`);
|
| 332 |
+
return;
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
if (status === 409) {
|
| 336 |
+
const ops = Object.entries(payload).map(([key, value]) => ({ op: 'add', path: `/${key}`, value }));
|
| 337 |
+
const patchRes = await fetch(`${BOX_API}${path}`, {
|
| 338 |
+
method: 'PUT',
|
| 339 |
+
headers: {
|
| 340 |
+
Authorization: `Bearer ${token}`,
|
| 341 |
+
'Content-Type': 'application/json-patch+json',
|
| 342 |
+
},
|
| 343 |
+
body: JSON.stringify(ops),
|
| 344 |
+
});
|
| 345 |
+
if (!patchRes.ok) {
|
| 346 |
+
const patchErr = await patchRes.json().catch(() => ({}));
|
| 347 |
+
throw new Error(`Metadata patch failed (${patchRes.status}): ${patchErr.message || ''}`.trim());
|
| 348 |
+
}
|
| 349 |
+
console.log(`[BoxFolders] Metadata patched (${patchRes.status}) for ${itemType.slice(0, -1)} ${itemId} (${scope}/${templateKey})`);
|
| 350 |
+
return;
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
throw new Error(`Metadata write failed (${status}) for ${itemType.slice(0, -1)} ${itemId} (${scope}/${templateKey})`);
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
async function applyMetadata(itemType, itemId, kvMap, token, options = {}) {
|
| 357 |
+
// Generic freeform properties: accepts any key, values as strings.
|
| 358 |
+
await writeMetadataDocument(itemType, itemId, kvMap, token, { scope: 'global', templateKey: 'properties' });
|
| 359 |
+
|
| 360 |
+
if (shouldApplyCustomTemplate(options.template)) {
|
| 361 |
+
const { scope, templateKey } = normalizeTemplateConfig(options.template);
|
| 362 |
+
const schema = await getTemplateSchema(scope, templateKey, token);
|
| 363 |
+
if (!schema) {
|
| 364 |
+
console.warn(`[BoxFolders] Schema unavailable for ${scope}/${templateKey}; skipping structured template.`);
|
| 365 |
+
return;
|
| 366 |
+
}
|
| 367 |
+
const projected = projectToTemplateSchema(kvMap, schema);
|
| 368 |
+
await writeMetadataDocument(itemType, itemId, projected, token, options.template, { stringifyValues: false });
|
| 369 |
+
}
|
| 370 |
+
}
|
| 371 |
+
|
| 372 |
+
async function applyFolderMetadata(folderId, kvMap, token, options = {}) {
|
| 373 |
+
return applyMetadata('folders', folderId, kvMap, token, options);
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
async function applyFileMetadata(fileId, kvMap, token, options = {}) {
|
| 377 |
+
return applyMetadata('files', fileId, kvMap, token, options);
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
+
function classifyRoadContext(detail = '') {
|
| 381 |
+
const normalized = String(detail || '').toLowerCase();
|
| 382 |
+
if (normalized.includes('critical infrastructure') || normalized.includes('high-traffic')) return 'critical';
|
| 383 |
+
if (normalized.includes('major arterial') || normalized.includes('high traffic')) return 'high';
|
| 384 |
+
if (normalized.includes('residential') || normalized.includes('low-traffic')) return 'low';
|
| 385 |
+
if (normalized.includes('moderate')) return 'medium';
|
| 386 |
+
return 'unknown';
|
| 387 |
+
}
|
| 388 |
+
|
| 389 |
+
function isUncertainPotholeCase(reportPayload = {}) {
|
| 390 |
+
const reviewStatus = String(reportPayload?.aiReviewStatus || '').toLowerCase().trim();
|
| 391 |
+
const aiResult = String(reportPayload?.aiResult || '').trim();
|
| 392 |
+
return reviewStatus === 'manual_review' && !aiResult;
|
| 393 |
+
}
|
| 394 |
+
|
| 395 |
+
function buildReviewMetadata(caseId, ward, district, folderPath, enrichment, workflowFields) {
|
| 396 |
+
const breakdown = enrichment?.severityBreakdown || {};
|
| 397 |
+
const priorNotice = breakdown.priorNotice || {};
|
| 398 |
+
const traffic = breakdown.traffic || {};
|
| 399 |
+
const damage = breakdown.damage || {};
|
| 400 |
+
const weather = breakdown.weather || {};
|
| 401 |
+
const duplicate = enrichment?.duplicate || {};
|
| 402 |
+
const districtData = enrichment?.district || {};
|
| 403 |
+
const reportPayload = enrichment?.report || {};
|
| 404 |
+
const aiReviewStatus = String(reportPayload.aiReviewStatus || '').trim();
|
| 405 |
+
const aiResult = String(reportPayload.aiResult || '').trim();
|
| 406 |
+
const aiPrediction = String(reportPayload.aiPrediction || '').trim();
|
| 407 |
+
const aiConfidenceRaw = reportPayload.aiConfidence;
|
| 408 |
+
const aiConfidence = Number.isFinite(Number(aiConfidenceRaw))
|
| 409 |
+
? Math.round(Number(aiConfidenceRaw) * 100)
|
| 410 |
+
: '';
|
| 411 |
+
const needsVisualVerification = isUncertainPotholeCase(reportPayload);
|
| 412 |
+
const duplicateStatus = duplicate.isDuplicate ? 'duplicate' : 'new';
|
| 413 |
+
const weatherStatus = weather.riskLevel || (weather.detail ? 'available' : 'pending');
|
| 414 |
+
const potholeSize = aiResult || aiPrediction || String(reportPayload.potholeSize || '').trim();
|
| 415 |
+
|
| 416 |
+
return {
|
| 417 |
+
caseId,
|
| 418 |
+
submittedAt: reportPayload.submittedAt || '',
|
| 419 |
+
address: reportPayload.address || '',
|
| 420 |
+
areaLabel: reportPayload.areaLabel || '',
|
| 421 |
+
latitude: reportPayload.location?.lat ?? '',
|
| 422 |
+
longitude: reportPayload.location?.lng ?? '',
|
| 423 |
+
locationSource: reportPayload.location?.source || reportPayload.locationSource || '',
|
| 424 |
+
district: districtData.district || district || 'Unknown',
|
| 425 |
+
ward: districtData.ward || ward || 'Unknown',
|
| 426 |
+
maintenanceZone: districtData.maintenanceZone || 'Zone-X',
|
| 427 |
+
duplicateStatus,
|
| 428 |
+
severityScore: enrichment?.severityScore ?? '',
|
| 429 |
+
severityLevel: enrichment?.severityLevel || '',
|
| 430 |
+
priorNoticeScore: priorNotice.score ?? '',
|
| 431 |
+
priorNoticeDetail: priorNotice.detail || '',
|
| 432 |
+
trafficScore: traffic.score ?? '',
|
| 433 |
+
trafficDetail: traffic.detail || '',
|
| 434 |
+
roadClassification: classifyRoadContext(traffic.detail),
|
| 435 |
+
damageScore: damage.score ?? '',
|
| 436 |
+
damageDetail: damage.detail || '',
|
| 437 |
+
weatherScore: weather.score ?? '',
|
| 438 |
+
weatherDetail: weather.detail || '',
|
| 439 |
+
weatherRiskLevel: weather.riskLevel || '',
|
| 440 |
+
weatherStatus,
|
| 441 |
+
potholeSize,
|
| 442 |
+
aiResult,
|
| 443 |
+
aiPrediction,
|
| 444 |
+
aiReviewStatus,
|
| 445 |
+
aiReviewMessage: reportPayload.aiReviewMessage || '',
|
| 446 |
+
aiConfidence,
|
| 447 |
+
needsVisualVerification: needsVisualVerification ? 'true' : 'false',
|
| 448 |
+
duplicateCount: duplicate.duplicateCount ?? '',
|
| 449 |
+
duplicateBoost: duplicate.effectiveSeverityBoost ?? '',
|
| 450 |
+
isDuplicate: duplicate.isDuplicate ? 'true' : 'false',
|
| 451 |
+
linkedCases: (duplicate.linkedCaseIds || []).join(', '),
|
| 452 |
+
boxPath: folderPath,
|
| 453 |
+
enrichedAt: enrichment?.enrichedAt || '',
|
| 454 |
+
enrichedBy: 'ops-console-v2',
|
| 455 |
+
workflowStage: 'supervisor_review',
|
| 456 |
+
workflowStatus: 'Under Review',
|
| 457 |
+
workflowNextAction: 'Supervisor review pending',
|
| 458 |
+
workflowPriority: enrichment?.severityLevel === 'Critical' ? 'urgent'
|
| 459 |
+
: enrichment?.severityLevel === 'High' ? 'high'
|
| 460 |
+
: enrichment?.severityLevel === 'Medium' ? 'medium'
|
| 461 |
+
: 'normal',
|
| 462 |
+
...workflowFields,
|
| 463 |
+
};
|
| 464 |
+
}
|
| 465 |
+
|
| 466 |
+
/**
|
| 467 |
+
* @param {object} opts
|
| 468 |
+
* @param {string} opts.caseId
|
| 469 |
+
* @param {string} opts.ward
|
| 470 |
+
* @param {string} opts.district
|
| 471 |
+
* @param {string} [opts.photoFileId]
|
| 472 |
+
* @param {string} [opts.sidecarFileId]
|
| 473 |
+
* @param {object} opts.enrichment
|
| 474 |
+
* @param {object} opts.reportPayload
|
| 475 |
+
* @param {string} opts.rootFolderId
|
| 476 |
+
* @param {string} opts.token
|
| 477 |
+
* @param {(label: string, status: string) => void} [opts.onStep]
|
| 478 |
+
* @returns {Promise<{ folderPath: string, caseFolderId: string, potholesId: string, wardId: string, enrichedFileId: string|null, reviewSummaryFileId: string|null }>}
|
| 479 |
+
*/
|
| 480 |
+
async function organizeInBox(opts = {}) {
|
| 481 |
+
if (typeof window !== 'undefined') {
|
| 482 |
+
const response = await fetch('/api/box/organize-case', {
|
| 483 |
+
method: 'POST',
|
| 484 |
+
headers: {
|
| 485 |
+
'Content-Type': 'application/json',
|
| 486 |
+
},
|
| 487 |
+
body: JSON.stringify(opts),
|
| 488 |
+
});
|
| 489 |
+
|
| 490 |
+
const payload = await response.json().catch(() => null);
|
| 491 |
+
if (!response.ok) {
|
| 492 |
+
throw new Error(payload?.error || `Box organize failed with HTTP ${response.status}`);
|
| 493 |
+
}
|
| 494 |
+
|
| 495 |
+
return payload?.result || null;
|
| 496 |
+
}
|
| 497 |
+
|
| 498 |
+
const {
|
| 499 |
+
caseId,
|
| 500 |
+
ward,
|
| 501 |
+
district,
|
| 502 |
+
photoFileId,
|
| 503 |
+
sidecarFileId,
|
| 504 |
+
enrichment,
|
| 505 |
+
reportPayload,
|
| 506 |
+
rootFolderId,
|
| 507 |
+
metadataTemplate = {},
|
| 508 |
+
token,
|
| 509 |
+
onStep = () => {},
|
| 510 |
+
} = opts;
|
| 511 |
+
const step = (label, status) => onStep(label, status);
|
| 512 |
+
const metadataOptions = { template: normalizeTemplateConfig(metadataTemplate) };
|
| 513 |
+
|
| 514 |
+
step('Creating Potholes/ root folder...', 'running');
|
| 515 |
+
const potholesId = await getOrCreateFolder(rootFolderId, 'Potholes', token);
|
| 516 |
+
step(`Potholes/ folder ready (id: ${potholesId})`, 'done');
|
| 517 |
+
|
| 518 |
+
const wardSlug = (ward || 'Ward-Unknown').replace(/\s+/g, '-');
|
| 519 |
+
step(`Creating ${wardSlug}/ subfolder...`, 'running');
|
| 520 |
+
const wardId = await getOrCreateFolder(potholesId, wardSlug, token);
|
| 521 |
+
step(`${wardSlug}/ ready (id: ${wardId})`, 'done');
|
| 522 |
+
|
| 523 |
+
const caseFolderName = `Case-${caseId}`;
|
| 524 |
+
step(`Creating case folder ${caseFolderName}/...`, 'running');
|
| 525 |
+
const caseFolderId = await getOrCreateFolder(wardId, caseFolderName, token);
|
| 526 |
+
step(`Case folder created (id: ${caseFolderId})`, 'done');
|
| 527 |
+
|
| 528 |
+
const folderPath = `Potholes/${wardSlug}/${caseFolderName}`;
|
| 529 |
+
|
| 530 |
+
if (photoFileId) {
|
| 531 |
+
step('Moving photo into case folder...', 'running');
|
| 532 |
+
await moveFile(photoFileId, caseFolderId, token).catch((error) => {
|
| 533 |
+
step(`Photo move warning: ${error.message}`, 'warn');
|
| 534 |
+
});
|
| 535 |
+
step('Photo moved OK', 'done');
|
| 536 |
+
}
|
| 537 |
+
|
| 538 |
+
if (sidecarFileId) {
|
| 539 |
+
step('Moving original metadata JSON...', 'running');
|
| 540 |
+
await moveFile(sidecarFileId, caseFolderId, token).catch((error) => {
|
| 541 |
+
step(`Sidecar move warning: ${error.message}`, 'warn');
|
| 542 |
+
});
|
| 543 |
+
step('Metadata JSON moved OK', 'done');
|
| 544 |
+
}
|
| 545 |
+
|
| 546 |
+
const workflow = buildFolderWorkflowMetadata(reportPayload, enrichment, { folderPath, rootFolderId });
|
| 547 |
+
const workflowFields = flattenFolderWorkflow(workflow);
|
| 548 |
+
const reviewMetadata = buildReviewMetadata(caseId, ward, district, folderPath, enrichment, workflowFields);
|
| 549 |
+
const reviewSummary = buildReviewSummary(caseId, reportPayload, enrichment, reviewMetadata, folderPath);
|
| 550 |
+
const needsVisualVerification = isUncertainPotholeCase(reportPayload);
|
| 551 |
+
|
| 552 |
+
step('Uploading enriched metadata JSON...', 'running');
|
| 553 |
+
const enrichedPayload = {
|
| 554 |
+
caseId,
|
| 555 |
+
submittedAt: reportPayload.submittedAt || '',
|
| 556 |
+
enrichedAt: enrichment.enrichedAt,
|
| 557 |
+
address: reportPayload.address || '',
|
| 558 |
+
areaLabel: reportPayload.areaLabel || '',
|
| 559 |
+
location: reportPayload.location || null,
|
| 560 |
+
locationSource: reportPayload.location?.source || reportPayload.locationSource || '',
|
| 561 |
+
severityScore: enrichment.severityScore,
|
| 562 |
+
severityLevel: enrichment.severityLevel,
|
| 563 |
+
district: enrichment.district?.district || district,
|
| 564 |
+
ward: enrichment.district?.ward || ward,
|
| 565 |
+
maintenanceZone: enrichment.district?.maintenanceZone || 'Zone-X',
|
| 566 |
+
duplicateStatus: enrichment.duplicate?.isDuplicate ? 'duplicate' : 'new',
|
| 567 |
+
isDuplicate: enrichment.duplicate?.isDuplicate,
|
| 568 |
+
duplicateCount: enrichment.duplicate?.duplicateCount ?? 0,
|
| 569 |
+
duplicateBoost: enrichment.duplicate?.effectiveSeverityBoost ?? 0,
|
| 570 |
+
linkedCases: enrichment.duplicate?.linkedCaseIds?.join(', ') || '',
|
| 571 |
+
weatherStatus: enrichment.severityBreakdown?.weather?.riskLevel || 'pending',
|
| 572 |
+
potholeSize: reportPayload.aiResult || reportPayload.aiPrediction || '',
|
| 573 |
+
aiResult: reportPayload.aiResult || '',
|
| 574 |
+
aiPrediction: reportPayload.aiPrediction || '',
|
| 575 |
+
aiReviewStatus: reportPayload.aiReviewStatus || '',
|
| 576 |
+
aiReviewMessage: reportPayload.aiReviewMessage || '',
|
| 577 |
+
priorNoticeScore: enrichment.severityBreakdown?.priorNotice?.score ?? '',
|
| 578 |
+
priorNoticeDetail: enrichment.severityBreakdown?.priorNotice?.detail || '',
|
| 579 |
+
trafficScore: enrichment.severityBreakdown?.traffic?.score ?? '',
|
| 580 |
+
trafficDetail: enrichment.severityBreakdown?.traffic?.detail || '',
|
| 581 |
+
roadClassification: classifyRoadContext(enrichment.severityBreakdown?.traffic?.detail),
|
| 582 |
+
damageScore: enrichment.severityBreakdown?.damage?.score ?? '',
|
| 583 |
+
damageDetail: enrichment.severityBreakdown?.damage?.detail || '',
|
| 584 |
+
weatherScore: enrichment.severityBreakdown?.weather?.score ?? '',
|
| 585 |
+
weatherDetail: enrichment.severityBreakdown?.weather?.detail || '',
|
| 586 |
+
weatherRiskLevel: enrichment.severityBreakdown?.weather?.riskLevel || '',
|
| 587 |
+
weatherRisk: enrichment.weather
|
| 588 |
+
? `${enrichment.severityBreakdown?.weather?.riskLevel || 'unknown'} - ${enrichment.severityBreakdown?.weather?.detail}`
|
| 589 |
+
: 'Unavailable',
|
| 590 |
+
boxPath: folderPath,
|
| 591 |
+
workflow,
|
| 592 |
+
...workflowFields,
|
| 593 |
+
severityBreakdown: enrichment.severityBreakdown,
|
| 594 |
+
report: reportPayload,
|
| 595 |
+
};
|
| 596 |
+
const enrichedFileId = await uploadEnrichedSidecar(caseFolderId, caseId, enrichedPayload, token).catch((error) => {
|
| 597 |
+
step(`Enriched sidecar warning: ${error.message}`, 'warn');
|
| 598 |
+
return null;
|
| 599 |
+
});
|
| 600 |
+
step('Enriched metadata JSON uploaded OK', 'done');
|
| 601 |
+
|
| 602 |
+
step('Uploading human-readable review summary...', 'running');
|
| 603 |
+
const reviewSummaryFileId = await uploadReviewSummary(caseFolderId, caseId, reviewSummary, token, {
|
| 604 |
+
needsVisualVerification,
|
| 605 |
+
}).catch((error) => {
|
| 606 |
+
step(`Review summary warning: ${error.message}`, 'warn');
|
| 607 |
+
return null;
|
| 608 |
+
});
|
| 609 |
+
step('Review summary uploaded OK', 'done');
|
| 610 |
+
|
| 611 |
+
step('Applying Box folder metadata template...', 'running');
|
| 612 |
+
await applyFolderMetadata(caseFolderId, reviewMetadata, token, metadataOptions).catch((error) => {
|
| 613 |
+
step(`Metadata warning: ${error.message}`, 'warn');
|
| 614 |
+
});
|
| 615 |
+
step('Box metadata template applied OK', 'done');
|
| 616 |
+
|
| 617 |
+
// NOTE: only the case FOLDER carries the structured potholeCase template (it's the
|
| 618 |
+
// canonical case record). Helper files get freeform properties only ({} = no custom
|
| 619 |
+
// template) so the App's metadata view counts 1 record per case, not 3.
|
| 620 |
+
if (enrichedFileId) {
|
| 621 |
+
step('Applying Box metadata to enriched review file...', 'running');
|
| 622 |
+
await applyFileMetadata(enrichedFileId, reviewMetadata, token, {}).catch((error) => {
|
| 623 |
+
step(`Review file metadata warning: ${error.message}`, 'warn');
|
| 624 |
+
});
|
| 625 |
+
step('Review file metadata applied OK', 'done');
|
| 626 |
+
}
|
| 627 |
+
|
| 628 |
+
if (reviewSummaryFileId) {
|
| 629 |
+
step('Applying Box metadata to review summary file...', 'running');
|
| 630 |
+
await applyFileMetadata(reviewSummaryFileId, reviewMetadata, token, {}).catch((error) => {
|
| 631 |
+
step(`Review summary metadata warning: ${error.message}`, 'warn');
|
| 632 |
+
});
|
| 633 |
+
step('Review summary metadata applied OK', 'done');
|
| 634 |
+
}
|
| 635 |
+
|
| 636 |
+
console.log(`[BoxFolders] organizeInBox complete -> ${folderPath}`);
|
| 637 |
+
return { folderPath, caseFolderId, wardId, potholesId, enrichedFileId, reviewSummaryFileId };
|
| 638 |
+
}
|
| 639 |
+
|
| 640 |
+
if (typeof window !== 'undefined') {
|
| 641 |
+
window.BoxFolders = { organizeInBox, getOrCreateFolder, moveFile, applyFolderMetadata };
|
| 642 |
+
}
|
| 643 |
+
|
| 644 |
+
if (typeof module !== 'undefined' && module.exports) {
|
| 645 |
+
module.exports = {
|
| 646 |
+
organizeInBox,
|
| 647 |
+
getOrCreateFolder,
|
| 648 |
+
moveFile,
|
| 649 |
+
applyFolderMetadata,
|
| 650 |
+
applyFileMetadata,
|
| 651 |
+
getTemplateSchema,
|
| 652 |
+
projectToTemplateSchema,
|
| 653 |
+
coerceForField,
|
| 654 |
+
matchEnumOption,
|
| 655 |
+
};
|
| 656 |
+
}
|
box-service.js
ADDED
|
@@ -0,0 +1,1318 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use strict';
|
| 2 |
+
|
| 3 |
+
const {
|
| 4 |
+
buildInitialWorkflow,
|
| 5 |
+
flattenWorkflowForBox,
|
| 6 |
+
} = require('./box-workflow.js');
|
| 7 |
+
const {
|
| 8 |
+
organizeInBox,
|
| 9 |
+
applyFolderMetadata,
|
| 10 |
+
getOrCreateFolder,
|
| 11 |
+
} = require('./box-folders.js');
|
| 12 |
+
|
| 13 |
+
const BOX_API_BASE = 'https://api.box.com/2.0';
|
| 14 |
+
const BOX_UPLOAD_URL = 'https://upload.box.com/api/2.0/files/content';
|
| 15 |
+
const BOX_OAUTH_TOKEN_URL = 'https://api.box.com/oauth2/token';
|
| 16 |
+
const DEFAULT_GIS_BOUNDARY_URL = 'https://opendata.arcgis.com/datasets/philly-political-wards.geojson';
|
| 17 |
+
const TOKEN_REFRESH_SKEW_MS = 2 * 60 * 1000;
|
| 18 |
+
|
| 19 |
+
function nowIso() {
|
| 20 |
+
return new Date().toISOString();
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
function normalizeString(value) {
|
| 24 |
+
return String(value ?? '').trim();
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
// The citizen contact field is free-text ("email or phone" and may include a name),
|
| 28 |
+
// so pull the email out of whatever string (or JSON blob) we're handed. Phone-only
|
| 29 |
+
// values yield '' and never trigger a notify.
|
| 30 |
+
function extractEmail(value) {
|
| 31 |
+
const match = String(value || '').match(/[^\s@<>"'(),;:]+@[^\s@<>"'(),;:]+\.[^\s@<>"'(),;:]+/);
|
| 32 |
+
return match ? match[0].replace(/[.,;:>)\]]+$/, '') : '';
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
// Friendly, citizen-facing copy for each status β the body of the Box status note.
|
| 36 |
+
const CITIZEN_STATUS_COPY = {
|
| 37 |
+
'under review': 'Your report is being reviewed by our team. We will update you once a decision is made.',
|
| 38 |
+
submitted: 'Thanks β your report has been received and logged. We will review it shortly.',
|
| 39 |
+
assigned: 'Good news! Your report has been approved and a repair crew has been assigned. A work order has been created.',
|
| 40 |
+
'in progress': 'A crew is now actively working on the repair at the reported location.',
|
| 41 |
+
resolved: 'The pothole has been repaired and your case is now closed. Thank you for helping keep our roads safe.',
|
| 42 |
+
rejected: 'After review, this report was closed without a repair being dispatched. See the notes in your case for details.',
|
| 43 |
+
};
|
| 44 |
+
|
| 45 |
+
function buildCitizenStatusNote(caseId, statusLabel, caseRecord = null) {
|
| 46 |
+
const report = caseRecord?.report || {};
|
| 47 |
+
const key = String(statusLabel || '').trim().toLowerCase();
|
| 48 |
+
const message = CITIZEN_STATUS_COPY[key] || `Your report status has been updated to "${statusLabel}".`;
|
| 49 |
+
const lines = [
|
| 50 |
+
`Pothole Report β Status Update`,
|
| 51 |
+
``,
|
| 52 |
+
`Case Reference: ${caseId}`,
|
| 53 |
+
`Current Status: ${statusLabel}`,
|
| 54 |
+
report.address ? `Location: ${report.address}` : '',
|
| 55 |
+
`Updated: ${new Date().toLocaleString()}`,
|
| 56 |
+
``,
|
| 57 |
+
message,
|
| 58 |
+
``,
|
| 59 |
+
`You can view live progress any time on the Track Your Report page using your Case Reference number.`,
|
| 60 |
+
];
|
| 61 |
+
return lines.filter((line) => line !== '').join('\n');
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
function delay(ms) {
|
| 65 |
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
function parseRetryAfter(headers) {
|
| 69 |
+
const raw = headers?.get?.('retry-after');
|
| 70 |
+
if (!raw) return null;
|
| 71 |
+
|
| 72 |
+
const seconds = Number(raw);
|
| 73 |
+
if (Number.isFinite(seconds) && seconds >= 0) {
|
| 74 |
+
return seconds * 1000;
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
const when = Date.parse(raw);
|
| 78 |
+
if (Number.isFinite(when)) {
|
| 79 |
+
return Math.max(0, when - Date.now());
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
return null;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
async function readErrorMessage(response) {
|
| 86 |
+
const contentType = response.headers.get('content-type') || '';
|
| 87 |
+
try {
|
| 88 |
+
if (contentType.includes('application/json')) {
|
| 89 |
+
const payload = await response.json();
|
| 90 |
+
return payload?.message || payload?.error || JSON.stringify(payload);
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
const text = await response.text();
|
| 94 |
+
return text || `HTTP ${response.status}`;
|
| 95 |
+
} catch {
|
| 96 |
+
return `HTTP ${response.status}`;
|
| 97 |
+
}
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
async function fetchWithRetry(url, options = {}, retryOptions = {}) {
|
| 101 |
+
const {
|
| 102 |
+
retries = 3,
|
| 103 |
+
baseDelayMs = 600,
|
| 104 |
+
maxDelayMs = 5000,
|
| 105 |
+
label = 'request',
|
| 106 |
+
retryStatuses = [408, 429, 500, 502, 503, 504],
|
| 107 |
+
} = retryOptions;
|
| 108 |
+
|
| 109 |
+
let lastError = null;
|
| 110 |
+
|
| 111 |
+
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
| 112 |
+
try {
|
| 113 |
+
const response = await fetch(url, options);
|
| 114 |
+
if (!retryStatuses.includes(response.status) || attempt === retries) {
|
| 115 |
+
return response;
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
const retryDelay = parseRetryAfter(response.headers)
|
| 119 |
+
?? Math.min(maxDelayMs, baseDelayMs * (2 ** attempt));
|
| 120 |
+
console.warn(`[retry] ${label} returned ${response.status}; retrying in ${retryDelay}ms`);
|
| 121 |
+
await delay(retryDelay);
|
| 122 |
+
} catch (error) {
|
| 123 |
+
lastError = error;
|
| 124 |
+
if (attempt === retries) throw error;
|
| 125 |
+
|
| 126 |
+
const retryDelay = Math.min(maxDelayMs, baseDelayMs * (2 ** attempt));
|
| 127 |
+
console.warn(`[retry] ${label} failed (${error.message}); retrying in ${retryDelay}ms`);
|
| 128 |
+
await delay(retryDelay);
|
| 129 |
+
}
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
throw lastError || new Error(`${label} failed.`);
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
function stripPhotoDataUrl(value) {
|
| 136 |
+
if (Array.isArray(value)) {
|
| 137 |
+
return value.map((item) => stripPhotoDataUrl(item));
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
if (!value || typeof value !== 'object') {
|
| 141 |
+
return value;
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
const clone = {};
|
| 145 |
+
for (const [key, child] of Object.entries(value)) {
|
| 146 |
+
if (key === 'photoDataUrl') continue;
|
| 147 |
+
clone[key] = stripPhotoDataUrl(child);
|
| 148 |
+
}
|
| 149 |
+
return clone;
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
function isStoredTokenFresh(boxConfig) {
|
| 153 |
+
const accessToken = normalizeString(boxConfig.accessToken);
|
| 154 |
+
if (!accessToken) return false;
|
| 155 |
+
|
| 156 |
+
const expiresAt = normalizeString(boxConfig.accessTokenExpiresAt);
|
| 157 |
+
if (!expiresAt) return true;
|
| 158 |
+
|
| 159 |
+
const expiryTime = Date.parse(expiresAt);
|
| 160 |
+
if (!Number.isFinite(expiryTime)) return true;
|
| 161 |
+
return expiryTime - TOKEN_REFRESH_SKEW_MS > Date.now();
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
function hasBoxAuth(boxConfig = {}) {
|
| 165 |
+
const authMode = normalizeString(boxConfig.authMode || 'oauth_refresh').toLowerCase();
|
| 166 |
+
if (authMode === 'access_token') {
|
| 167 |
+
return !!normalizeString(boxConfig.accessToken);
|
| 168 |
+
}
|
| 169 |
+
if (authMode === 'client_credentials') {
|
| 170 |
+
return !!(
|
| 171 |
+
normalizeString(boxConfig.clientId) &&
|
| 172 |
+
normalizeString(boxConfig.clientSecret) &&
|
| 173 |
+
normalizeString(boxConfig.subjectType) &&
|
| 174 |
+
normalizeString(boxConfig.subjectId)
|
| 175 |
+
);
|
| 176 |
+
}
|
| 177 |
+
return !!(
|
| 178 |
+
normalizeString(boxConfig.accessToken) ||
|
| 179 |
+
(
|
| 180 |
+
normalizeString(boxConfig.refreshToken) &&
|
| 181 |
+
normalizeString(boxConfig.clientId) &&
|
| 182 |
+
normalizeString(boxConfig.clientSecret)
|
| 183 |
+
)
|
| 184 |
+
);
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
function computeBoxStatus(boxConfig = {}) {
|
| 188 |
+
const intakeFolderId = normalizeString(boxConfig.intakeFolderId);
|
| 189 |
+
const caseRootFolderId = normalizeString(boxConfig.caseRootFolderId || intakeFolderId);
|
| 190 |
+
const metadataScope = normalizeString(boxConfig.metadataScope);
|
| 191 |
+
const metadataTemplateKey = normalizeString(boxConfig.metadataTemplateKey);
|
| 192 |
+
return {
|
| 193 |
+
authMode: normalizeString(boxConfig.authMode || 'oauth_refresh').toLowerCase(),
|
| 194 |
+
configured: hasBoxAuth(boxConfig) && !!intakeFolderId,
|
| 195 |
+
hasAuth: hasBoxAuth(boxConfig),
|
| 196 |
+
intakeFolderId,
|
| 197 |
+
caseRootFolderId,
|
| 198 |
+
metadataScope,
|
| 199 |
+
metadataTemplateKey,
|
| 200 |
+
hasCustomMetadataTemplate: !!(metadataScope && metadataTemplateKey),
|
| 201 |
+
hasIntakeFolderId: !!intakeFolderId,
|
| 202 |
+
hasCaseRootFolderId: !!caseRootFolderId,
|
| 203 |
+
usingTemporaryAccessToken: normalizeString(boxConfig.authMode).toLowerCase() === 'access_token',
|
| 204 |
+
};
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
function buildBoxConfigError(boxConfig = {}) {
|
| 208 |
+
const authMode = normalizeString(boxConfig.authMode || 'oauth_refresh').toLowerCase();
|
| 209 |
+
if (authMode === 'client_credentials') {
|
| 210 |
+
return 'Box client credentials are incomplete. Save client ID, client secret, subject type, subject ID, and the intake folder in settings.html.';
|
| 211 |
+
}
|
| 212 |
+
if (authMode === 'access_token') {
|
| 213 |
+
return 'Box access token is missing. Open settings.html and save the Box integration first.';
|
| 214 |
+
}
|
| 215 |
+
return 'Box OAuth refresh credentials are incomplete. Save the client ID, client secret, refresh token, and intake folder in settings.html.';
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
function createBoxService({ configStore }) {
|
| 219 |
+
let tokenRefreshPromise = null;
|
| 220 |
+
|
| 221 |
+
async function readBoxConfig() {
|
| 222 |
+
const config = await configStore.readConfig();
|
| 223 |
+
const fileBox = config.box || {};
|
| 224 |
+
// Env-var overrides so the Box CCG connection survives on hosts that wipe disk
|
| 225 |
+
// (HuggingFace Spaces, Render, Railway, etc.). Set these as the host's secrets.
|
| 226 |
+
const E = process.env;
|
| 227 |
+
const env = {};
|
| 228 |
+
if (E.BOX_AUTH_MODE) env.authMode = E.BOX_AUTH_MODE;
|
| 229 |
+
if (E.BOX_CLIENT_ID) env.clientId = E.BOX_CLIENT_ID;
|
| 230 |
+
if (E.BOX_CLIENT_SECRET) env.clientSecret = E.BOX_CLIENT_SECRET;
|
| 231 |
+
if (E.BOX_SUBJECT_TYPE) env.subjectType = E.BOX_SUBJECT_TYPE;
|
| 232 |
+
if (E.BOX_SUBJECT_ID) env.subjectId = E.BOX_SUBJECT_ID;
|
| 233 |
+
if (E.BOX_INTAKE_FOLDER_ID) env.intakeFolderId = E.BOX_INTAKE_FOLDER_ID;
|
| 234 |
+
if (E.BOX_CASE_ROOT_FOLDER_ID) env.caseRootFolderId = E.BOX_CASE_ROOT_FOLDER_ID;
|
| 235 |
+
if (E.BOX_METADATA_SCOPE) env.metadataScope = E.BOX_METADATA_SCOPE;
|
| 236 |
+
if (E.BOX_METADATA_TEMPLATE_KEY) env.metadataTemplateKey = E.BOX_METADATA_TEMPLATE_KEY;
|
| 237 |
+
return { ...fileBox, ...env };
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
async function getClientConfig() {
|
| 241 |
+
const boxConfig = await readBoxConfig();
|
| 242 |
+
return configStore.sanitizeBoxConfigForClient(boxConfig);
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
async function getStatus() {
|
| 246 |
+
return computeBoxStatus(await readBoxConfig());
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
async function saveConfig(patch = {}, options = {}) {
|
| 250 |
+
const updated = await configStore.queueMutation((config) => ({
|
| 251 |
+
...config,
|
| 252 |
+
box: configStore.mergeBoxConfig(config.box, patch, options),
|
| 253 |
+
}));
|
| 254 |
+
|
| 255 |
+
return {
|
| 256 |
+
config: configStore.sanitizeBoxConfigForClient(updated.box),
|
| 257 |
+
status: computeBoxStatus(updated.box),
|
| 258 |
+
};
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
async function resetConfig() {
|
| 262 |
+
return saveConfig({}, { reset: true });
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
async function persistTokens(tokenPatch = {}) {
|
| 266 |
+
const updated = await configStore.queueMutation((config) => ({
|
| 267 |
+
...config,
|
| 268 |
+
box: configStore.mergeBoxConfig(config.box, tokenPatch),
|
| 269 |
+
}));
|
| 270 |
+
return updated.box;
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
async function requestBoxTokenViaRefresh(boxConfig, { persist = true } = {}) {
|
| 274 |
+
const refreshToken = normalizeString(boxConfig.refreshToken);
|
| 275 |
+
const clientId = normalizeString(boxConfig.clientId);
|
| 276 |
+
const clientSecret = normalizeString(boxConfig.clientSecret);
|
| 277 |
+
if (!refreshToken || !clientId || !clientSecret) {
|
| 278 |
+
throw new Error(buildBoxConfigError(boxConfig));
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
const body = new URLSearchParams({
|
| 282 |
+
grant_type: 'refresh_token',
|
| 283 |
+
refresh_token: refreshToken,
|
| 284 |
+
client_id: clientId,
|
| 285 |
+
client_secret: clientSecret,
|
| 286 |
+
});
|
| 287 |
+
|
| 288 |
+
const response = await fetchWithRetry(BOX_OAUTH_TOKEN_URL, {
|
| 289 |
+
method: 'POST',
|
| 290 |
+
headers: {
|
| 291 |
+
'Content-Type': 'application/x-www-form-urlencoded',
|
| 292 |
+
},
|
| 293 |
+
body,
|
| 294 |
+
}, { label: 'Box OAuth refresh token request' });
|
| 295 |
+
|
| 296 |
+
if (!response.ok) {
|
| 297 |
+
throw new Error(`Box token refresh failed (${response.status}): ${await readErrorMessage(response)}`);
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
const payload = await response.json();
|
| 301 |
+
const expiresAt = payload.expires_in
|
| 302 |
+
? new Date(Date.now() + (Number(payload.expires_in) * 1000)).toISOString()
|
| 303 |
+
: '';
|
| 304 |
+
|
| 305 |
+
if (persist) {
|
| 306 |
+
await persistTokens({
|
| 307 |
+
accessToken: payload.access_token || '',
|
| 308 |
+
accessTokenExpiresAt: expiresAt,
|
| 309 |
+
refreshToken: payload.refresh_token || refreshToken,
|
| 310 |
+
});
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
return normalizeString(payload.access_token);
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
async function requestBoxTokenViaClientCredentials(boxConfig, { persist = true } = {}) {
|
| 317 |
+
const clientId = normalizeString(boxConfig.clientId);
|
| 318 |
+
const clientSecret = normalizeString(boxConfig.clientSecret);
|
| 319 |
+
const subjectType = normalizeString(boxConfig.subjectType);
|
| 320 |
+
const subjectId = normalizeString(boxConfig.subjectId);
|
| 321 |
+
if (!clientId || !clientSecret || !subjectType || !subjectId) {
|
| 322 |
+
throw new Error(buildBoxConfigError(boxConfig));
|
| 323 |
+
}
|
| 324 |
+
|
| 325 |
+
const body = new URLSearchParams({
|
| 326 |
+
grant_type: 'client_credentials',
|
| 327 |
+
client_id: clientId,
|
| 328 |
+
client_secret: clientSecret,
|
| 329 |
+
box_subject_type: subjectType,
|
| 330 |
+
box_subject_id: subjectId,
|
| 331 |
+
});
|
| 332 |
+
|
| 333 |
+
const response = await fetchWithRetry(BOX_OAUTH_TOKEN_URL, {
|
| 334 |
+
method: 'POST',
|
| 335 |
+
headers: {
|
| 336 |
+
'Content-Type': 'application/x-www-form-urlencoded',
|
| 337 |
+
},
|
| 338 |
+
body,
|
| 339 |
+
}, { label: 'Box client credentials token request' });
|
| 340 |
+
|
| 341 |
+
if (!response.ok) {
|
| 342 |
+
throw new Error(`Box client credentials token request failed (${response.status}): ${await readErrorMessage(response)}`);
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
const payload = await response.json();
|
| 346 |
+
const expiresAt = payload.expires_in
|
| 347 |
+
? new Date(Date.now() + (Number(payload.expires_in) * 1000)).toISOString()
|
| 348 |
+
: '';
|
| 349 |
+
|
| 350 |
+
if (persist) {
|
| 351 |
+
await persistTokens({
|
| 352 |
+
accessToken: payload.access_token || '',
|
| 353 |
+
accessTokenExpiresAt: expiresAt,
|
| 354 |
+
});
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
+
return normalizeString(payload.access_token);
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
async function resolveAccessToken(boxConfig = null, options = {}) {
|
| 361 |
+
const persist = options.persist !== false;
|
| 362 |
+
const effectiveConfig = boxConfig || await readBoxConfig();
|
| 363 |
+
const authMode = normalizeString(effectiveConfig.authMode || 'oauth_refresh').toLowerCase();
|
| 364 |
+
const canRefreshOAuth = !!(
|
| 365 |
+
normalizeString(effectiveConfig.refreshToken) &&
|
| 366 |
+
normalizeString(effectiveConfig.clientId) &&
|
| 367 |
+
normalizeString(effectiveConfig.clientSecret)
|
| 368 |
+
);
|
| 369 |
+
|
| 370 |
+
if (authMode === 'access_token') {
|
| 371 |
+
const token = normalizeString(effectiveConfig.accessToken);
|
| 372 |
+
if (!token) throw new Error(buildBoxConfigError(effectiveConfig));
|
| 373 |
+
return token;
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
if ((authMode === 'oauth_refresh' && canRefreshOAuth && normalizeString(effectiveConfig.accessTokenExpiresAt) && isStoredTokenFresh(effectiveConfig))
|
| 377 |
+
|| (authMode === 'oauth_refresh' && !canRefreshOAuth && isStoredTokenFresh(effectiveConfig))
|
| 378 |
+
|| (authMode === 'client_credentials' && normalizeString(effectiveConfig.accessTokenExpiresAt) && isStoredTokenFresh(effectiveConfig))) {
|
| 379 |
+
return normalizeString(effectiveConfig.accessToken);
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
if (tokenRefreshPromise) {
|
| 383 |
+
return tokenRefreshPromise;
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
tokenRefreshPromise = (async () => {
|
| 387 |
+
if (authMode === 'client_credentials') {
|
| 388 |
+
return requestBoxTokenViaClientCredentials(effectiveConfig, { persist });
|
| 389 |
+
}
|
| 390 |
+
return requestBoxTokenViaRefresh(effectiveConfig, { persist });
|
| 391 |
+
})();
|
| 392 |
+
|
| 393 |
+
try {
|
| 394 |
+
return await tokenRefreshPromise;
|
| 395 |
+
} finally {
|
| 396 |
+
tokenRefreshPromise = null;
|
| 397 |
+
}
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
async function boxRequestJson(boxPath, options = {}, overrideConfig = null) {
|
| 401 |
+
const boxConfig = overrideConfig || await readBoxConfig();
|
| 402 |
+
const accessToken = await resolveAccessToken(boxConfig, { persist: !overrideConfig });
|
| 403 |
+
const response = await fetchWithRetry(`${BOX_API_BASE}${boxPath}`, {
|
| 404 |
+
method: options.method || 'GET',
|
| 405 |
+
headers: {
|
| 406 |
+
Authorization: `Bearer ${accessToken}`,
|
| 407 |
+
...(options.body ? { 'Content-Type': 'application/json' } : {}),
|
| 408 |
+
...(options.headers || {}),
|
| 409 |
+
},
|
| 410 |
+
...(options.body ? { body: JSON.stringify(options.body) } : {}),
|
| 411 |
+
}, { label: options.label || `Box ${options.method || 'GET'} ${boxPath}` });
|
| 412 |
+
|
| 413 |
+
if (!response.ok) {
|
| 414 |
+
throw new Error(`${options.label || 'Box request'} failed (${response.status}): ${await readErrorMessage(response)}`);
|
| 415 |
+
}
|
| 416 |
+
|
| 417 |
+
if (options.rawResponse) return response;
|
| 418 |
+
return response.json();
|
| 419 |
+
}
|
| 420 |
+
|
| 421 |
+
async function uploadBufferToBox({ folderId, fileName, buffer, contentType, boxConfig = null }) {
|
| 422 |
+
const effectiveConfig = boxConfig || await readBoxConfig();
|
| 423 |
+
const accessToken = await resolveAccessToken(effectiveConfig, { persist: !boxConfig });
|
| 424 |
+
|
| 425 |
+
const formData = new FormData();
|
| 426 |
+
formData.append('attributes', JSON.stringify({
|
| 427 |
+
name: fileName,
|
| 428 |
+
parent: { id: folderId },
|
| 429 |
+
}));
|
| 430 |
+
formData.append('file', new Blob([buffer], { type: contentType || 'application/octet-stream' }), fileName);
|
| 431 |
+
|
| 432 |
+
const response = await fetchWithRetry(BOX_UPLOAD_URL, {
|
| 433 |
+
method: 'POST',
|
| 434 |
+
headers: {
|
| 435 |
+
Authorization: `Bearer ${accessToken}`,
|
| 436 |
+
},
|
| 437 |
+
body: formData,
|
| 438 |
+
}, { label: `Box upload ${fileName}` });
|
| 439 |
+
|
| 440 |
+
if (!response.ok) {
|
| 441 |
+
if (response.status === 409) {
|
| 442 |
+
try {
|
| 443 |
+
const conflictData = await response.json();
|
| 444 |
+
const existingFileId = conflictData.context_info?.conflicts?.id
|
| 445 |
+
|| conflictData.context_info?.conflicts?.[0]?.id;
|
| 446 |
+
|
| 447 |
+
if (existingFileId) {
|
| 448 |
+
console.log(`[BoxService] File "${fileName}" already exists (id: ${existingFileId}). Uploading new version.`);
|
| 449 |
+
const versionFormData = new FormData();
|
| 450 |
+
versionFormData.append('file', new Blob([buffer], { type: contentType || 'application/octet-stream' }), fileName);
|
| 451 |
+
|
| 452 |
+
const versionUrl = `https://upload.box.com/api/2.0/files/${existingFileId}/content`;
|
| 453 |
+
const versionResponse = await fetchWithRetry(versionUrl, {
|
| 454 |
+
method: 'POST',
|
| 455 |
+
headers: {
|
| 456 |
+
Authorization: `Bearer ${accessToken}`,
|
| 457 |
+
},
|
| 458 |
+
body: versionFormData,
|
| 459 |
+
}, { label: `Box upload new version ${fileName}` });
|
| 460 |
+
|
| 461 |
+
if (versionResponse.ok) {
|
| 462 |
+
const versionPayload = await versionResponse.json();
|
| 463 |
+
const entry = versionPayload.entries?.[0] || {};
|
| 464 |
+
return {
|
| 465 |
+
fileId: normalizeString(entry.id),
|
| 466 |
+
fileName: normalizeString(entry.name),
|
| 467 |
+
sharedLink: entry.shared_link?.url || null,
|
| 468 |
+
};
|
| 469 |
+
} else {
|
| 470 |
+
throw new Error(`Box upload new version failed (${versionResponse.status}): ${await readErrorMessage(versionResponse)}`);
|
| 471 |
+
}
|
| 472 |
+
}
|
| 473 |
+
} catch (conflictError) {
|
| 474 |
+
throw new Error(`Box upload conflict resolution failed: ${conflictError.message}`);
|
| 475 |
+
}
|
| 476 |
+
}
|
| 477 |
+
throw new Error(`Box upload failed (${response.status}): ${await readErrorMessage(response)}`);
|
| 478 |
+
}
|
| 479 |
+
|
| 480 |
+
const payload = await response.json();
|
| 481 |
+
const entry = payload.entries?.[0] || {};
|
| 482 |
+
return {
|
| 483 |
+
fileId: normalizeString(entry.id),
|
| 484 |
+
fileName: normalizeString(entry.name),
|
| 485 |
+
sharedLink: entry.shared_link?.url || null,
|
| 486 |
+
};
|
| 487 |
+
}
|
| 488 |
+
|
| 489 |
+
async function testConnection() {
|
| 490 |
+
const boxConfig = await readBoxConfig();
|
| 491 |
+
const status = computeBoxStatus(boxConfig);
|
| 492 |
+
if (!status.hasAuth) {
|
| 493 |
+
return { ok: false, error: buildBoxConfigError(boxConfig) };
|
| 494 |
+
}
|
| 495 |
+
if (!status.hasIntakeFolderId) {
|
| 496 |
+
return { ok: false, error: 'Incoming intake folder ID is required.' };
|
| 497 |
+
}
|
| 498 |
+
|
| 499 |
+
try {
|
| 500 |
+
const intakeFolder = await boxRequestJson(`/folders/${encodeURIComponent(status.intakeFolderId)}`, {
|
| 501 |
+
label: 'Box intake folder lookup',
|
| 502 |
+
}, boxConfig);
|
| 503 |
+
|
| 504 |
+
let caseRootFolder = null;
|
| 505 |
+
if (status.caseRootFolderId && status.caseRootFolderId !== status.intakeFolderId) {
|
| 506 |
+
caseRootFolder = await boxRequestJson(`/folders/${encodeURIComponent(status.caseRootFolderId)}`, {
|
| 507 |
+
label: 'Box case root folder lookup',
|
| 508 |
+
}, boxConfig);
|
| 509 |
+
}
|
| 510 |
+
|
| 511 |
+
return {
|
| 512 |
+
ok: true,
|
| 513 |
+
folderName: intakeFolder.name || '(unknown)',
|
| 514 |
+
caseRootFolderName: caseRootFolder?.name || intakeFolder.name || '(unknown)',
|
| 515 |
+
};
|
| 516 |
+
} catch (error) {
|
| 517 |
+
return { ok: false, error: error.message };
|
| 518 |
+
}
|
| 519 |
+
}
|
| 520 |
+
|
| 521 |
+
// Create an open, downloadable shared link and return its direct download_url
|
| 522 |
+
// (what Box Doc Gen needs to fetch+embed the photo as an image tag).
|
| 523 |
+
async function createDirectSharedLink(fileId, boxConfig) {
|
| 524 |
+
try {
|
| 525 |
+
const token = await resolveAccessToken(boxConfig);
|
| 526 |
+
const response = await fetchWithRetry(`${BOX_API_BASE}/files/${encodeURIComponent(fileId)}?fields=shared_link`, {
|
| 527 |
+
method: 'PUT',
|
| 528 |
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
| 529 |
+
body: JSON.stringify({ shared_link: { access: 'open', permissions: { can_download: true } } }),
|
| 530 |
+
}, { label: `Box shared link ${fileId}` });
|
| 531 |
+
if (!response.ok) return '';
|
| 532 |
+
const data = await response.json();
|
| 533 |
+
return normalizeString(data.shared_link?.download_url || data.shared_link?.url || '');
|
| 534 |
+
} catch (error) {
|
| 535 |
+
console.warn('[box-service] createDirectSharedLink failed:', error.message);
|
| 536 |
+
return '';
|
| 537 |
+
}
|
| 538 |
+
}
|
| 539 |
+
|
| 540 |
+
async function uploadPhoto({ caseId, fileBuffer, contentType = 'image/jpeg' }) {
|
| 541 |
+
const boxConfig = await readBoxConfig();
|
| 542 |
+
const status = computeBoxStatus(boxConfig);
|
| 543 |
+
if (!status.configured) {
|
| 544 |
+
throw new Error(buildBoxConfigError(boxConfig));
|
| 545 |
+
}
|
| 546 |
+
|
| 547 |
+
// Photos go to a SEPARATE "Pothole Photos" folder β not the trigger folder β
|
| 548 |
+
// so the photo upload never fires WF1 (which would crash the Extract Agent on an image).
|
| 549 |
+
let photoFolderId = status.intakeFolderId;
|
| 550 |
+
try {
|
| 551 |
+
const token = await resolveAccessToken(boxConfig);
|
| 552 |
+
photoFolderId = await getOrCreateFolder(status.intakeFolderId, 'Pothole Photos', token);
|
| 553 |
+
} catch (e) {
|
| 554 |
+
console.warn('[box-service] could not create Pothole Photos folder, using intake:', e.message);
|
| 555 |
+
}
|
| 556 |
+
const upload = await uploadBufferToBox({
|
| 557 |
+
folderId: photoFolderId,
|
| 558 |
+
fileName: `${caseId}_photo.jpg`,
|
| 559 |
+
buffer: fileBuffer,
|
| 560 |
+
contentType,
|
| 561 |
+
boxConfig,
|
| 562 |
+
});
|
| 563 |
+
// photoUrl = direct shared link to the photo (used in the JSON, the mail, and docs).
|
| 564 |
+
let photoUrl = await createDirectSharedLink(upload.fileId, boxConfig);
|
| 565 |
+
if (!photoUrl) {
|
| 566 |
+
// Box can briefly reject a shared link right after upload β retry once.
|
| 567 |
+
await new Promise((r) => setTimeout(r, 700));
|
| 568 |
+
photoUrl = await createDirectSharedLink(upload.fileId, boxConfig);
|
| 569 |
+
}
|
| 570 |
+
return { ...upload, photoUrl };
|
| 571 |
+
}
|
| 572 |
+
|
| 573 |
+
function buildSubmissionSidecar(metadata = {}, intakeFolderId = '') {
|
| 574 |
+
const sanitized = stripPhotoDataUrl(metadata);
|
| 575 |
+
const caseId = normalizeString(sanitized.caseId);
|
| 576 |
+
const submittedAt = normalizeString(sanitized.submittedAt) || nowIso();
|
| 577 |
+
const workflow = buildInitialWorkflow(
|
| 578 |
+
{ ...sanitized, caseId, submittedAt },
|
| 579 |
+
{ folderId: intakeFolderId, folderName: 'Incoming Detections' }
|
| 580 |
+
);
|
| 581 |
+
|
| 582 |
+
return {
|
| 583 |
+
caseId,
|
| 584 |
+
submittedAt,
|
| 585 |
+
...sanitized,
|
| 586 |
+
workflow,
|
| 587 |
+
...flattenWorkflowForBox(workflow),
|
| 588 |
+
};
|
| 589 |
+
}
|
| 590 |
+
|
| 591 |
+
async function uploadSidecar({ caseId, metadata }) {
|
| 592 |
+
const boxConfig = await readBoxConfig();
|
| 593 |
+
const status = computeBoxStatus(boxConfig);
|
| 594 |
+
if (!status.configured) {
|
| 595 |
+
throw new Error(buildBoxConfigError(boxConfig));
|
| 596 |
+
}
|
| 597 |
+
|
| 598 |
+
const sidecarPayload = buildSubmissionSidecar({ ...metadata, caseId }, status.intakeFolderId);
|
| 599 |
+
const sidecarBuffer = Buffer.from(JSON.stringify(sidecarPayload, null, 2), 'utf8');
|
| 600 |
+
const result = await uploadBufferToBox({
|
| 601 |
+
folderId: status.intakeFolderId,
|
| 602 |
+
fileName: `${caseId}_metadata.json`,
|
| 603 |
+
buffer: sidecarBuffer,
|
| 604 |
+
contentType: 'application/json',
|
| 605 |
+
boxConfig,
|
| 606 |
+
});
|
| 607 |
+
|
| 608 |
+
return {
|
| 609 |
+
...result,
|
| 610 |
+
sidecarPayload,
|
| 611 |
+
};
|
| 612 |
+
}
|
| 613 |
+
|
| 614 |
+
async function generateAndUploadAuditReport({ caseId, caseRecord }) {
|
| 615 |
+
const boxConfig = await readBoxConfig();
|
| 616 |
+
const status = computeBoxStatus(boxConfig);
|
| 617 |
+
if (!status.configured) {
|
| 618 |
+
throw new Error(buildBoxConfigError(boxConfig));
|
| 619 |
+
}
|
| 620 |
+
|
| 621 |
+
const report = caseRecord.report || {};
|
| 622 |
+
const folderId = report.boxFolderId || status.caseRootFolderId || status.intakeFolderId;
|
| 623 |
+
|
| 624 |
+
const mdContent = `# Pothole Case Audit Report - ${caseId}
|
| 625 |
+
**Status:** Resolved
|
| 626 |
+
**Generated on:** ${nowIso()}
|
| 627 |
+
|
| 628 |
+
---
|
| 629 |
+
|
| 630 |
+
## 1. Intake Information
|
| 631 |
+
* **Case ID:** ${caseId}
|
| 632 |
+
* **Submission Timestamp:** ${report.submittedAt || caseRecord.createdAt || 'N/A'}
|
| 633 |
+
* **Location:** ${report.address || 'Unknown Address'}
|
| 634 |
+
* **GPS Coordinates:** ${report.location?.lat ?? 'N/A'}, ${report.location?.lng ?? 'N/A'}
|
| 635 |
+
* **Reporter Name:** ${report.anonymous ? 'Anonymous' : (report.reporterName || 'N/A')}
|
| 636 |
+
* **Reporter Contact:** ${report.anonymous ? 'Anonymous' : (report.reporterContact || 'N/A')}
|
| 637 |
+
* **Initial Description:** ${report.description || 'No description provided.'}
|
| 638 |
+
* **AI Initial Classification:** ${report.aiResult || 'N/A'}
|
| 639 |
+
|
| 640 |
+
---
|
| 641 |
+
|
| 642 |
+
## 2. Enrichment & Severity Triage
|
| 643 |
+
* **Enriched At:** ${report.enrichment?.enrichedAt || 'N/A'}
|
| 644 |
+
* **Severity Score:** ${report.enrichment?.severityScore ?? 'N/A'} / 100
|
| 645 |
+
* **Severity Level:** ${report.enrichment?.severityLevel || 'N/A'}
|
| 646 |
+
* **Ward / District:** ${report.ward || 'N/A'} / ${report.district || 'N/A'}
|
| 647 |
+
* **Maintenance Zone:** ${report.maintenanceZone || 'N/A'}
|
| 648 |
+
* **Duplicate Status:** ${report.enrichment?.duplicate?.isDuplicate ? `Duplicate (${report.enrichment.duplicate.duplicateCount} prior reports)` : 'New Incident'}
|
| 649 |
+
* **Weather Risk:** ${report.enrichment?.weather?.condition || 'N/A'} (Risk Level: ${report.enrichment?.weather?.riskLevel || 'N/A'})
|
| 650 |
+
|
| 651 |
+
---
|
| 652 |
+
|
| 653 |
+
## 3. Dispatch & Field Operations
|
| 654 |
+
* **Assigned Crew / Lead:** ${report.crewName || report.assignedTo || 'Unassigned'}
|
| 655 |
+
* **Work Status:** ${caseRecord.status || 'Resolved'}
|
| 656 |
+
|
| 657 |
+
---
|
| 658 |
+
|
| 659 |
+
## 4. Completion & Resolution Proof
|
| 660 |
+
* **Completion Timestamp:** ${report.resolvedAt || 'N/A'}
|
| 661 |
+
* **Resolution Notes:** ${report.resolutionNotes || 'No notes provided.'}
|
| 662 |
+
* **Materials Used / Quantity:** ${report.materialsUsed || 'N/A'}
|
| 663 |
+
* **Labor Hours Expended:** ${report.laborHours || 'N/A'}
|
| 664 |
+
* **Crew Digital Signature:** ${report.crewSignature || 'N/A'}
|
| 665 |
+
|
| 666 |
+
---
|
| 667 |
+
|
| 668 |
+
## 5. Visual Evidence (Before vs. After)
|
| 669 |
+
* **Before Photo (Intake):** ${report.photoFileId ? `[View Intake Photo](https://app.box.com/file/${report.photoFileId})` : 'N/A'}
|
| 670 |
+
* **After Photo (Resolution):** ${report.afterPhotoFileId ? `[View Repair Photo](https://app.box.com/file/${report.afterPhotoFileId})` : 'N/A'}
|
| 671 |
+
|
| 672 |
+
---
|
| 673 |
+
|
| 674 |
+
## 6. System Logs & Audit Trail
|
| 675 |
+
* **Created At:** ${caseRecord.createdAt || 'N/A'}
|
| 676 |
+
* **Last Updated At:** ${caseRecord.updatedAt || 'N/A'}
|
| 677 |
+
`;
|
| 678 |
+
|
| 679 |
+
const reportBuffer = Buffer.from(mdContent, 'utf8');
|
| 680 |
+
const fileName = `${caseId}_audit_report.md`;
|
| 681 |
+
|
| 682 |
+
return uploadBufferToBox({
|
| 683 |
+
folderId,
|
| 684 |
+
fileName,
|
| 685 |
+
buffer: reportBuffer,
|
| 686 |
+
contentType: 'text/markdown',
|
| 687 |
+
boxConfig,
|
| 688 |
+
});
|
| 689 |
+
}
|
| 690 |
+
|
| 691 |
+
// Locate the Box case folder for a case: prefer the stored boxFolderId, else
|
| 692 |
+
// search for the "Case-{caseId}" folder by name.
|
| 693 |
+
async function findCaseFolderId(caseId, caseRecord, boxConfig) {
|
| 694 |
+
const stored = caseRecord?.report?.boxFolderId || caseRecord?.boxFolderId;
|
| 695 |
+
if (stored) return String(stored);
|
| 696 |
+
try {
|
| 697 |
+
const params = new URLSearchParams({ query: `Case-${caseId}`, type: 'folder', limit: '10' });
|
| 698 |
+
const payload = await boxRequestJson(`/search?${params.toString()}`, {
|
| 699 |
+
label: `Box folder search ${caseId}`,
|
| 700 |
+
}, boxConfig);
|
| 701 |
+
const match = (payload.entries || []).find(
|
| 702 |
+
(e) => e.type === 'folder' && e.name === `Case-${caseId}`,
|
| 703 |
+
);
|
| 704 |
+
return match ? String(match.id) : null;
|
| 705 |
+
} catch (error) {
|
| 706 |
+
console.warn('[box-service] findCaseFolderId failed:', error.message);
|
| 707 |
+
return null;
|
| 708 |
+
}
|
| 709 |
+
}
|
| 710 |
+
|
| 711 |
+
// Flip the case folder's potholeCase metadata to status=resolved + resolvedAt,
|
| 712 |
+
// so the Box App "Resolved" view (which reads metadata) reflects the closeout.
|
| 713 |
+
async function markCaseResolvedInBox({ caseId, caseRecord, resolvedAt, resolvedBy } = {}) {
|
| 714 |
+
const id = String(caseId || caseRecord?.caseId || '').trim();
|
| 715 |
+
if (!id) return { skipped: true, reason: 'missing_case_id' };
|
| 716 |
+
|
| 717 |
+
const boxConfig = await readBoxConfig();
|
| 718 |
+
const status = computeBoxStatus(boxConfig);
|
| 719 |
+
if (!status.configured) return { skipped: true, reason: 'box_not_configured' };
|
| 720 |
+
if (!status.hasCustomMetadataTemplate) return { skipped: true, reason: 'no_metadata_template' };
|
| 721 |
+
|
| 722 |
+
const folderId = await findCaseFolderId(id, caseRecord, boxConfig);
|
| 723 |
+
if (!folderId) return { skipped: true, reason: 'case_folder_not_found' };
|
| 724 |
+
|
| 725 |
+
const token = await resolveAccessToken(boxConfig);
|
| 726 |
+
const template = { scope: boxConfig.metadataScope, templateKey: boxConfig.metadataTemplateKey };
|
| 727 |
+
const kv = {
|
| 728 |
+
status: 'resolved',
|
| 729 |
+
resolvedAt: resolvedAt || nowIso(),
|
| 730 |
+
supervisorWardEngineer: String(resolvedBy || '').trim(),
|
| 731 |
+
};
|
| 732 |
+
await applyFolderMetadata(folderId, kv, token, { template });
|
| 733 |
+
return { ok: true, folderId, applied: kv };
|
| 734 |
+
}
|
| 735 |
+
|
| 736 |
+
// Set any status on a case folder's metadata (Approve/Resolve/Reject/Reopen from the dashboard).
|
| 737 |
+
async function setCaseStatusInBox({ caseId, caseRecord, status, by } = {}) {
|
| 738 |
+
const id = String(caseId || caseRecord?.caseId || '').trim();
|
| 739 |
+
if (!id) return { skipped: true, reason: 'missing_case_id' };
|
| 740 |
+
const boxConfig = await readBoxConfig();
|
| 741 |
+
const st = computeBoxStatus(boxConfig);
|
| 742 |
+
if (!st.configured) return { skipped: true, reason: 'box_not_configured' };
|
| 743 |
+
if (!st.hasCustomMetadataTemplate) return { skipped: true, reason: 'no_metadata_template' };
|
| 744 |
+
const folderId = await findCaseFolderId(id, caseRecord, boxConfig);
|
| 745 |
+
if (!folderId) return { skipped: true, reason: 'case_folder_not_found' };
|
| 746 |
+
const token = await resolveAccessToken(boxConfig);
|
| 747 |
+
const template = { scope: boxConfig.metadataScope, templateKey: boxConfig.metadataTemplateKey };
|
| 748 |
+
const kv = { status };
|
| 749 |
+
if (by) kv.supervisorWardEngineer = String(by).trim();
|
| 750 |
+
if (String(status).toLowerCase() === 'resolved') kv.resolvedAt = nowIso();
|
| 751 |
+
await applyFolderMetadata(folderId, kv, token, { template });
|
| 752 |
+
return { ok: true, folderId, applied: kv };
|
| 753 |
+
}
|
| 754 |
+
|
| 755 |
+
async function uploadCaseArtifact({ caseId, caseRecord, fileName, content, contentType = 'text/plain; charset=utf-8' }) {
|
| 756 |
+
const boxConfig = await readBoxConfig();
|
| 757 |
+
const status = computeBoxStatus(boxConfig);
|
| 758 |
+
if (!status.configured) {
|
| 759 |
+
return {
|
| 760 |
+
skipped: true,
|
| 761 |
+
reason: 'box_not_configured',
|
| 762 |
+
};
|
| 763 |
+
}
|
| 764 |
+
|
| 765 |
+
const report = caseRecord?.report || {};
|
| 766 |
+
const folderId = report.boxFolderId || status.caseRootFolderId || status.intakeFolderId;
|
| 767 |
+
const buffer = Buffer.isBuffer(content) ? content : Buffer.from(String(content ?? ''), 'utf8');
|
| 768 |
+
|
| 769 |
+
const upload = await uploadBufferToBox({
|
| 770 |
+
folderId,
|
| 771 |
+
fileName,
|
| 772 |
+
buffer,
|
| 773 |
+
contentType,
|
| 774 |
+
boxConfig,
|
| 775 |
+
});
|
| 776 |
+
|
| 777 |
+
return {
|
| 778 |
+
caseId: normalizeString(caseId || caseRecord?.caseId),
|
| 779 |
+
folderId,
|
| 780 |
+
...upload,
|
| 781 |
+
};
|
| 782 |
+
}
|
| 783 |
+
|
| 784 |
+
// Ensure a single dedicated "Closeout Reports" folder under the case root. All
|
| 785 |
+
// before/after reports go here so a collaborator can watch ONE folder and get
|
| 786 |
+
// exactly one notification per closeout (no per-case-folder photo/audit noise).
|
| 787 |
+
const _closeoutReportsFolderCache = {};
|
| 788 |
+
const CLOSEOUT_REPORTS_FOLDER_NAME = 'Closeout Reports';
|
| 789 |
+
async function ensureCloseoutReportsFolder(boxConfig) {
|
| 790 |
+
const status = computeBoxStatus(boxConfig);
|
| 791 |
+
const rootId = normalizeString(status.caseRootFolderId || status.intakeFolderId);
|
| 792 |
+
if (!rootId) return '';
|
| 793 |
+
if (_closeoutReportsFolderCache[rootId]) return _closeoutReportsFolderCache[rootId];
|
| 794 |
+
const token = await resolveAccessToken(boxConfig);
|
| 795 |
+
const folderId = await getOrCreateFolder(rootId, CLOSEOUT_REPORTS_FOLDER_NAME, token);
|
| 796 |
+
_closeoutReportsFolderCache[rootId] = folderId;
|
| 797 |
+
return folderId;
|
| 798 |
+
}
|
| 799 |
+
|
| 800 |
+
// Upload a before/after report into the dedicated "Closeout Reports" folder.
|
| 801 |
+
async function uploadCloseoutReport({ caseId, fileName, content, contentType = 'application/octet-stream' }) {
|
| 802 |
+
const boxConfig = await readBoxConfig();
|
| 803 |
+
const status = computeBoxStatus(boxConfig);
|
| 804 |
+
if (!status.configured) return { skipped: true, reason: 'box_not_configured' };
|
| 805 |
+
|
| 806 |
+
const folderId = await ensureCloseoutReportsFolder(boxConfig);
|
| 807 |
+
if (!folderId) return { skipped: true, reason: 'no_root_folder' };
|
| 808 |
+
|
| 809 |
+
const buffer = Buffer.isBuffer(content) ? content : Buffer.from(String(content ?? ''), 'utf8');
|
| 810 |
+
const upload = await uploadBufferToBox({ folderId, fileName, buffer, contentType, boxConfig });
|
| 811 |
+
return {
|
| 812 |
+
caseId: normalizeString(caseId),
|
| 813 |
+
folderId,
|
| 814 |
+
fileName,
|
| 815 |
+
...upload,
|
| 816 |
+
};
|
| 817 |
+
}
|
| 818 |
+
|
| 819 |
+
// Upload a backend-generated document (.docx) to the case folder (or a dedicated
|
| 820 |
+
// "Pothole Documents" folder) β never the trigger root β and return a shared link.
|
| 821 |
+
async function uploadGeneratedDoc({ caseId, caseRecord, fileName, buffer }) {
|
| 822 |
+
const boxConfig = await readBoxConfig();
|
| 823 |
+
const status = computeBoxStatus(boxConfig);
|
| 824 |
+
if (!status.configured) throw new Error(buildBoxConfigError(boxConfig));
|
| 825 |
+
|
| 826 |
+
let folderId = await findCaseFolderId(caseId, caseRecord, boxConfig);
|
| 827 |
+
if (!folderId) {
|
| 828 |
+
const token = await resolveAccessToken(boxConfig);
|
| 829 |
+
folderId = await getOrCreateFolder(status.intakeFolderId, 'Pothole Documents', token);
|
| 830 |
+
}
|
| 831 |
+
|
| 832 |
+
const upload = await uploadBufferToBox({
|
| 833 |
+
folderId,
|
| 834 |
+
fileName,
|
| 835 |
+
buffer,
|
| 836 |
+
contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
| 837 |
+
boxConfig,
|
| 838 |
+
});
|
| 839 |
+
|
| 840 |
+
let sharedLink = '';
|
| 841 |
+
try { sharedLink = await createDirectSharedLink(upload.fileId, boxConfig); } catch (_) {}
|
| 842 |
+
if (!sharedLink) {
|
| 843 |
+
// Box can briefly reject a shared link right after upload β retry once.
|
| 844 |
+
await new Promise((r) => setTimeout(r, 700));
|
| 845 |
+
try { sharedLink = await createDirectSharedLink(upload.fileId, boxConfig); } catch (_) {}
|
| 846 |
+
}
|
| 847 |
+
|
| 848 |
+
return { caseId: normalizeString(caseId || caseRecord?.caseId), folderId, fileName, sharedLink, ...upload };
|
| 849 |
+
}
|
| 850 |
+
|
| 851 |
+
// Create a Box Sign request β emails the signer(s) a fillable doc to complete + sign (WF2 crew closeout).
|
| 852 |
+
async function createSignRequest({ sourceFileId, parentFolderId, signers, emailSubject, emailMessage } = {}) {
|
| 853 |
+
const boxConfig = await readBoxConfig();
|
| 854 |
+
const status = computeBoxStatus(boxConfig);
|
| 855 |
+
if (!status.configured) throw new Error(buildBoxConfigError(boxConfig));
|
| 856 |
+
if (!sourceFileId) throw new Error('sourceFileId is required for the sign request.');
|
| 857 |
+
if (!Array.isArray(signers) || !signers.length) throw new Error('At least one signer email is required.');
|
| 858 |
+
const token = await resolveAccessToken(boxConfig);
|
| 859 |
+
const body = {
|
| 860 |
+
source_files: [{ id: String(sourceFileId), type: 'file' }],
|
| 861 |
+
signers: signers.map((s, i) => ({ email: s.email, role: 'signer', order: i + 1 })),
|
| 862 |
+
};
|
| 863 |
+
if (parentFolderId) body.parent_folder = { id: String(parentFolderId), type: 'folder' };
|
| 864 |
+
if (emailSubject) body.email_subject = emailSubject;
|
| 865 |
+
if (emailMessage) body.email_message = emailMessage;
|
| 866 |
+
const response = await fetchWithRetry(`${BOX_API_BASE}/sign_requests`, {
|
| 867 |
+
method: 'POST',
|
| 868 |
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
| 869 |
+
body: JSON.stringify(body),
|
| 870 |
+
}, { label: 'Box Sign request' });
|
| 871 |
+
if (!response.ok) {
|
| 872 |
+
throw new Error(`Box Sign request failed (${response.status}): ${await readErrorMessage(response)}`);
|
| 873 |
+
}
|
| 874 |
+
const data = await response.json();
|
| 875 |
+
return {
|
| 876 |
+
ok: true,
|
| 877 |
+
signRequestId: data.id,
|
| 878 |
+
status: data.status,
|
| 879 |
+
signers: (data.signers || []).map((s) => s.email),
|
| 880 |
+
prepareUrl: data.prepare_url || '',
|
| 881 |
+
};
|
| 882 |
+
}
|
| 883 |
+
|
| 884 |
+
async function organizeCase(payload = {}) {
|
| 885 |
+
const boxConfig = await readBoxConfig();
|
| 886 |
+
const status = computeBoxStatus(boxConfig);
|
| 887 |
+
if (!status.configured) {
|
| 888 |
+
throw new Error(buildBoxConfigError(boxConfig));
|
| 889 |
+
}
|
| 890 |
+
|
| 891 |
+
const accessToken = await resolveAccessToken(boxConfig);
|
| 892 |
+
return organizeInBox({
|
| 893 |
+
caseId: normalizeString(payload.caseId),
|
| 894 |
+
ward: normalizeString(payload.ward),
|
| 895 |
+
district: normalizeString(payload.district),
|
| 896 |
+
photoFileId: normalizeString(payload.photoFileId),
|
| 897 |
+
sidecarFileId: normalizeString(payload.sidecarFileId),
|
| 898 |
+
enrichment: stripPhotoDataUrl(payload.enrichment || {}),
|
| 899 |
+
reportPayload: stripPhotoDataUrl(payload.reportPayload || {}),
|
| 900 |
+
rootFolderId: status.caseRootFolderId || status.intakeFolderId,
|
| 901 |
+
metadataTemplate: {
|
| 902 |
+
scope: normalizeString(boxConfig.metadataScope),
|
| 903 |
+
templateKey: normalizeString(boxConfig.metadataTemplateKey),
|
| 904 |
+
},
|
| 905 |
+
token: accessToken,
|
| 906 |
+
});
|
| 907 |
+
}
|
| 908 |
+
|
| 909 |
+
async function getFileContent(fileId) {
|
| 910 |
+
const boxConfig = await readBoxConfig();
|
| 911 |
+
const accessToken = await resolveAccessToken(boxConfig);
|
| 912 |
+
const response = await fetchWithRetry(`${BOX_API_BASE}/files/${encodeURIComponent(fileId)}/content`, {
|
| 913 |
+
method: 'GET',
|
| 914 |
+
headers: {
|
| 915 |
+
Authorization: `Bearer ${accessToken}`,
|
| 916 |
+
},
|
| 917 |
+
}, { label: `Box file content ${fileId}` });
|
| 918 |
+
|
| 919 |
+
if (!response.ok) {
|
| 920 |
+
throw new Error(`Box file preview failed (${response.status}): ${await readErrorMessage(response)}`);
|
| 921 |
+
}
|
| 922 |
+
|
| 923 |
+
return {
|
| 924 |
+
contentType: response.headers.get('content-type') || 'application/octet-stream',
|
| 925 |
+
contentLength: response.headers.get('content-length') || '',
|
| 926 |
+
body: Buffer.from(await response.arrayBuffer()),
|
| 927 |
+
};
|
| 928 |
+
}
|
| 929 |
+
|
| 930 |
+
// Look up a single case straight from Box and return its REAL status (read from
|
| 931 |
+
// the case folder's potholeCase metadata) so the citizen tracker reflects live
|
| 932 |
+
// supervisor decisions instead of being pinned to "Under Review".
|
| 933 |
+
async function searchCase(caseId) {
|
| 934 |
+
const id = String(caseId || '').trim();
|
| 935 |
+
if (!id) return null;
|
| 936 |
+
const boxConfig = await readBoxConfig();
|
| 937 |
+
const status = computeBoxStatus(boxConfig);
|
| 938 |
+
if (!status.configured) return null;
|
| 939 |
+
|
| 940 |
+
try {
|
| 941 |
+
const folderId = await findCaseFolderId(id, null, boxConfig);
|
| 942 |
+
if (!folderId) return null;
|
| 943 |
+
|
| 944 |
+
const scope = normalizeString(boxConfig.metadataScope) || 'enterprise';
|
| 945 |
+
const templateKey = normalizeString(boxConfig.metadataTemplateKey) || 'potholeCase';
|
| 946 |
+
let md = {};
|
| 947 |
+
try {
|
| 948 |
+
md = await boxRequestJson(
|
| 949 |
+
`/folders/${folderId}/metadata/${scope}/${templateKey}`,
|
| 950 |
+
{ label: `Box case metadata ${id}` },
|
| 951 |
+
boxConfig,
|
| 952 |
+
);
|
| 953 |
+
} catch (_) {
|
| 954 |
+
// Folder exists but has no potholeCase metadata yet β treat as freshly submitted.
|
| 955 |
+
md = {};
|
| 956 |
+
}
|
| 957 |
+
|
| 958 |
+
const num = (v) => (v === undefined || v === null || v === '' ? null : Number(v));
|
| 959 |
+
const lat = num(md.latitude);
|
| 960 |
+
const lng = num(md.longitude);
|
| 961 |
+
return {
|
| 962 |
+
caseId: md.caseId || id,
|
| 963 |
+
status: normalizeString(md.status) || 'Under Review',
|
| 964 |
+
ts: md.submittedAt || Date.now(),
|
| 965 |
+
address: md.address || '',
|
| 966 |
+
ward: md.ward || '',
|
| 967 |
+
location: (lat != null && lng != null) ? { lat, lng } : null,
|
| 968 |
+
reporterContact: md.reporterContact || '',
|
| 969 |
+
potholeSize: md.potholeSize || '',
|
| 970 |
+
severityLevel: md.severityLevel || '',
|
| 971 |
+
severityScore: num(md.severityScore0100),
|
| 972 |
+
weatherRisk: md.weatherRisk || '',
|
| 973 |
+
district: md.district || '',
|
| 974 |
+
maintenanceZone: md.maintenanceZone || '',
|
| 975 |
+
resolvedAt: md.resolvedAt || '',
|
| 976 |
+
folderId,
|
| 977 |
+
source: 'box',
|
| 978 |
+
};
|
| 979 |
+
} catch (error) {
|
| 980 |
+
console.warn('[box-service] searchCase failed:', error.message);
|
| 981 |
+
}
|
| 982 |
+
|
| 983 |
+
return null;
|
| 984 |
+
}
|
| 985 |
+
|
| 986 |
+
// Pull the citizen's email out of the case folder's JSON sidecar(s). The submission
|
| 987 |
+
// sidecar ({caseId}_metadata.json) and enriched metadata carry reporterContact even
|
| 988 |
+
// when the Box metadata template field is empty β so read the JSON and extract it.
|
| 989 |
+
async function extractCitizenEmailFromCaseJson(caseId, folderId, boxConfig) {
|
| 990 |
+
try {
|
| 991 |
+
const items = await boxRequestJson(
|
| 992 |
+
`/folders/${folderId}/items?fields=id,name,type&limit=1000`,
|
| 993 |
+
{ label: `case items ${caseId}` },
|
| 994 |
+
boxConfig,
|
| 995 |
+
);
|
| 996 |
+
const score = (name) => {
|
| 997 |
+
if (/_metadata\.json$/i.test(name) && !/enriched/i.test(name)) return 3; // submission sidecar
|
| 998 |
+
if (/enriched.*\.json$/i.test(name)) return 2;
|
| 999 |
+
return 1;
|
| 1000 |
+
};
|
| 1001 |
+
const jsonFiles = (items.entries || [])
|
| 1002 |
+
.filter((e) => e.type === 'file' && /\.json$/i.test(e.name))
|
| 1003 |
+
.sort((a, b) => score(b.name) - score(a.name));
|
| 1004 |
+
|
| 1005 |
+
for (const file of jsonFiles) {
|
| 1006 |
+
let text;
|
| 1007 |
+
try {
|
| 1008 |
+
const content = await getFileContent(file.id);
|
| 1009 |
+
text = content.body.toString('utf8');
|
| 1010 |
+
} catch (_) { continue; }
|
| 1011 |
+
|
| 1012 |
+
let email = '';
|
| 1013 |
+
try {
|
| 1014 |
+
const obj = JSON.parse(text);
|
| 1015 |
+
email = extractEmail(
|
| 1016 |
+
obj.reporterContact || obj.reporterEmail || obj.contact || obj.email
|
| 1017 |
+
|| obj.report?.reporterContact || obj.report?.reporterEmail || obj.report?.contact || '',
|
| 1018 |
+
);
|
| 1019 |
+
} catch (_) { /* not valid JSON β fall through to a raw scan */ }
|
| 1020 |
+
if (!email) email = extractEmail(text);
|
| 1021 |
+
if (email) return email;
|
| 1022 |
+
}
|
| 1023 |
+
} catch (error) {
|
| 1024 |
+
console.warn('[box-service] extractCitizenEmailFromCaseJson failed:', error.message);
|
| 1025 |
+
}
|
| 1026 |
+
return '';
|
| 1027 |
+
}
|
| 1028 |
+
|
| 1029 |
+
// Read the submission sidecar JSON for a case and return the reporter + location data
|
| 1030 |
+
// captured at submission (reporterName, reporterContact, anonymous, address, location).
|
| 1031 |
+
// The metadata template often doesn't carry the reporter name, but the sidecar does.
|
| 1032 |
+
async function getCaseSubmissionData(caseId) {
|
| 1033 |
+
const id = String(caseId || '').trim();
|
| 1034 |
+
if (!id) return null;
|
| 1035 |
+
const boxConfig = await readBoxConfig();
|
| 1036 |
+
const status = computeBoxStatus(boxConfig);
|
| 1037 |
+
if (!status.configured) return null;
|
| 1038 |
+
const folderId = await findCaseFolderId(id, null, boxConfig);
|
| 1039 |
+
if (!folderId) return null;
|
| 1040 |
+
try {
|
| 1041 |
+
const items = await boxRequestJson(
|
| 1042 |
+
`/folders/${folderId}/items?fields=id,name,type&limit=1000`,
|
| 1043 |
+
{ label: `case items ${id}` },
|
| 1044 |
+
boxConfig,
|
| 1045 |
+
);
|
| 1046 |
+
const score = (name) => (/_metadata\.json$/i.test(name) && !/enriched/i.test(name)) ? 3 : (/enriched.*\.json$/i.test(name) ? 2 : 1);
|
| 1047 |
+
const jsonFiles = (items.entries || [])
|
| 1048 |
+
.filter((e) => e.type === 'file' && /\.json$/i.test(e.name))
|
| 1049 |
+
.sort((a, b) => score(b.name) - score(a.name));
|
| 1050 |
+
for (const file of jsonFiles) {
|
| 1051 |
+
let obj;
|
| 1052 |
+
try { obj = JSON.parse((await getFileContent(file.id)).body.toString('utf8')); } catch (_) { continue; }
|
| 1053 |
+
const r = (obj && obj.report) ? obj.report : (obj || {});
|
| 1054 |
+
return {
|
| 1055 |
+
reporterName: normalizeString(r.reporterName || obj.reporterName),
|
| 1056 |
+
reporterContact: normalizeString(r.reporterContact || obj.reporterContact || r.reporterEmail || obj.reporterEmail),
|
| 1057 |
+
anonymous: (r.anonymous !== undefined ? r.anonymous : obj.anonymous),
|
| 1058 |
+
address: normalizeString(r.address || obj.address),
|
| 1059 |
+
location: r.location || obj.location || null,
|
| 1060 |
+
ward: normalizeString(r.ward || obj.ward),
|
| 1061 |
+
};
|
| 1062 |
+
}
|
| 1063 |
+
} catch (error) {
|
| 1064 |
+
console.warn('[box-service] getCaseSubmissionData failed:', error.message);
|
| 1065 |
+
}
|
| 1066 |
+
return null;
|
| 1067 |
+
}
|
| 1068 |
+
|
| 1069 |
+
// Notify a citizen of a status change *via Box*: drop a short status-update note
|
| 1070 |
+
// into the case folder, then collaborate the citizen (viewer) on that note with
|
| 1071 |
+
// notify=true so Box emails them a link β the same Box-native "share the summary"
|
| 1072 |
+
// path used elsewhere, so no separate email service is needed.
|
| 1073 |
+
async function notifyCitizenViaBox({ caseId, caseRecord, status, citizenEmail, summary } = {}) {
|
| 1074 |
+
const id = String(caseId || caseRecord?.caseId || '').trim();
|
| 1075 |
+
if (!id) return { skipped: true, reason: 'missing_case_id' };
|
| 1076 |
+
|
| 1077 |
+
const boxConfig = await readBoxConfig();
|
| 1078 |
+
const st = computeBoxStatus(boxConfig);
|
| 1079 |
+
if (!st.configured) return { skipped: true, reason: 'box_not_configured' };
|
| 1080 |
+
|
| 1081 |
+
const folderId = await findCaseFolderId(id, caseRecord, boxConfig);
|
| 1082 |
+
if (!folderId) return { skipped: true, reason: 'case_folder_not_found' };
|
| 1083 |
+
|
| 1084 |
+
// Extract the citizen email: first from whatever contact string we were handed,
|
| 1085 |
+
// then from the case-folder JSON sidecar (reporterContact lives in the JSON even
|
| 1086 |
+
// when the Box metadata field is blank).
|
| 1087 |
+
let email = extractEmail(citizenEmail || caseRecord?.report?.reporterContact || '');
|
| 1088 |
+
if (!email) email = await extractCitizenEmailFromCaseJson(id, folderId, boxConfig);
|
| 1089 |
+
if (!email) return { skipped: true, reason: 'no_citizen_email' };
|
| 1090 |
+
|
| 1091 |
+
const statusLabel = String(status || caseRecord?.status || 'Updated').trim();
|
| 1092 |
+
const stamp = nowIso().slice(0, 10);
|
| 1093 |
+
const fileName = `Status Update - Case ${id} - ${statusLabel} (${stamp}).txt`;
|
| 1094 |
+
const body = summary || buildCitizenStatusNote(id, statusLabel, caseRecord);
|
| 1095 |
+
|
| 1096 |
+
let upload;
|
| 1097 |
+
try {
|
| 1098 |
+
upload = await uploadBufferToBox({
|
| 1099 |
+
folderId,
|
| 1100 |
+
fileName,
|
| 1101 |
+
buffer: Buffer.from(body, 'utf8'),
|
| 1102 |
+
contentType: 'text/plain; charset=utf-8',
|
| 1103 |
+
boxConfig,
|
| 1104 |
+
});
|
| 1105 |
+
} catch (error) {
|
| 1106 |
+
return { ok: false, emailed: false, reason: 'note_upload_failed', error: error.message };
|
| 1107 |
+
}
|
| 1108 |
+
|
| 1109 |
+
let sharedLink = '';
|
| 1110 |
+
try { sharedLink = await createDirectSharedLink(upload.fileId, boxConfig); } catch (_) {}
|
| 1111 |
+
|
| 1112 |
+
// Add the citizen as a viewer on the note file -> Box emails them the invite/link.
|
| 1113 |
+
try {
|
| 1114 |
+
const collab = await boxRequestJson('/collaborations?notify=true', {
|
| 1115 |
+
method: 'POST',
|
| 1116 |
+
label: `Box notify citizen ${id}`,
|
| 1117 |
+
body: {
|
| 1118 |
+
item: { type: 'file', id: String(upload.fileId) },
|
| 1119 |
+
accessible_by: { type: 'user', login: email },
|
| 1120 |
+
role: 'viewer',
|
| 1121 |
+
},
|
| 1122 |
+
}, boxConfig);
|
| 1123 |
+
return {
|
| 1124 |
+
ok: true,
|
| 1125 |
+
emailed: true,
|
| 1126 |
+
to: email,
|
| 1127 |
+
status: statusLabel,
|
| 1128 |
+
fileId: upload.fileId,
|
| 1129 |
+
sharedLink,
|
| 1130 |
+
collaborationId: collab?.id || '',
|
| 1131 |
+
};
|
| 1132 |
+
} catch (error) {
|
| 1133 |
+
// The note is in Box even if the collaboration email failed (e.g. external
|
| 1134 |
+
// collaboration disabled) β surface that without failing the status update.
|
| 1135 |
+
return { ok: false, emailed: false, to: email, fileId: upload.fileId, sharedLink, error: error.message };
|
| 1136 |
+
}
|
| 1137 |
+
}
|
| 1138 |
+
|
| 1139 |
+
// Give a supervisor/ops recipient access to closeout reports *via Box*, as a ONE-TIME
|
| 1140 |
+
// invite. Rather than collaborating them on each case's report file (which would email
|
| 1141 |
+
// a fresh "invite to collaborate" for every case), we collaborate them once on the
|
| 1142 |
+
// stable case-root folder. Box emails that single invite the first time; on every
|
| 1143 |
+
// later case they are already a collaborator, so no repeat invite is sent β they just
|
| 1144 |
+
// have standing access, and we hand back a direct shared link to the new report.
|
| 1145 |
+
async function notifyCloseoutViaBox({ caseId, caseRecord, reportFileId, recipientEmail } = {}) {
|
| 1146 |
+
const id = String(caseId || caseRecord?.caseId || '').trim();
|
| 1147 |
+
if (!id) return { skipped: true, reason: 'missing_case_id' };
|
| 1148 |
+
|
| 1149 |
+
const email = extractEmail(recipientEmail || '');
|
| 1150 |
+
if (!email) return { skipped: true, reason: 'no_recipient_email' };
|
| 1151 |
+
|
| 1152 |
+
const boxConfig = await readBoxConfig();
|
| 1153 |
+
const st = computeBoxStatus(boxConfig);
|
| 1154 |
+
if (!st.configured) return { skipped: true, reason: 'box_not_configured' };
|
| 1155 |
+
|
| 1156 |
+
// Direct shared link to THIS case's report (handy for the response/UI regardless).
|
| 1157 |
+
const fileId = String(reportFileId || '').trim();
|
| 1158 |
+
let sharedLink = '';
|
| 1159 |
+
if (fileId) { try { sharedLink = await createDirectSharedLink(fileId, boxConfig); } catch (_) {} }
|
| 1160 |
+
|
| 1161 |
+
// Grant standing access to the dedicated "Closeout Reports" folder so the recipient
|
| 1162 |
+
// can watch exactly that one folder for new before/after reports.
|
| 1163 |
+
const rootFolderId = await ensureCloseoutReportsFolder(boxConfig);
|
| 1164 |
+
if (!rootFolderId) {
|
| 1165 |
+
// No stable workspace folder to grant standing access β the report link still works.
|
| 1166 |
+
return { ok: !!sharedLink, emailed: false, to: email, fileId, sharedLink, reason: 'no_root_folder' };
|
| 1167 |
+
}
|
| 1168 |
+
|
| 1169 |
+
// One-time collaboration on the Closeout Reports folder -> Box invites them once.
|
| 1170 |
+
try {
|
| 1171 |
+
const collab = await boxRequestJson('/collaborations?notify=true', {
|
| 1172 |
+
method: 'POST',
|
| 1173 |
+
label: `Box closeout access ${email}`,
|
| 1174 |
+
body: {
|
| 1175 |
+
item: { type: 'folder', id: rootFolderId },
|
| 1176 |
+
accessible_by: { type: 'user', login: email },
|
| 1177 |
+
role: 'viewer',
|
| 1178 |
+
},
|
| 1179 |
+
}, boxConfig);
|
| 1180 |
+
return { ok: true, emailed: true, firstTime: true, to: email, fileId, sharedLink, folderId: rootFolderId, collaborationId: collab?.id || '' };
|
| 1181 |
+
} catch (error) {
|
| 1182 |
+
// Already a collaborator -> they were invited once before. No new invite is sent;
|
| 1183 |
+
// they keep standing access to every report. This is the intended steady state.
|
| 1184 |
+
if (/already a collaborator/i.test(error.message || '')) {
|
| 1185 |
+
return { ok: true, emailed: false, alreadyHasAccess: true, to: email, fileId, sharedLink, folderId: rootFolderId };
|
| 1186 |
+
}
|
| 1187 |
+
// The report is saved in Box even if the collaboration failed (e.g. external
|
| 1188 |
+
// collaboration disabled) β surface that without failing the closeout.
|
| 1189 |
+
return { ok: false, emailed: false, to: email, fileId, sharedLink, error: error.message };
|
| 1190 |
+
}
|
| 1191 |
+
}
|
| 1192 |
+
|
| 1193 |
+
// List all cases straight from Box: walk Potholes/{ward}/Case-* and read each
|
| 1194 |
+
// case folder's potholeCase metadata (incl. severity breakdown) + its photo file id.
|
| 1195 |
+
async function listBoxCases() {
|
| 1196 |
+
const boxConfig = await readBoxConfig();
|
| 1197 |
+
const status = computeBoxStatus(boxConfig);
|
| 1198 |
+
if (!status.configured) return [];
|
| 1199 |
+
const rootId = status.caseRootFolderId || status.intakeFolderId;
|
| 1200 |
+
|
| 1201 |
+
const listItems = (folderId, label) =>
|
| 1202 |
+
boxRequestJson(`/folders/${folderId}/items?fields=id,name,type&limit=1000`, { label }, boxConfig)
|
| 1203 |
+
.then((r) => r.entries || [])
|
| 1204 |
+
.catch(() => []);
|
| 1205 |
+
|
| 1206 |
+
const rootItems = await listItems(rootId, 'list root');
|
| 1207 |
+
const potholes = rootItems.find((e) => e.type === 'folder' && e.name === 'Potholes');
|
| 1208 |
+
if (!potholes) return [];
|
| 1209 |
+
|
| 1210 |
+
const wards = (await listItems(potholes.id, 'list wards')).filter((e) => e.type === 'folder');
|
| 1211 |
+
|
| 1212 |
+
// List all wards' case folders in parallel, then fetch each case's metadata +
|
| 1213 |
+
// items through a bounded worker pool. Sequential calls took 100s+ with ~40
|
| 1214 |
+
// cases, which times out behind hosted proxies (e.g. HuggingFace Spaces).
|
| 1215 |
+
const wardFolderLists = await Promise.all(
|
| 1216 |
+
wards.map(async (ward) => (await listItems(ward.id, 'list cases'))
|
| 1217 |
+
.filter((e) => e.type === 'folder')
|
| 1218 |
+
.map((cf) => ({ ward, cf })))
|
| 1219 |
+
);
|
| 1220 |
+
const caseFolders = wardFolderLists.flat();
|
| 1221 |
+
|
| 1222 |
+
const num = (v) => (v === undefined || v === null || v === '' ? null : Number(v));
|
| 1223 |
+
const cases = [];
|
| 1224 |
+
let cursor = 0;
|
| 1225 |
+
const CONCURRENCY = 8;
|
| 1226 |
+
async function worker() {
|
| 1227 |
+
while (cursor < caseFolders.length) {
|
| 1228 |
+
const { ward, cf } = caseFolders[cursor++];
|
| 1229 |
+
let md;
|
| 1230 |
+
let items;
|
| 1231 |
+
try {
|
| 1232 |
+
[md, items] = await Promise.all([
|
| 1233 |
+
boxRequestJson(`/folders/${cf.id}/metadata/enterprise/potholeCase`, { label: 'case md' }, boxConfig),
|
| 1234 |
+
listItems(cf.id, 'case items'),
|
| 1235 |
+
]);
|
| 1236 |
+
} catch (e) { continue; } // no potholeCase metadata -> not a case record
|
| 1237 |
+
const photo = items.find((e) => e.type === 'file' && /\.(jpe?g|png)$/i.test(e.name));
|
| 1238 |
+
cases.push({
|
| 1239 |
+
folderId: cf.id, folderName: cf.name, ward: ward.name,
|
| 1240 |
+
photoFileId: photo ? photo.id : '',
|
| 1241 |
+
caseId: md.caseId || cf.name.replace(/^Case-/, ''),
|
| 1242 |
+
status: md.status || '', severityLevel: md.severityLevel || '',
|
| 1243 |
+
potholeSize: md.potholeSize || '',
|
| 1244 |
+
district: md.district || '', maintenanceZone: md.maintenanceZone || '',
|
| 1245 |
+
aiReviewStatus: md.aiReviewStatus || '', reporterContact: md.reporterContact || '',
|
| 1246 |
+
severityScore: num(md.severityScore0100), address: md.address || '',
|
| 1247 |
+
submittedAt: md.submittedAt || '', resolvedAt: md.resolvedAt || '',
|
| 1248 |
+
duplicateStatus: (md.duplicateStatus || '').trim(),
|
| 1249 |
+
duplicateCount: num(md.duplicateCount) || 0, linkedCaseIds: md.linkedCaseIds || '',
|
| 1250 |
+
latitude: num(md.latitude), longitude: num(md.longitude),
|
| 1251 |
+
weatherRisk: md.weatherRisk || '', aiSummary: md.aiSummary || '',
|
| 1252 |
+
breakdown: {
|
| 1253 |
+
priorNotice: { score: num(md.priorNoticeScore), max: 30, detail: md.priorNoticeDetail || '' },
|
| 1254 |
+
traffic: { score: num(md.trafficScore), max: 25, detail: md.trafficDetail || '' },
|
| 1255 |
+
damage: { score: num(md.damageScore), max: 25, detail: md.damageDetail || '' },
|
| 1256 |
+
weather: { score: num(md.weatherScore), max: 10, detail: md.weatherDetail || '' },
|
| 1257 |
+
},
|
| 1258 |
+
});
|
| 1259 |
+
}
|
| 1260 |
+
}
|
| 1261 |
+
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, caseFolders.length || 1) }, worker));
|
| 1262 |
+
return cases;
|
| 1263 |
+
}
|
| 1264 |
+
|
| 1265 |
+
async function fetchOfficialBoundaries() {
|
| 1266 |
+
const response = await fetchWithRetry(DEFAULT_GIS_BOUNDARY_URL, {
|
| 1267 |
+
method: 'GET',
|
| 1268 |
+
headers: {
|
| 1269 |
+
Accept: 'application/geo+json, application/json;q=0.9, */*;q=0.1',
|
| 1270 |
+
},
|
| 1271 |
+
}, { label: 'Official GIS boundary fetch' });
|
| 1272 |
+
|
| 1273 |
+
if (!response.ok) {
|
| 1274 |
+
throw new Error(`Official GIS boundary fetch failed (${response.status}): ${await readErrorMessage(response)}`);
|
| 1275 |
+
}
|
| 1276 |
+
|
| 1277 |
+
return {
|
| 1278 |
+
contentType: response.headers.get('content-type') || 'application/json; charset=utf-8',
|
| 1279 |
+
body: await response.text(),
|
| 1280 |
+
};
|
| 1281 |
+
}
|
| 1282 |
+
|
| 1283 |
+
return {
|
| 1284 |
+
DEFAULT_GIS_BOUNDARY_URL,
|
| 1285 |
+
fetchWithRetry,
|
| 1286 |
+
stripPhotoDataUrl,
|
| 1287 |
+
getClientConfig,
|
| 1288 |
+
getStatus,
|
| 1289 |
+
saveConfig,
|
| 1290 |
+
resetConfig,
|
| 1291 |
+
testConnection,
|
| 1292 |
+
uploadPhoto,
|
| 1293 |
+
uploadSidecar,
|
| 1294 |
+
generateAndUploadAuditReport,
|
| 1295 |
+
markCaseResolvedInBox,
|
| 1296 |
+
setCaseStatusInBox,
|
| 1297 |
+
notifyCitizenViaBox,
|
| 1298 |
+
notifyCloseoutViaBox,
|
| 1299 |
+
getCaseSubmissionData,
|
| 1300 |
+
uploadCloseoutReport,
|
| 1301 |
+
createSignRequest,
|
| 1302 |
+
uploadCaseArtifact,
|
| 1303 |
+
uploadGeneratedDoc,
|
| 1304 |
+
organizeCase,
|
| 1305 |
+
getFileContent,
|
| 1306 |
+
searchCase,
|
| 1307 |
+
listBoxCases,
|
| 1308 |
+
fetchOfficialBoundaries,
|
| 1309 |
+
buildSubmissionSidecar,
|
| 1310 |
+
};
|
| 1311 |
+
}
|
| 1312 |
+
|
| 1313 |
+
module.exports = {
|
| 1314 |
+
createBoxService,
|
| 1315 |
+
fetchWithRetry,
|
| 1316 |
+
stripPhotoDataUrl,
|
| 1317 |
+
DEFAULT_GIS_BOUNDARY_URL,
|
| 1318 |
+
};
|
box-upload.js
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use strict';
|
| 2 |
+
|
| 3 |
+
const API_PREFIX = '/api';
|
| 4 |
+
|
| 5 |
+
let __boxStatusCache = null;
|
| 6 |
+
let __boxStatusFetchedAt = 0;
|
| 7 |
+
|
| 8 |
+
function stripPhotoDataUrl(value) {
|
| 9 |
+
if (Array.isArray(value)) {
|
| 10 |
+
return value.map((item) => stripPhotoDataUrl(item));
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
if (!value || typeof value !== 'object') {
|
| 14 |
+
return value;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
const clone = {};
|
| 18 |
+
for (const [key, child] of Object.entries(value)) {
|
| 19 |
+
if (key === 'photoDataUrl') continue;
|
| 20 |
+
clone[key] = stripPhotoDataUrl(child);
|
| 21 |
+
}
|
| 22 |
+
return clone;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
async function parseApiResponse(response) {
|
| 26 |
+
let payload = null;
|
| 27 |
+
try {
|
| 28 |
+
payload = await response.json();
|
| 29 |
+
} catch {
|
| 30 |
+
payload = null;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
if (!response.ok) {
|
| 34 |
+
throw new Error(payload?.error || payload?.message || `Request failed with HTTP ${response.status}`);
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
return payload;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
async function apiRequest(path, options = {}) {
|
| 41 |
+
const response = await fetch(`${API_PREFIX}${path}`, {
|
| 42 |
+
method: options.method || 'GET',
|
| 43 |
+
headers: {
|
| 44 |
+
...(options.rawBody ? {} : { 'Content-Type': 'application/json' }),
|
| 45 |
+
...(options.headers || {}),
|
| 46 |
+
},
|
| 47 |
+
...(options.body !== undefined ? { body: options.body } : {}),
|
| 48 |
+
});
|
| 49 |
+
|
| 50 |
+
return parseApiResponse(response);
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
async function getBoxStatus(options = {}) {
|
| 54 |
+
const { force = false } = options;
|
| 55 |
+
if (__boxStatusCache && !force && (Date.now() - __boxStatusFetchedAt) < 10000) {
|
| 56 |
+
return __boxStatusCache;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
const payload = await apiRequest('/box/status', { method: 'GET' });
|
| 60 |
+
__boxStatusCache = payload?.status || {};
|
| 61 |
+
__boxStatusFetchedAt = Date.now();
|
| 62 |
+
return __boxStatusCache;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
function buildMissingConfigMessage(status = {}) {
|
| 66 |
+
if (!status.hasAuth) {
|
| 67 |
+
return 'Box authentication is not configured on the backend. Open settings.html and save the Box integration first.';
|
| 68 |
+
}
|
| 69 |
+
if (!status.hasIntakeFolderId) {
|
| 70 |
+
return 'Incoming intake folder ID is not configured. Open settings.html and save the Box integration first.';
|
| 71 |
+
}
|
| 72 |
+
return 'Box is not configured on the backend. Open settings.html and save the Box integration first.';
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
async function testBoxConnection() {
|
| 76 |
+
try {
|
| 77 |
+
const payload = await apiRequest('/box/test-connection', {
|
| 78 |
+
method: 'POST',
|
| 79 |
+
body: JSON.stringify({}),
|
| 80 |
+
});
|
| 81 |
+
__boxStatusCache = null;
|
| 82 |
+
__boxStatusFetchedAt = 0;
|
| 83 |
+
return payload;
|
| 84 |
+
} catch (error) {
|
| 85 |
+
return { ok: false, error: error.message };
|
| 86 |
+
}
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
async function uploadPhoto(file, caseId) {
|
| 90 |
+
if (!file) {
|
| 91 |
+
throw new Error('Photo file is required.');
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
const status = await getBoxStatus();
|
| 95 |
+
if (!status.configured) {
|
| 96 |
+
throw new Error(buildMissingConfigMessage(status));
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
const payload = await apiRequest(`/box/upload-photo?caseId=${encodeURIComponent(caseId)}`, {
|
| 100 |
+
method: 'POST',
|
| 101 |
+
rawBody: true,
|
| 102 |
+
headers: {
|
| 103 |
+
'Content-Type': file.type || 'application/octet-stream',
|
| 104 |
+
},
|
| 105 |
+
body: file,
|
| 106 |
+
});
|
| 107 |
+
|
| 108 |
+
return {
|
| 109 |
+
fileId: payload.fileId || '',
|
| 110 |
+
sharedLink: payload.sharedLink || null,
|
| 111 |
+
photoUrl: payload.photoUrl || '',
|
| 112 |
+
};
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
async function uploadSidecar(metadata, caseId) {
|
| 116 |
+
const status = await getBoxStatus();
|
| 117 |
+
if (!status.configured) {
|
| 118 |
+
throw new Error(buildMissingConfigMessage(status));
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
const payload = await apiRequest('/box/upload-sidecar', {
|
| 122 |
+
method: 'POST',
|
| 123 |
+
body: JSON.stringify({
|
| 124 |
+
caseId,
|
| 125 |
+
metadata: stripPhotoDataUrl(metadata || {}),
|
| 126 |
+
}),
|
| 127 |
+
});
|
| 128 |
+
|
| 129 |
+
return {
|
| 130 |
+
fileId: payload.fileId || '',
|
| 131 |
+
sidecarPayload: payload.sidecarPayload || null,
|
| 132 |
+
};
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
async function submitToBox(photoFile, metadata) {
|
| 136 |
+
const providedCaseId = metadata && typeof metadata.caseId === 'string'
|
| 137 |
+
? metadata.caseId.trim()
|
| 138 |
+
: '';
|
| 139 |
+
const randomSuffix = Math.random()
|
| 140 |
+
.toString(36)
|
| 141 |
+
.toUpperCase()
|
| 142 |
+
.replace(/[^A-Z]/g, '')
|
| 143 |
+
.slice(0, 4)
|
| 144 |
+
.padEnd(4, 'X');
|
| 145 |
+
|
| 146 |
+
const caseId = providedCaseId || `PHX-${Date.now()}-${randomSuffix}`;
|
| 147 |
+
const submittedAt = metadata?.submittedAt || new Date().toISOString();
|
| 148 |
+
const sanitizedMetadata = stripPhotoDataUrl({
|
| 149 |
+
...metadata,
|
| 150 |
+
caseId,
|
| 151 |
+
submittedAt,
|
| 152 |
+
});
|
| 153 |
+
|
| 154 |
+
try {
|
| 155 |
+
const photoResult = await uploadPhoto(photoFile, caseId);
|
| 156 |
+
const sidecarResult = await uploadSidecar({
|
| 157 |
+
...sanitizedMetadata,
|
| 158 |
+
photoFileId: photoResult.fileId || '',
|
| 159 |
+
photoUrl: photoResult.photoUrl || '',
|
| 160 |
+
}, caseId);
|
| 161 |
+
|
| 162 |
+
return {
|
| 163 |
+
success: true,
|
| 164 |
+
caseId,
|
| 165 |
+
photoFileId: photoResult.fileId || '',
|
| 166 |
+
sidecarFileId: sidecarResult.fileId || '',
|
| 167 |
+
sidecarPayload: sidecarResult.sidecarPayload || sanitizedMetadata,
|
| 168 |
+
};
|
| 169 |
+
} catch (error) {
|
| 170 |
+
return {
|
| 171 |
+
success: false,
|
| 172 |
+
error: error instanceof Error ? error.message : String(error),
|
| 173 |
+
};
|
| 174 |
+
}
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
if (typeof module !== 'undefined' && module.exports) {
|
| 178 |
+
module.exports = {
|
| 179 |
+
getBoxStatus,
|
| 180 |
+
testBoxConnection,
|
| 181 |
+
uploadPhoto,
|
| 182 |
+
uploadSidecar,
|
| 183 |
+
submitToBox,
|
| 184 |
+
stripPhotoDataUrl,
|
| 185 |
+
};
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
if (typeof window !== 'undefined') {
|
| 189 |
+
window.getBoxStatus = getBoxStatus;
|
| 190 |
+
window.testBoxConnection = testBoxConnection;
|
| 191 |
+
window.submitToBox = submitToBox;
|
| 192 |
+
window.BoxUpload = {
|
| 193 |
+
getBoxStatus,
|
| 194 |
+
testBoxConnection,
|
| 195 |
+
uploadPhoto,
|
| 196 |
+
uploadSidecar,
|
| 197 |
+
submitToBox,
|
| 198 |
+
stripPhotoDataUrl,
|
| 199 |
+
};
|
| 200 |
+
}
|
box-workflow.js
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use strict';
|
| 2 |
+
|
| 3 |
+
const BOX_WORKFLOW_TEMPLATE = 'pothole_box_ops_v1';
|
| 4 |
+
|
| 5 |
+
function workflowPriorityFromSeverity(severity) {
|
| 6 |
+
const normalized = String(severity || '').toLowerCase();
|
| 7 |
+
if (normalized === 'critical') return 'urgent';
|
| 8 |
+
if (normalized === 'high' || normalized === 'large') return 'high';
|
| 9 |
+
if (normalized === 'medium') return 'medium';
|
| 10 |
+
return 'normal';
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
function buildInitialWorkflow(report = {}, boxConfig = {}) {
|
| 14 |
+
const aiStatus = String(report.aiReviewStatus || '').toLowerCase();
|
| 15 |
+
const aiResult = String(report.aiResult || report.aiPrediction || '').toLowerCase();
|
| 16 |
+
const needsManualReview = aiStatus.includes('manual') || aiStatus.includes('face');
|
| 17 |
+
const needsAttention = aiStatus === 'invalid_image';
|
| 18 |
+
|
| 19 |
+
return {
|
| 20 |
+
template: BOX_WORKFLOW_TEMPLATE,
|
| 21 |
+
currentStage: 'incoming_detection',
|
| 22 |
+
currentStageLabel: 'Incoming Detection',
|
| 23 |
+
status: needsManualReview || needsAttention ? 'Under Review' : 'Submitted',
|
| 24 |
+
queue: needsManualReview || needsAttention ? 'Manual Review' : 'Ops Intake',
|
| 25 |
+
nextAction: needsAttention
|
| 26 |
+
? 'Supervisor must validate the upload before enrichment'
|
| 27 |
+
: needsManualReview
|
| 28 |
+
? 'Supervisor must verify the image before enrichment'
|
| 29 |
+
: 'Run enrichment and create the case folder',
|
| 30 |
+
priority: workflowPriorityFromSeverity(aiResult),
|
| 31 |
+
needsManualReview,
|
| 32 |
+
triggerFolderId: boxConfig.folderId || '',
|
| 33 |
+
triggerFolderName: boxConfig.folderName || 'Incoming Detections',
|
| 34 |
+
submittedAt: report.submittedAt || new Date().toISOString(),
|
| 35 |
+
lastUpdatedAt: report.submittedAt || new Date().toISOString(),
|
| 36 |
+
};
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
function buildEnrichedWorkflow(report = {}, enrichment = {}, context = {}) {
|
| 40 |
+
const severityLevel = String(enrichment.severityLevel || '').trim() || 'Pending';
|
| 41 |
+
const duplicateCount = enrichment.duplicate?.duplicateCount || 0;
|
| 42 |
+
const linkedCases = Array.isArray(enrichment.duplicate?.linkedCaseIds)
|
| 43 |
+
? enrichment.duplicate.linkedCaseIds
|
| 44 |
+
: [];
|
| 45 |
+
const urgent = severityLevel.toLowerCase() === 'critical';
|
| 46 |
+
|
| 47 |
+
return {
|
| 48 |
+
template: BOX_WORKFLOW_TEMPLATE,
|
| 49 |
+
currentStage: 'supervisor_review',
|
| 50 |
+
currentStageLabel: 'Supervisor Review',
|
| 51 |
+
status: 'Under Review',
|
| 52 |
+
queue: urgent ? 'Emergency Dispatch' : 'Supervisor Review',
|
| 53 |
+
nextAction: urgent
|
| 54 |
+
? 'Assign an emergency crew and dispatch immediately'
|
| 55 |
+
: duplicateCount > 0
|
| 56 |
+
? 'Review duplicate cluster and confirm routing'
|
| 57 |
+
: 'Supervisor approve the case and send it to crew routing',
|
| 58 |
+
priority: workflowPriorityFromSeverity(severityLevel),
|
| 59 |
+
needsManualReview: String(report.aiReviewStatus || '').toLowerCase().includes('manual'),
|
| 60 |
+
severityLevel,
|
| 61 |
+
severityScore: enrichment.severityScore ?? '',
|
| 62 |
+
duplicateCount,
|
| 63 |
+
linkedCases: linkedCases.join(', '),
|
| 64 |
+
district: enrichment.district?.district || report.district || '',
|
| 65 |
+
ward: enrichment.district?.ward || report.ward || '',
|
| 66 |
+
maintenanceZone: enrichment.district?.maintenanceZone || report.maintenanceZone || '',
|
| 67 |
+
boxPath: context.folderPath || '',
|
| 68 |
+
rootFolderId: context.rootFolderId || '',
|
| 69 |
+
lastUpdatedAt: enrichment.enrichedAt || new Date().toISOString(),
|
| 70 |
+
};
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
function flattenWorkflowForBox(workflow = {}) {
|
| 74 |
+
return {
|
| 75 |
+
workflowTemplate: workflow.template || BOX_WORKFLOW_TEMPLATE,
|
| 76 |
+
workflowStage: workflow.currentStage || '',
|
| 77 |
+
workflowStageLabel: workflow.currentStageLabel || '',
|
| 78 |
+
workflowStatus: workflow.status || '',
|
| 79 |
+
workflowQueue: workflow.queue || '',
|
| 80 |
+
workflowNextAction: workflow.nextAction || '',
|
| 81 |
+
workflowPriority: workflow.priority || '',
|
| 82 |
+
workflowNeedsManualReview: workflow.needsManualReview ? 'true' : 'false',
|
| 83 |
+
workflowSeverityLevel: workflow.severityLevel || '',
|
| 84 |
+
workflowSeverityScore: workflow.severityScore ?? '',
|
| 85 |
+
workflowDuplicateCount: workflow.duplicateCount ?? '',
|
| 86 |
+
workflowLinkedCases: workflow.linkedCases || '',
|
| 87 |
+
workflowWard: workflow.ward || '',
|
| 88 |
+
workflowDistrict: workflow.district || '',
|
| 89 |
+
workflowMaintenanceZone: workflow.maintenanceZone || '',
|
| 90 |
+
workflowBoxPath: workflow.boxPath || '',
|
| 91 |
+
workflowLastUpdatedAt: workflow.lastUpdatedAt || '',
|
| 92 |
+
workflowTriggerFolderId: workflow.triggerFolderId || workflow.rootFolderId || '',
|
| 93 |
+
workflowTriggerFolderName: workflow.triggerFolderName || '',
|
| 94 |
+
};
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
function getWorkflowInputFields() {
|
| 98 |
+
return [
|
| 99 |
+
{ key: 'caseId', source: 'Citizen upload', purpose: 'Primary record key used across Box, ops review, and tracking.' },
|
| 100 |
+
{ key: 'submittedAt', source: 'Citizen upload', purpose: 'Timestamp for intake SLAs and queue sorting.' },
|
| 101 |
+
{ key: 'photoFileId', source: 'Box upload', purpose: 'Original pothole photo file to preview or move into the case folder.' },
|
| 102 |
+
{ key: 'sidecarFileId', source: 'Box upload', purpose: 'Original JSON sidecar used as the workflow payload.' },
|
| 103 |
+
{ key: 'address', source: 'Citizen upload', purpose: 'Road location shown to reviewers and crew dispatch.' },
|
| 104 |
+
{ key: 'areaLabel', source: 'Reverse geocode', purpose: 'Human-friendly locality label shown before official ward matching succeeds.' },
|
| 105 |
+
{ key: 'ward', source: 'GIS or reverse geocode', purpose: 'Routes the case into the correct Box subfolder and maintenance area.' },
|
| 106 |
+
{ key: 'district', source: 'GIS mapping', purpose: 'Maps the case to the review or engineering district.' },
|
| 107 |
+
{ key: 'maintenanceZone', source: 'GIS mapping', purpose: 'Directs dispatch to the correct crew zone.' },
|
| 108 |
+
{ key: 'duplicateStatus', source: 'Ops enrichment', purpose: 'Simple duplicate/new label used by external workflow decisions.' },
|
| 109 |
+
{ key: 'weatherStatus', source: 'Ops enrichment', purpose: 'Weather risk status stored with the case for triage and reporting.' },
|
| 110 |
+
{ key: 'potholeSize', source: 'Citizen upload AI', purpose: 'Human-readable pothole size used before and after enrichment.' },
|
| 111 |
+
{ key: 'aiReviewStatus', source: 'Citizen upload AI', purpose: 'Determines whether the case should enter manual review first.' },
|
| 112 |
+
{ key: 'aiResult', source: 'Citizen upload AI', purpose: 'Initial pothole size signal before enrichment recalculates severity.' },
|
| 113 |
+
{ key: 'severityScore', source: 'Ops enrichment', purpose: 'Numeric urgency score for routing, triage, and reporting.' },
|
| 114 |
+
{ key: 'severityLevel', source: 'Ops enrichment', purpose: 'Human-readable priority label for Box tasks and dashboards.' },
|
| 115 |
+
{ key: 'isDuplicate', source: 'Ops enrichment', purpose: 'Flags repeat reports so teams can merge or escalate clusters.' },
|
| 116 |
+
{ key: 'workflowStage', source: 'Workflow metadata', purpose: 'Stable machine field for the current lifecycle stage.' },
|
| 117 |
+
{ key: 'workflowNextAction', source: 'Workflow metadata', purpose: 'Plain-language instruction shown to the next owner.' },
|
| 118 |
+
];
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
function getWorkflowStages() {
|
| 122 |
+
return [
|
| 123 |
+
{
|
| 124 |
+
id: 'incoming_detection',
|
| 125 |
+
title: '1. Citizen intake and Box file storage',
|
| 126 |
+
use: 'The portal uploads the photo and JSON sidecar to Box, then upserts the same case into the backend queue.',
|
| 127 |
+
input: 'Folder ID from Settings, caseId, submittedAt, address, location, aiReviewStatus, potholeSize',
|
| 128 |
+
},
|
| 129 |
+
{
|
| 130 |
+
id: 'supervisor_review',
|
| 131 |
+
title: '2. Database-owned enrichment and review',
|
| 132 |
+
use: 'Ops Review and backend services calculate GIS, duplicates, weather, and severity, then save enriched artifacts back into Box.',
|
| 133 |
+
input: 'photoFileId, sidecarFileId, ward, district, maintenanceZone, duplicateStatus, weatherStatus, severityScore',
|
| 134 |
+
},
|
| 135 |
+
{
|
| 136 |
+
id: 'crew_dispatch',
|
| 137 |
+
title: '3. Database dispatch with Box-backed artifacts',
|
| 138 |
+
use: 'The backend records supervisor approval, authority routing, and crew dispatch while Box stores the generated notices and assignment files.',
|
| 139 |
+
input: 'severityLevel, duplicateStatus, workflowPriority, workflowStatus, maintenanceZone, assignedCrew',
|
| 140 |
+
},
|
| 141 |
+
{
|
| 142 |
+
id: 'resolved',
|
| 143 |
+
title: '4. Database closeout with Box proof storage',
|
| 144 |
+
use: 'Crew forms, after photos, reports, and audit outputs are saved by the backend and written back into Box for shared recordkeeping.',
|
| 145 |
+
input: 'resolvedAt, resolutionNotes, afterPhotoFileId, laborHours, materialsUsed, workflowStatus',
|
| 146 |
+
},
|
| 147 |
+
];
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
function buildWorkflowBlueprint(boxConfig = {}) {
|
| 151 |
+
return {
|
| 152 |
+
template: BOX_WORKFLOW_TEMPLATE,
|
| 153 |
+
triggerFolderId: boxConfig.folderId || '',
|
| 154 |
+
triggerFolderName: boxConfig.folderName || 'Incoming Detections',
|
| 155 |
+
automationUses: [
|
| 156 |
+
'Box as the content repository for intake, case folders, proof files, and reports',
|
| 157 |
+
'JSON sidecar payload uploaded by the citizen portal',
|
| 158 |
+
'Enriched metadata and readable summaries written back after backend processing',
|
| 159 |
+
'Backend and app screens own approvals, dispatch, authority notifications, and closeout',
|
| 160 |
+
],
|
| 161 |
+
inputFields: getWorkflowInputFields(),
|
| 162 |
+
stages: getWorkflowStages(),
|
| 163 |
+
metadataKeys: Object.keys(flattenWorkflowForBox({})),
|
| 164 |
+
};
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
if (typeof module !== 'undefined' && module.exports) {
|
| 168 |
+
module.exports = {
|
| 169 |
+
BOX_WORKFLOW_TEMPLATE,
|
| 170 |
+
buildInitialWorkflow,
|
| 171 |
+
buildEnrichedWorkflow,
|
| 172 |
+
flattenWorkflowForBox,
|
| 173 |
+
getWorkflowInputFields,
|
| 174 |
+
getWorkflowStages,
|
| 175 |
+
buildWorkflowBlueprint,
|
| 176 |
+
};
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
if (typeof window !== 'undefined') {
|
| 180 |
+
window.BoxWorkflow = {
|
| 181 |
+
BOX_WORKFLOW_TEMPLATE,
|
| 182 |
+
buildInitialWorkflow,
|
| 183 |
+
buildEnrichedWorkflow,
|
| 184 |
+
flattenWorkflowForBox,
|
| 185 |
+
getWorkflowInputFields,
|
| 186 |
+
getWorkflowStages,
|
| 187 |
+
buildWorkflowBlueprint,
|
| 188 |
+
};
|
| 189 |
+
}
|
case-report.html
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>Before / After Report β PotholeIQ</title>
|
| 7 |
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 8 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
| 9 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet" />
|
| 10 |
+
<style>
|
| 11 |
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
| 12 |
+
:root {
|
| 13 |
+
--ink-950:#0c1626; --ink-700:#344256; --ink-500:#64748b; --ink-400:#8a97a8;
|
| 14 |
+
--border:#e2e8f0; --brand:#0061d5; --mint:#0e9f6e; --amber:#b45309; --red:#dc2626;
|
| 15 |
+
}
|
| 16 |
+
body { font-family:'Inter',system-ui,sans-serif; background:#eef2f7; color:var(--ink-950); -webkit-font-smoothing:antialiased; }
|
| 17 |
+
|
| 18 |
+
/* Toolbar (not printed) */
|
| 19 |
+
.toolbar {
|
| 20 |
+
position:sticky; top:0; z-index:10;
|
| 21 |
+
display:flex; align-items:center; justify-content:space-between; gap:16px;
|
| 22 |
+
padding:14px 24px; background:rgba(12,22,38,0.96); color:#fff;
|
| 23 |
+
backdrop-filter:blur(10px);
|
| 24 |
+
}
|
| 25 |
+
.toolbar__brand { display:flex; align-items:center; gap:10px; font-weight:800; font-size:14px; }
|
| 26 |
+
.toolbar__brand-icon { width:30px; height:30px; border-radius:8px; background:linear-gradient(135deg,#0b66d6,#083f8c); display:flex; align-items:center; justify-content:center; font-weight:900; }
|
| 27 |
+
.toolbar__actions { display:flex; gap:10px; }
|
| 28 |
+
.tbtn { padding:9px 16px; border-radius:9px; font:inherit; font-size:13px; font-weight:700; cursor:pointer; border:1px solid rgba(255,255,255,0.18); background:rgba(255,255,255,0.08); color:#fff; text-decoration:none; transition:background .15s ease; }
|
| 29 |
+
.tbtn:hover { background:rgba(255,255,255,0.16); }
|
| 30 |
+
.tbtn.primary { background:linear-gradient(135deg,#1273e6,#0a55b4); border-color:transparent; }
|
| 31 |
+
|
| 32 |
+
.page { max-width:920px; margin:24px auto 60px; padding:0 20px; }
|
| 33 |
+
.sheet { background:#fff; border:1px solid var(--border); border-radius:16px; box-shadow:0 20px 50px rgba(15,23,42,0.10); overflow:hidden; }
|
| 34 |
+
|
| 35 |
+
.rhead { padding:28px 32px; border-bottom:1px solid var(--border); background:linear-gradient(135deg,#f8fbff,#eef4fb); }
|
| 36 |
+
.rhead__eyebrow { font-size:11px; font-weight:800; letter-spacing:0.12em; text-transform:uppercase; color:var(--brand); margin-bottom:6px; }
|
| 37 |
+
.rhead__title { font-size:26px; font-weight:900; letter-spacing:-0.03em; line-height:1.1; }
|
| 38 |
+
.rhead__meta { margin-top:8px; font-size:13px; color:var(--ink-500); display:flex; flex-wrap:wrap; gap:14px; }
|
| 39 |
+
.rhead__meta strong { color:var(--ink-700); }
|
| 40 |
+
.status-pill { display:inline-flex; align-items:center; gap:6px; padding:5px 12px; border-radius:999px; font-size:12px; font-weight:800; }
|
| 41 |
+
|
| 42 |
+
.summary { display:grid; grid-template-columns:repeat(4,1fr); gap:1px; background:var(--border); border-bottom:1px solid var(--border); }
|
| 43 |
+
.summary__cell { background:#fff; padding:16px 20px; }
|
| 44 |
+
.summary__label { font-size:10px; font-weight:800; text-transform:uppercase; letter-spacing:0.08em; color:var(--ink-400); margin-bottom:5px; }
|
| 45 |
+
.summary__value { font-size:20px; font-weight:800; letter-spacing:-0.02em; }
|
| 46 |
+
|
| 47 |
+
.section { padding:24px 32px; border-bottom:1px solid var(--border); }
|
| 48 |
+
.section__label { font-size:11px; font-weight:800; text-transform:uppercase; letter-spacing:0.1em; color:var(--ink-400); margin-bottom:14px; display:flex; align-items:center; gap:10px; }
|
| 49 |
+
.section__label::after { content:''; flex:1; height:1px; background:var(--border); }
|
| 50 |
+
|
| 51 |
+
.ba-grid { display:grid; grid-template-columns:1fr 1fr; gap:18px; }
|
| 52 |
+
.ba-col__tag { font-size:11px; font-weight:800; text-transform:uppercase; letter-spacing:0.08em; margin-bottom:8px; }
|
| 53 |
+
.ba-col__tag.before { color:var(--amber); }
|
| 54 |
+
.ba-col__tag.after { color:var(--mint); }
|
| 55 |
+
.ba-photo { width:100%; height:230px; border-radius:12px; object-fit:cover; border:1px solid var(--border); background:#f1f5f9; display:block; }
|
| 56 |
+
.ba-photo__ph { width:100%; height:230px; border-radius:12px; border:1.5px dashed var(--border); background:linear-gradient(135deg,#f8fafc,#eef2f7); display:flex; align-items:center; justify-content:center; text-align:center; color:var(--ink-400); font-size:12px; font-weight:600; padding:16px; }
|
| 57 |
+
|
| 58 |
+
.kv { display:grid; grid-template-columns:1fr 1fr; gap:10px; }
|
| 59 |
+
.kv__item { border:1px solid var(--border); border-radius:11px; padding:11px 13px; background:#fbfdff; }
|
| 60 |
+
.kv__label { font-size:10px; font-weight:800; text-transform:uppercase; letter-spacing:0.07em; color:var(--ink-400); margin-bottom:4px; }
|
| 61 |
+
.kv__value { font-size:13px; font-weight:700; color:var(--ink-950); line-height:1.4; word-break:break-word; }
|
| 62 |
+
|
| 63 |
+
.cost-table { width:100%; border-collapse:collapse; font-size:13px; }
|
| 64 |
+
.cost-table td { padding:11px 14px; border-bottom:1px solid var(--border); }
|
| 65 |
+
.cost-table td:last-child { text-align:right; font-variant-numeric:tabular-nums; font-weight:700; white-space:nowrap; }
|
| 66 |
+
.cost-table tr.total td { border-top:2px solid var(--ink-950); border-bottom:none; font-weight:900; font-size:15px; }
|
| 67 |
+
.cost-table tr.total td:last-child { color:var(--amber); }
|
| 68 |
+
.cost-table .muted { color:var(--ink-500); font-weight:600; }
|
| 69 |
+
.variance-pos { color:var(--red); } .variance-neg { color:var(--mint); }
|
| 70 |
+
|
| 71 |
+
.notes { font-size:13.5px; line-height:1.7; color:var(--ink-700); background:#f8fafc; border:1px solid var(--border); border-radius:12px; padding:16px 18px; white-space:pre-wrap; }
|
| 72 |
+
|
| 73 |
+
.sign { display:grid; grid-template-columns:1fr 1fr; gap:24px; padding:24px 32px; }
|
| 74 |
+
.sign__line { border-top:1.5px solid var(--ink-700); padding-top:8px; font-size:12px; color:var(--ink-500); }
|
| 75 |
+
.sign__line strong { display:block; color:var(--ink-950); font-size:13px; }
|
| 76 |
+
|
| 77 |
+
.rfoot { padding:16px 32px; font-size:11px; color:var(--ink-400); text-align:center; }
|
| 78 |
+
.loading { padding:80px 20px; text-align:center; color:var(--ink-500); font-weight:600; }
|
| 79 |
+
|
| 80 |
+
@media (max-width:680px){ .summary{grid-template-columns:1fr 1fr;} .ba-grid,.kv,.sign{grid-template-columns:1fr;} }
|
| 81 |
+
|
| 82 |
+
@media print {
|
| 83 |
+
body { background:#fff; }
|
| 84 |
+
.toolbar { display:none; }
|
| 85 |
+
.page { margin:0; max-width:none; padding:0; }
|
| 86 |
+
.sheet { border:none; border-radius:0; box-shadow:none; }
|
| 87 |
+
.section, .summary, .rhead { break-inside:avoid; }
|
| 88 |
+
}
|
| 89 |
+
</style>
|
| 90 |
+
</head>
|
| 91 |
+
<body>
|
| 92 |
+
<div class="toolbar">
|
| 93 |
+
<div class="toolbar__brand"><div class="toolbar__brand-icon">P</div> PotholeIQ</div>
|
| 94 |
+
<div class="toolbar__actions">
|
| 95 |
+
<a class="tbtn" href="ops-review.html">β Ops Review</a>
|
| 96 |
+
<button class="tbtn primary" onclick="window.print()">Print / Save PDF</button>
|
| 97 |
+
</div>
|
| 98 |
+
</div>
|
| 99 |
+
|
| 100 |
+
<div class="page">
|
| 101 |
+
<div id="report-root" class="sheet">
|
| 102 |
+
<div class="loading" id="loading">Loading case reportβ¦</div>
|
| 103 |
+
</div>
|
| 104 |
+
</div>
|
| 105 |
+
|
| 106 |
+
<script src="backend-api.js?v=20260604b"></script>
|
| 107 |
+
<script>
|
| 108 |
+
'use strict';
|
| 109 |
+
const CASES_KEY = 'submitted_cases';
|
| 110 |
+
const params = new URLSearchParams(location.search);
|
| 111 |
+
const caseId = (params.get('case') || '').trim();
|
| 112 |
+
|
| 113 |
+
const num = (v) => { const n = Number(v); return Number.isFinite(n) ? n : 0; };
|
| 114 |
+
const money = (v) => `$${num(v).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
| 115 |
+
const esc = (s) => String(s == null ? '' : s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
| 116 |
+
const dash = (v) => (v === 0 || v) ? v : 'β';
|
| 117 |
+
|
| 118 |
+
function estimateRepairCost(size, severityScore, address) {
|
| 119 |
+
const base = { Small: 140, Medium: 260, Large: 480 }[size] || 0;
|
| 120 |
+
if (!base) return { base: 0, severity: 0, traffic: 0, total: 0 };
|
| 121 |
+
const score = Math.max(0, Math.min(100, num(severityScore) || 50));
|
| 122 |
+
const severity = Math.round((score / 100) * 80);
|
| 123 |
+
const traffic = /(broad st|market st|chestnut st|ridge ave|main road|ring road|highway|expressway)/i.test(String(address || '')) ? Math.round(base * 0.12) : 0;
|
| 124 |
+
return { base, severity, traffic, total: Math.round((base + severity + traffic) / 5) * 5 };
|
| 125 |
+
}
|
| 126 |
+
function priorityFromScore(score) {
|
| 127 |
+
const s = num(score);
|
| 128 |
+
if (s >= 80) return { label:'Critical', desc:'Immediate (<12h)', color:'#dc2626', bg:'#fef2f2' };
|
| 129 |
+
if (s >= 60) return { label:'High', desc:'Next 24h', color:'#b45309', bg:'#fff8e6' };
|
| 130 |
+
if (s >= 40) return { label:'Medium', desc:'Next 72h', color:'#0061d5', bg:'#eff4ff' };
|
| 131 |
+
return { label:'Low', desc:'Scheduled (<7d)', color:'#0e9f6e', bg:'#e7f8f1' };
|
| 132 |
+
}
|
| 133 |
+
const STATUS_TONE = {
|
| 134 |
+
Resolved:{c:'#0e9f6e',bg:'#e7f8f1'}, 'In Progress':{c:'#b45309',bg:'#fff3e6'},
|
| 135 |
+
Assigned:{c:'#6d28d9',bg:'#f1edfc'}, 'Under Review':{c:'#b45309',bg:'#fff8e6'},
|
| 136 |
+
Submitted:{c:'#0061d5',bg:'#eaf1fb'}, Rejected:{c:'#9b1c1c',bg:'#fdf0ef'}
|
| 137 |
+
};
|
| 138 |
+
|
| 139 |
+
async function loadCase() {
|
| 140 |
+
if (window.PotholeBackend?.getCase) {
|
| 141 |
+
try { const c = await window.PotholeBackend.getCase(caseId); if (c) return c; } catch (e) { /* fall through */ }
|
| 142 |
+
}
|
| 143 |
+
try {
|
| 144 |
+
const local = JSON.parse(localStorage.getItem(CASES_KEY) || '[]');
|
| 145 |
+
return local.find((x) => x && x.caseId === caseId) || null;
|
| 146 |
+
} catch { return null; }
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
function photoCell(fileId, fileName, fallbackLabel) {
|
| 150 |
+
const id = `ph-${Math.random().toString(36).slice(2)}`;
|
| 151 |
+
if (fileId) {
|
| 152 |
+
// Try to load from Box; fall back to placeholder on error.
|
| 153 |
+
setTimeout(() => {
|
| 154 |
+
fetch(`/api/box/files/${encodeURIComponent(fileId)}/content`)
|
| 155 |
+
.then((r) => { if (!r.ok) throw new Error(); return r.blob(); })
|
| 156 |
+
.then((b) => { const el = document.getElementById(id); if (el) el.outerHTML = `<img class="ba-photo" src="${URL.createObjectURL(b)}" alt="${esc(fileName||'')}" />`; })
|
| 157 |
+
.catch(() => {});
|
| 158 |
+
}, 0);
|
| 159 |
+
return `<div class="ba-photo__ph" id="${id}">Loading imageβ¦<br>${esc(fileName || fileId)}</div>`;
|
| 160 |
+
}
|
| 161 |
+
return `<div class="ba-photo__ph">${esc(fallbackLabel)}${fileName ? `<br>${esc(fileName)}` : ''}</div>`;
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
function kv(label, value) {
|
| 165 |
+
return `<div class="kv__item"><div class="kv__label">${esc(label)}</div><div class="kv__value">${esc(dash(value))}</div></div>`;
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
function render(rec) {
|
| 169 |
+
const r = rec.report || {};
|
| 170 |
+
const sup = r.supervisor || {};
|
| 171 |
+
const enr = r.enrichment || {};
|
| 172 |
+
const bd = enr.severityBreakdown || {};
|
| 173 |
+
const loc = r.location || {};
|
| 174 |
+
|
| 175 |
+
const size = sup.size || r.potholeSize || r.aiResult || r.aiPrediction || 'Unknown';
|
| 176 |
+
const sevScore = enr.severityScore ?? r.workflowSeverityScore ?? '';
|
| 177 |
+
const sevLevel = enr.severityLevel || r.workflowSeverityLevel || 'Pending';
|
| 178 |
+
const pr = priorityFromScore(sevScore);
|
| 179 |
+
|
| 180 |
+
const laborHours = num(r.laborHours), laborRate = num(r.laborRate), materialsCost = num(r.materialsCost);
|
| 181 |
+
const laborCost = laborHours * laborRate;
|
| 182 |
+
const actualTotal = num(r.totalCost) || (laborCost + materialsCost);
|
| 183 |
+
const est = estimateRepairCost(size, sevScore, r.address);
|
| 184 |
+
const variance = est.total ? actualTotal - est.total : 0;
|
| 185 |
+
const variancePct = est.total ? ((variance / est.total) * 100).toFixed(1) + '%' : 'N/A';
|
| 186 |
+
|
| 187 |
+
const st = STATUS_TONE[rec.status] || STATUS_TONE.Submitted;
|
| 188 |
+
const roadScore = typeof r.aiRoadScore === 'number' ? (r.aiRoadScore * 100).toFixed(1) + '%' : 'β';
|
| 189 |
+
const dup = enr.duplicate?.isDuplicate ? `Duplicate (${enr.duplicate.duplicateCount || 0} linked)` : 'New case';
|
| 190 |
+
const supDecision = sup.verified ? (sup.isPothole ? 'Approved' : 'Rejected') : 'Pending';
|
| 191 |
+
|
| 192 |
+
document.getElementById('report-root').innerHTML = `
|
| 193 |
+
<div class="rhead">
|
| 194 |
+
<div class="rhead__eyebrow">Pothole Repair Β· Before / After Report</div>
|
| 195 |
+
<div class="rhead__title">${esc(rec.caseId)}</div>
|
| 196 |
+
<div class="rhead__meta">
|
| 197 |
+
<span><strong>Status:</strong> <span class="status-pill" style="background:${st.bg};color:${st.c};">${esc(rec.status || 'Resolved')}</span></span>
|
| 198 |
+
<span><strong>Address:</strong> ${esc(r.address || 'Unknown')}</span>
|
| 199 |
+
<span><strong>Generated:</strong> ${new Date().toLocaleString()}</span>
|
| 200 |
+
</div>
|
| 201 |
+
</div>
|
| 202 |
+
|
| 203 |
+
<div class="summary">
|
| 204 |
+
<div class="summary__cell"><div class="summary__label">AI Size</div><div class="summary__value">${esc(size)}</div></div>
|
| 205 |
+
<div class="summary__cell"><div class="summary__label">Severity</div><div class="summary__value">${dash(sevScore)}${sevScore!=='' ? '/100' : ''}</div></div>
|
| 206 |
+
<div class="summary__cell"><div class="summary__label">Priority</div><div class="summary__value" style="color:${pr.color}">${pr.label}</div></div>
|
| 207 |
+
<div class="summary__cell"><div class="summary__label">Total Spend</div><div class="summary__value" style="color:var(--amber)">${money(actualTotal)}</div></div>
|
| 208 |
+
</div>
|
| 209 |
+
|
| 210 |
+
<div class="section">
|
| 211 |
+
<div class="section__label">Before & After</div>
|
| 212 |
+
<div class="ba-grid">
|
| 213 |
+
<div>
|
| 214 |
+
<div class="ba-col__tag before">β Before β Reported Condition</div>
|
| 215 |
+
${photoCell(r.photoFileId, r.photoFileName, 'Reported photo not archived')}
|
| 216 |
+
</div>
|
| 217 |
+
<div>
|
| 218 |
+
<div class="ba-col__tag after">β After β Completed Repair</div>
|
| 219 |
+
${photoCell(r.afterPhotoFileId, r.afterPhotoFileName, 'After-photo not uploaded')}
|
| 220 |
+
</div>
|
| 221 |
+
</div>
|
| 222 |
+
</div>
|
| 223 |
+
|
| 224 |
+
<div class="section">
|
| 225 |
+
<div class="section__label">Report & Location</div>
|
| 226 |
+
<div class="kv">
|
| 227 |
+
${kv('Submitted', r.submittedAt ? new Date(r.submittedAt).toLocaleString() : (rec.ts ? new Date(rec.ts).toLocaleString() : 'β'))}
|
| 228 |
+
${kv('Reporter', r.anonymous ? 'Anonymous' : (r.reporterName || 'β'))}
|
| 229 |
+
${kv('Ward / District', `${r.ward || 'Pending'} / ${r.district || 'Pending'}`)}
|
| 230 |
+
${kv('Maintenance Zone', r.maintenanceZone || 'Pending')}
|
| 231 |
+
${kv('Coordinates', (loc.lat != null && loc.lng != null) ? `${Number(loc.lat).toFixed(5)}, ${Number(loc.lng).toFixed(5)}` : 'β')}
|
| 232 |
+
${kv('Reporter Contact', r.anonymous ? 'β' : (r.reporterContact || 'β'))}
|
| 233 |
+
</div>
|
| 234 |
+
</div>
|
| 235 |
+
|
| 236 |
+
<div class="section">
|
| 237 |
+
<div class="section__label">AI Assessment & Severity (Before)</div>
|
| 238 |
+
<div class="kv">
|
| 239 |
+
${kv('AI Classification', r.aiResult || r.aiPrediction || 'Unknown')}
|
| 240 |
+
${kv('Road-likeness', roadScore)}
|
| 241 |
+
${kv('Severity Level', `${sevLevel}${sevScore!=='' ? ` (${sevScore}/100)` : ''}`)}
|
| 242 |
+
${kv('Resolution Priority', `${pr.label} β ${pr.desc}`)}
|
| 243 |
+
${kv('311 Prior Notice', `${dash(bd.priorNotice?.score)} / ${bd.priorNotice?.max ?? 30}`)}
|
| 244 |
+
${kv('Traffic & Road Class', `${dash(bd.traffic?.score)} / ${bd.traffic?.max ?? 25}`)}
|
| 245 |
+
${kv('Damage Type', `${dash(bd.damage?.score)} / ${bd.damage?.max ?? 25}`)}
|
| 246 |
+
${kv('Weather Risk', `${dash(bd.weather?.score)} / ${bd.weather?.max ?? 10}`)}
|
| 247 |
+
${kv('Duplicate Status', dup)}
|
| 248 |
+
${kv('Supervisor Decision', `${supDecision} Β· ${sup.by || 'β'}`)}
|
| 249 |
+
</div>
|
| 250 |
+
</div>
|
| 251 |
+
|
| 252 |
+
<div class="section">
|
| 253 |
+
<div class="section__label">Repair Completion (After)</div>
|
| 254 |
+
<div class="kv">
|
| 255 |
+
${kv('Resolved At', r.resolvedAt ? new Date(r.resolvedAt).toLocaleString() : 'Pending')}
|
| 256 |
+
${kv('Crew', r.crewName || r.dispatchPlan?.crewName || r.assignedTo || 'Pending')}
|
| 257 |
+
${kv('Repair Method', r.repairMethod || 'Not specified')}
|
| 258 |
+
${kv('Materials Used', r.materialsUsed || 'Not specified')}
|
| 259 |
+
${kv('Labor Hours', r.laborHours ? `${r.laborHours} h` : 'Not specified')}
|
| 260 |
+
${kv('Crew Signature', r.crewSignature || 'Not provided')}
|
| 261 |
+
</div>
|
| 262 |
+
</div>
|
| 263 |
+
|
| 264 |
+
<div class="section">
|
| 265 |
+
<div class="section__label">Cost β Estimated vs. Actual</div>
|
| 266 |
+
<table class="cost-table">
|
| 267 |
+
<tr><td>Estimated repair cost <span class="muted">(base ${money(est.base)} + severity ${money(est.severity)} + traffic ${money(est.traffic)})</span></td><td>${money(est.total)}</td></tr>
|
| 268 |
+
<tr><td>Actual labor <span class="muted">(${laborHours} h Γ ${money(laborRate)}/hr)</span></td><td>${money(laborCost)}</td></tr>
|
| 269 |
+
<tr><td>Actual materials</td><td>${money(materialsCost)}</td></tr>
|
| 270 |
+
<tr class="total"><td>Actual total spend</td><td>${money(actualTotal)}</td></tr>
|
| 271 |
+
<tr><td>Variance vs. estimate</td><td class="${variance > 0 ? 'variance-pos' : 'variance-neg'}">${variance >= 0 ? '+' : ''}${money(variance)} (${variancePct})</td></tr>
|
| 272 |
+
</table>
|
| 273 |
+
</div>
|
| 274 |
+
|
| 275 |
+
<div class="section">
|
| 276 |
+
<div class="section__label">Field Notes</div>
|
| 277 |
+
<div class="notes">${esc(r.resolutionNotes || 'No field notes provided.')}</div>
|
| 278 |
+
</div>
|
| 279 |
+
|
| 280 |
+
<div class="sign">
|
| 281 |
+
<div class="sign__line"><strong>${esc(r.crewSignature || '________________')}</strong>Crew Lead β Repair Completed</div>
|
| 282 |
+
<div class="sign__line"><strong>${esc(sup.by || '________________')}</strong>Supervisor β Verified & Closed</div>
|
| 283 |
+
</div>
|
| 284 |
+
|
| 285 |
+
<div class="rfoot">PotholeIQ Β· Department of Streets β Road Maintenance Division Β· Case ${esc(rec.caseId)}</div>
|
| 286 |
+
`;
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
(async function init() {
|
| 290 |
+
if (!caseId) {
|
| 291 |
+
document.getElementById('report-root').innerHTML = '<div class="loading">No case specified. Open this report from a case (e.g. case-report.html?case=POT-β¦).</div>';
|
| 292 |
+
return;
|
| 293 |
+
}
|
| 294 |
+
const rec = await loadCase();
|
| 295 |
+
if (!rec) {
|
| 296 |
+
document.getElementById('report-root').innerHTML = `<div class="loading">Case β${esc(caseId)}β was not found.</div>`;
|
| 297 |
+
return;
|
| 298 |
+
}
|
| 299 |
+
render(rec);
|
| 300 |
+
})();
|
| 301 |
+
</script>
|
| 302 |
+
</body>
|
| 303 |
+
</html>
|
command-center.html
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>PotholeIQ β Command Center</title>
|
| 7 |
+
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
| 8 |
+
<style>
|
| 9 |
+
:root{
|
| 10 |
+
/* Box palette β white + blue only */
|
| 11 |
+
--bg:#f4f8fd; --panel:#ffffff; --panel2:#eef4fc; --line:#dbe6f5; --ink:#0b2545; --muted:#5b6b85;
|
| 12 |
+
--blue:#0061d5; --blue-deep:#003a85; --blue-mid:#3b86e6; --blue-soft:#8fbef0; --blue-pale:#c9e0f8;
|
| 13 |
+
/* severity = blue intensity scale (deep=critical β¦ pale=low) */
|
| 14 |
+
--crit:#003a85; --high:#0061d5; --med:#4a93f0; --low:#a9cdf6;
|
| 15 |
+
}
|
| 16 |
+
*{box-sizing:border-box;margin:0;padding:0}
|
| 17 |
+
body{font-family:'Segoe UI',Inter,system-ui,Arial,sans-serif;background:var(--bg);color:var(--ink);padding:22px 26px 60px}
|
| 18 |
+
.top{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px;margin-bottom:20px}
|
| 19 |
+
.brand{display:flex;align-items:center;gap:12px}
|
| 20 |
+
.logo{width:40px;height:40px;border-radius:11px;background:var(--blue);display:flex;align-items:center;justify-content:center;font-size:20px;color:#fff}
|
| 21 |
+
h1{font-size:21px;font-weight:800;letter-spacing:.2px;color:var(--blue-deep)}
|
| 22 |
+
.sub{font-size:12.5px;color:var(--muted)}
|
| 23 |
+
.live{display:inline-flex;align-items:center;gap:7px;font-size:12px;color:var(--blue-deep);background:var(--panel2);border:1px solid var(--line);padding:5px 11px;border-radius:20px}
|
| 24 |
+
.live i{width:8px;height:8px;border-radius:50%;background:var(--blue);animation:p 1.6s infinite}
|
| 25 |
+
@keyframes p{0%{box-shadow:0 0 0 0 rgba(0,97,213,.5)}70%{box-shadow:0 0 0 7px rgba(0,97,213,0)}100%{box-shadow:0 0 0 0 rgba(0,97,213,0)}}
|
| 26 |
+
button{font:inherit;cursor:pointer;border:1px solid var(--line);background:var(--panel);color:var(--blue);padding:7px 14px;border-radius:9px;font-weight:600}
|
| 27 |
+
button:hover{border-color:var(--blue);background:var(--panel2)}
|
| 28 |
+
|
| 29 |
+
.kpis{display:grid;grid-template-columns:repeat(5,1fr);gap:14px;margin-bottom:18px}
|
| 30 |
+
.kpi{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:15px 16px;position:relative;overflow:hidden;box-shadow:0 1px 3px rgba(11,37,69,.04)}
|
| 31 |
+
.kpi .v{font-size:30px;font-weight:800;line-height:1;color:var(--blue-deep)}
|
| 32 |
+
.kpi .l{font-size:12px;color:var(--muted);margin-top:6px}
|
| 33 |
+
.kpi .bar{position:absolute;left:0;top:0;bottom:0;width:4px;background:var(--blue)}
|
| 34 |
+
|
| 35 |
+
.grid2{display:grid;grid-template-columns:1.55fr 1fr;gap:16px;margin-bottom:18px}
|
| 36 |
+
.grid3{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin-bottom:18px}
|
| 37 |
+
.grid3 canvas{max-height:210px}
|
| 38 |
+
.panel{background:var(--panel);border:1px solid var(--line);border-radius:16px;padding:16px;box-shadow:0 1px 3px rgba(11,37,69,.04)}
|
| 39 |
+
.panel h2{font-size:13px;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:12px}
|
| 40 |
+
#map{height:380px;border-radius:12px;overflow:hidden;border:1px solid var(--line)}
|
| 41 |
+
.leaflet-popup-content{font-family:inherit}
|
| 42 |
+
|
| 43 |
+
.donut-wrap{display:flex;align-items:center;gap:18px}
|
| 44 |
+
.donut{width:150px;height:150px;border-radius:50%;flex:none}
|
| 45 |
+
.legend{display:flex;flex-direction:column;gap:8px;font-size:13px}
|
| 46 |
+
.legend .row{display:flex;align-items:center;gap:9px}
|
| 47 |
+
.legend .sw{width:12px;height:12px;border-radius:3px}
|
| 48 |
+
.pipe{margin-top:18px;display:flex;flex-direction:column;gap:9px}
|
| 49 |
+
.pipe .seg{display:flex;align-items:center;gap:10px;font-size:12.5px}
|
| 50 |
+
.pipe .track{flex:1;height:9px;background:var(--panel2);border-radius:6px;overflow:hidden}
|
| 51 |
+
.pipe .fill{height:100%;border-radius:6px;background:var(--blue)}
|
| 52 |
+
.pipe .n{width:26px;text-align:right;color:var(--muted)}
|
| 53 |
+
|
| 54 |
+
/* filter bar */
|
| 55 |
+
.cards-h{display:flex;align-items:center;justify-content:space-between;margin:8px 2px 12px;flex-wrap:wrap;gap:10px}
|
| 56 |
+
.cards-h h2{color:var(--muted);font-size:13px;text-transform:uppercase;letter-spacing:.08em}
|
| 57 |
+
.filters{display:flex;flex-wrap:wrap;gap:8px;align-items:center;background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:10px 12px;margin-bottom:14px}
|
| 58 |
+
.fgroup{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
|
| 59 |
+
.fgroup .glab{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin-right:2px}
|
| 60 |
+
.chip{font-size:12px;padding:5px 11px;border-radius:20px;border:1px solid var(--line);background:var(--panel);color:var(--ink);cursor:pointer}
|
| 61 |
+
.chip:hover{border-color:var(--blue-soft)}
|
| 62 |
+
.chip.active{background:var(--blue);color:#fff;border-color:var(--blue)}
|
| 63 |
+
select,input[type=search]{font:inherit;font-size:12.5px;padding:6px 10px;border:1px solid var(--line);border-radius:9px;background:var(--panel);color:var(--ink)}
|
| 64 |
+
input[type=search]{min-width:180px}
|
| 65 |
+
.fsep{width:1px;align-self:stretch;background:var(--line);margin:0 2px}
|
| 66 |
+
|
| 67 |
+
.cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:16px}
|
| 68 |
+
.card{background:var(--panel);border:1px solid var(--line);border-radius:16px;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 1px 3px rgba(11,37,69,.05)}
|
| 69 |
+
.card .photo{height:160px;background:var(--panel2);position:relative;display:flex;align-items:center;justify-content:center;color:var(--muted);font-size:13px}
|
| 70 |
+
.card .photo img{width:100%;height:100%;object-fit:cover;display:block}
|
| 71 |
+
.sev-badge{position:absolute;top:10px;left:10px;font-size:11px;font-weight:800;padding:4px 10px;border-radius:20px;color:#fff;text-transform:uppercase;letter-spacing:.04em}
|
| 72 |
+
.score-chip{position:absolute;top:10px;right:10px;font-size:13px;font-weight:800;background:rgba(0,58,133,.82);padding:4px 10px;border-radius:20px;color:#fff}
|
| 73 |
+
.card .photo[onclick]{cursor:pointer}
|
| 74 |
+
.open-box{position:absolute;bottom:10px;right:10px;background:rgba(11,37,69,.85);color:#fff;font-size:10.5px;font-weight:700;padding:4px 9px;border-radius:7px;opacity:0;transition:opacity .15s;pointer-events:none}
|
| 75 |
+
.card .photo:hover .open-box{opacity:1}
|
| 76 |
+
.card .body{padding:14px 15px;display:flex;flex-direction:column;gap:10px}
|
| 77 |
+
.card .cid{font-weight:800;font-size:15px;display:flex;align-items:center;gap:8px;flex-wrap:wrap;color:var(--blue-deep)}
|
| 78 |
+
.card .addr{font-size:12.5px;color:var(--muted)}
|
| 79 |
+
.tag{font-size:10.5px;padding:2px 8px;border-radius:6px;border:1px solid var(--line);color:var(--muted);background:var(--panel2)}
|
| 80 |
+
.tag.dup{color:var(--blue-deep);border-color:var(--blue-soft);background:var(--blue-pale)}
|
| 81 |
+
.tag.res{color:#fff;border-color:var(--blue);background:var(--blue)}
|
| 82 |
+
|
| 83 |
+
.bd{display:flex;flex-direction:column;gap:7px;margin-top:2px}
|
| 84 |
+
.bd .f{display:grid;grid-template-columns:78px 1fr 38px;align-items:center;gap:8px;font-size:11.5px}
|
| 85 |
+
.bd .f .lab{color:var(--muted)}
|
| 86 |
+
.bd .f .track{height:8px;background:var(--panel2);border-radius:5px;overflow:hidden}
|
| 87 |
+
.bd .f .fill{height:100%;border-radius:5px}
|
| 88 |
+
.bd .f .val{text-align:right;font-variant-numeric:tabular-nums;color:var(--ink)}
|
| 89 |
+
/* breakdown factors in blue shades */
|
| 90 |
+
.bd .f .fill.pn{background:var(--blue-deep)}.bd .f .fill.tr{background:var(--blue)}.bd .f .fill.dm{background:var(--blue-mid)}.bd .f .fill.we{background:var(--blue-soft)}
|
| 91 |
+
.summary{font-size:12px;color:var(--muted);border-top:1px solid var(--line);padding-top:9px;line-height:1.5}
|
| 92 |
+
.bd .f2{display:grid;grid-template-columns:92px 1fr;gap:10px;font-size:11.5px;align-items:baseline}
|
| 93 |
+
.bd .f2 .lab{color:var(--muted);font-weight:600}
|
| 94 |
+
.bd .f2 .dv{color:var(--ink);line-height:1.45}
|
| 95 |
+
.acts{display:flex;gap:6px;margin-top:11px;padding-top:11px;border-top:1px solid var(--line)}
|
| 96 |
+
.acts .act{flex:1;padding:8px 6px;border:none;border-radius:8px;font-size:11.5px;font-weight:700;cursor:pointer;transition:filter .15s}
|
| 97 |
+
.acts .act:hover{filter:brightness(.93)}
|
| 98 |
+
.acts .act:disabled{opacity:.55;cursor:default}
|
| 99 |
+
.acts .approve{background:var(--blue);color:#fff}
|
| 100 |
+
.acts .resolve{background:#16a366;color:#fff}
|
| 101 |
+
.acts .reject{background:#fff;color:#9b1c1c;border:1px solid #f0c5c5}
|
| 102 |
+
.acts .ghost{background:var(--panel2);color:var(--muted)}
|
| 103 |
+
.disp{display:flex;gap:6px;margin-top:8px}
|
| 104 |
+
.disp .crew-in{flex:1;min-width:0;padding:7px 9px;border:1px solid var(--line);border-radius:7px;font-size:11.5px;background:var(--bg);color:var(--ink)}
|
| 105 |
+
.disp .dbtn{padding:7px 11px;border:none;border-radius:7px;background:#0b3d91;color:#fff;font-size:11.5px;font-weight:700;cursor:pointer;white-space:nowrap}
|
| 106 |
+
.disp .dbtn:hover{filter:brightness(.92)}
|
| 107 |
+
.disp .dbtn:disabled{opacity:.55;cursor:default}
|
| 108 |
+
.nb-btn{margin-top:8px;padding:6px 10px;border:1px solid var(--line);background:var(--panel2);color:var(--blue-deep);font-size:11.5px;font-weight:700;border-radius:7px;cursor:pointer;width:100%}
|
| 109 |
+
.nb-btn:hover{border-color:var(--blue);background:#fff}
|
| 110 |
+
.modal{display:none;position:fixed;inset:0;background:rgba(11,37,69,.45);z-index:1000;align-items:center;justify-content:center;padding:20px}
|
| 111 |
+
.modal-box{background:var(--panel);border-radius:14px;max-width:540px;width:100%;max-height:80vh;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,.3)}
|
| 112 |
+
.modal-head{display:flex;justify-content:space-between;align-items:center;padding:15px 18px;border-bottom:1px solid var(--line);font-weight:800;color:var(--ink);font-size:13.5px}
|
| 113 |
+
.modal-x{border:none;background:var(--panel2);width:30px;height:30px;border-radius:8px;cursor:pointer;color:var(--muted);font-size:14px}
|
| 114 |
+
.nb-list{padding:6px 14px 12px;overflow:auto}
|
| 115 |
+
.nb-row{display:flex;align-items:center;gap:12px;padding:10px 4px;border-bottom:1px solid var(--line)}
|
| 116 |
+
.nb-row:last-child{border-bottom:none}
|
| 117 |
+
.nb-dist{min-width:70px;font-weight:800;font-variant-numeric:tabular-nums;color:var(--ink);font-size:13px}
|
| 118 |
+
.nb-sev{font-size:10px;font-weight:800;color:#fff;padding:3px 8px;border-radius:12px;text-transform:uppercase;white-space:nowrap}
|
| 119 |
+
.nb-info{font-size:12px;color:var(--ink);line-height:1.4}
|
| 120 |
+
.nb-info .muted{color:var(--muted)}
|
| 121 |
+
.nb-dup{color:#9b1c1c;font-weight:800;font-size:10.5px}
|
| 122 |
+
.empty{text-align:center;color:var(--muted);padding:40px;border:1px dashed var(--line);border-radius:14px;grid-column:1/-1;background:var(--panel)}
|
| 123 |
+
@media(max-width:1000px){.kpis{grid-template-columns:repeat(2,1fr)}.grid2{grid-template-columns:1fr}}
|
| 124 |
+
</style>
|
| 125 |
+
</head>
|
| 126 |
+
<body>
|
| 127 |
+
<div class="top">
|
| 128 |
+
<div class="brand">
|
| 129 |
+
<div class="logo">π£οΈ</div>
|
| 130 |
+
<div>
|
| 131 |
+
<h1>PotholeIQ β Command Center</h1>
|
| 132 |
+
<div class="sub">Operations view Β· Philadelphia</div>
|
| 133 |
+
</div>
|
| 134 |
+
</div>
|
| 135 |
+
<div style="display:flex;gap:10px;align-items:center">
|
| 136 |
+
<span class="sub" id="who"></span>
|
| 137 |
+
<button onclick="load()">β³ Refresh</button>
|
| 138 |
+
<button onclick="logout()">Sign out</button>
|
| 139 |
+
</div>
|
| 140 |
+
</div>
|
| 141 |
+
|
| 142 |
+
<div class="kpis" id="kpis"></div>
|
| 143 |
+
|
| 144 |
+
<div class="grid2">
|
| 145 |
+
<div class="panel">
|
| 146 |
+
<h2>Pothole Map β severity by location</h2>
|
| 147 |
+
<div id="map"></div>
|
| 148 |
+
</div>
|
| 149 |
+
<div class="panel">
|
| 150 |
+
<h2>Severity Distribution</h2>
|
| 151 |
+
<div class="donut-wrap"><div class="donut" id="donut"></div><div class="legend" id="legend"></div></div>
|
| 152 |
+
<div style="margin-top:18px"><h2>Status Pipeline</h2><div class="pipe" id="pipe"></div></div>
|
| 153 |
+
</div>
|
| 154 |
+
</div>
|
| 155 |
+
|
| 156 |
+
<div class="grid3">
|
| 157 |
+
<div class="panel"><h2>Cases by Ward</h2><canvas id="chWard"></canvas></div>
|
| 158 |
+
<div class="panel"><h2>Pothole Size</h2><canvas id="chSize"></canvas></div>
|
| 159 |
+
<div class="panel"><h2>Submissions Over Time</h2><canvas id="chTrend"></canvas></div>
|
| 160 |
+
</div>
|
| 161 |
+
|
| 162 |
+
<div class="cards-h">
|
| 163 |
+
<h2>Active Cases β severity breakdown</h2>
|
| 164 |
+
<span class="sub" id="count"></span>
|
| 165 |
+
</div>
|
| 166 |
+
|
| 167 |
+
<div class="filters" id="filters">
|
| 168 |
+
<div class="fgroup"><span class="glab">Severity</span><span id="sevChips"></span></div>
|
| 169 |
+
<div class="fsep"></div>
|
| 170 |
+
<div class="fgroup"><span class="glab">Status</span><span id="statChips"></span></div>
|
| 171 |
+
<div class="fsep"></div>
|
| 172 |
+
<div class="fgroup"><span class="glab">Ward</span><select id="wardSel"><option value="all">All</option></select></div>
|
| 173 |
+
<div class="fsep"></div>
|
| 174 |
+
<div class="fgroup"><input type="search" id="search" placeholder="Search case ID or addressβ¦" /></div>
|
| 175 |
+
</div>
|
| 176 |
+
|
| 177 |
+
<div class="cards" id="cards"></div>
|
| 178 |
+
|
| 179 |
+
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
| 180 |
+
<div id="nearby-modal" class="modal" onclick="if(event.target===this)closeNearby()">
|
| 181 |
+
<div class="modal-box">
|
| 182 |
+
<div class="modal-head"><span id="nearby-title">Nearby potholes</span><button class="modal-x" onclick="closeNearby()">β</button></div>
|
| 183 |
+
<div id="nearby-list" class="nb-list"></div>
|
| 184 |
+
</div>
|
| 185 |
+
</div>
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
<script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2"></script>
|
| 189 |
+
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
| 190 |
+
<script>
|
| 191 |
+
// ββ Supervisor auth gate ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 192 |
+
const SUPABASE_URL = 'https://knvoctdesiwogofcyced.supabase.co';
|
| 193 |
+
const SUPABASE_PUBLISHABLE = 'sb_publishable_pCMhOVA6YYg2948wi6ValQ_hhhC-xfK';
|
| 194 |
+
const sbAuth = supabase.createClient(SUPABASE_URL, SUPABASE_PUBLISHABLE);
|
| 195 |
+
let ACCESS_TOKEN = '';
|
| 196 |
+
const REQUIRE_LOGIN = true; // supervisor login required (set false to open the dashboard)
|
| 197 |
+
async function logout(){ await sbAuth.auth.signOut(); location.href = 'login.html'; }
|
| 198 |
+
async function requireSupervisor(){
|
| 199 |
+
const { data } = await sbAuth.auth.getSession();
|
| 200 |
+
const s = data.session;
|
| 201 |
+
if (s && s.user?.app_metadata?.role === 'supervisor') {
|
| 202 |
+
ACCESS_TOKEN = s.access_token;
|
| 203 |
+
const who = document.getElementById('who'); if (who) who.textContent = s.user.email;
|
| 204 |
+
return true;
|
| 205 |
+
}
|
| 206 |
+
if (!REQUIRE_LOGIN) return true; // login disabled β load anyway
|
| 207 |
+
location.href = 'login.html'; return false; // login required β bounce to login
|
| 208 |
+
}
|
| 209 |
+
const authHeaders = () => ({ Authorization: `Bearer ${ACCESS_TOKEN}` });
|
| 210 |
+
</script>
|
| 211 |
+
<script>
|
| 212 |
+
const SEV = { critical:'#003a85', high:'#0061d5', medium:'#4a93f0', low:'#a9cdf6' };
|
| 213 |
+
const sevColor = s => SEV[(s||'').toLowerCase()] || '#8fbef0';
|
| 214 |
+
let map, caseLayer, allCases=[];
|
| 215 |
+
const filt = { sev:'all', status:'all', ward:'all', q:'' };
|
| 216 |
+
|
| 217 |
+
function kpi(v,l){return `<div class="kpi"><div class="bar"></div><div class="v">${v}</div><div class="l">${l}</div></div>`;}
|
| 218 |
+
function renderKpis(cs){
|
| 219 |
+
const open=cs.filter(c=>!['resolved','rejected'].includes((c.status||'').toLowerCase())).length;
|
| 220 |
+
const crit=cs.filter(c=>(c.severityLevel||'').toLowerCase()==='critical').length;
|
| 221 |
+
const res=cs.filter(c=>(c.status||'').toLowerCase()==='resolved').length;
|
| 222 |
+
const avg=cs.length?Math.round(cs.reduce((a,c)=>a+(c.severityScore||0),0)/cs.length):0;
|
| 223 |
+
document.getElementById('kpis').innerHTML=kpi(cs.length,'Total Cases')+kpi(open,'Open')+kpi(crit,'Critical')+kpi(res,'Resolved')+kpi(avg,'Avg Severity');
|
| 224 |
+
}
|
| 225 |
+
function renderDonut(cs){
|
| 226 |
+
const order=['critical','high','medium','low'];
|
| 227 |
+
const counts=order.map(k=>cs.filter(c=>(c.severityLevel||'').toLowerCase()===k).length);
|
| 228 |
+
const total=counts.reduce((a,b)=>a+b,0)||1; let acc=0,stops=[];
|
| 229 |
+
order.forEach((k,i)=>{const fr=counts[i]/total,a=acc*360,b=(acc+fr)*360;if(counts[i])stops.push(`${SEV[k]} ${a}deg ${b}deg`);acc+=fr;});
|
| 230 |
+
const dn=document.getElementById('donut');
|
| 231 |
+
dn.style.background=`conic-gradient(${stops.join(',')||'#dbe6f5 0 360deg'})`; dn.style.position='relative';
|
| 232 |
+
dn.innerHTML=`<div style="position:absolute;inset:26px;background:#fff;border-radius:50%;display:flex;flex-direction:column;align-items:center;justify-content:center"><div style="font-size:26px;font-weight:800;color:var(--blue-deep)">${total}</div><div style="font-size:10px;color:var(--muted)">cases</div></div>`;
|
| 233 |
+
document.getElementById('legend').innerHTML=order.map((k,i)=>`<div class="row"><span class="sw" style="background:${SEV[k]}"></span><span style="text-transform:capitalize">${k}</span><b style="margin-left:auto">${counts[i]}</b></div>`).join('');
|
| 234 |
+
}
|
| 235 |
+
function renderPipe(cs){
|
| 236 |
+
const stages=[['under review','Under Review'],['assigned','Assigned'],['In progress','In Progress'],['resolved','Resolved']];
|
| 237 |
+
const max=cs.length||1;
|
| 238 |
+
document.getElementById('pipe').innerHTML=stages.map(([k,lab])=>{const n=cs.filter(c=>(c.status||'').toLowerCase()===k.toLowerCase()).length;return `<div class="seg"><span style="width:92px;color:var(--muted)">${lab}</span><div class="track"><div class="fill" style="width:${n/max*100}%"></div></div><span class="n">${n}</span></div>`;}).join('');
|
| 239 |
+
}
|
| 240 |
+
function bar(lab,cls,o){const s=o.score??0,m=o.max||1;return `<div class="f"><span class="lab">${lab}</span><div class="track"><div class="fill ${cls}" style="width:${Math.min(100,s/m*100)}%"></div></div><span class="val">${s}/${m}</span></div>`;}
|
| 241 |
+
// Estimated municipal repair cost by pothole size (US 2026: cold patch ~$90β180, hot-mix ~$250β550, full-depth up to ~$800).
|
| 242 |
+
function estCost(size){const s=(size||'').toLowerCase();
|
| 243 |
+
if(s.includes('large')) return '$450β$800 Β· hot-mix, full-depth';
|
| 244 |
+
if(s.includes('medium')) return '$250β$450 Β· hot-mix patch';
|
| 245 |
+
if(s.includes('small')) return '$90β$180 Β· cold patch';
|
| 246 |
+
return 'β';}
|
| 247 |
+
// Approve / Resolve / Reject / Reopen β writes the status to Box, then re-renders.
|
| 248 |
+
async function setStatus(caseId,status,btn){
|
| 249 |
+
const lbl=btn?btn.textContent:''; if(btn){btn.disabled=true;btn.textContent='β¦';}
|
| 250 |
+
try{
|
| 251 |
+
const r=await fetch('/api/box/set-status',{method:'POST',headers:Object.assign({'Content-Type':'application/json'},authHeaders()),body:JSON.stringify({caseId,status})}).then(x=>x.json());
|
| 252 |
+
if(!r.ok) throw new Error(r.error||r.reason||'update failed');
|
| 253 |
+
load();
|
| 254 |
+
}catch(e){ alert('Could not update '+caseId+': '+e.message); if(btn){btn.disabled=false;btn.textContent=lbl;} }
|
| 255 |
+
}
|
| 256 |
+
function card(c){
|
| 257 |
+
const sev=(c.severityLevel||'β'),col=sevColor(sev);
|
| 258 |
+
const img=c.photoFileId?`<img src="/api/box/files/${c.photoFileId}/content?access_token=${encodeURIComponent(ACCESS_TOKEN)}" onerror="this.style.display='none';this.parentElement.insertAdjacentHTML('beforeend','<span>π· photo in Box</span>')"/>`:'<span>π· no photo</span>';
|
| 259 |
+
const dup=(c.duplicateStatus||'').trim()==='duplicate'?`<span class="tag dup">β duplicate Γ${c.duplicateCount}</span>`:'';
|
| 260 |
+
const res=(c.status||'').toLowerCase()==='resolved'?`<span class="tag res">β resolved</span>`:`<span class="tag">${c.status||'β'}</span>`;
|
| 261 |
+
const b=c.breakdown||{};
|
| 262 |
+
return `<div class="card"><div class="photo" ${c.folderId?`onclick="openBox('${c.folderId}')" title="Open case folder in Box"`:''}><span class="sev-badge" style="background:${col}">${sev}</span><span class="score-chip">${c.severityScore??'β'}/100</span>${img}${c.folderId?'<span class="open-box">Open in Box β</span>':''}</div>
|
| 263 |
+
<div class="body"><div class="cid">${c.caseId} ${dup} ${res}</div><div class="addr">π Ward ${c.ward} Β· ${c.address||'β'}</div>
|
| 264 |
+
<div class="addr" style="margin-top:3px">π Reported ${c.submittedAt?new Date(c.submittedAt).toLocaleDateString():'β'}</div>
|
| 265 |
+
<button class="nb-btn" onclick="showNearby('${c.caseId}')">π See nearby potholes</button>
|
| 266 |
+
<div class="bd">
|
| 267 |
+
<div class="f2"><span class="lab">Prior detection</span><span class="dv">${(b.priorNotice&&b.priorNotice.detail)||'β'}</span></div>
|
| 268 |
+
<div class="f2"><span class="lab">Traffic / road</span><span class="dv">${(b.traffic&&b.traffic.detail)||'β'}</span></div>
|
| 269 |
+
<div class="f2"><span class="lab">Weather risk</span><span class="dv">${(b.weather&&b.weather.detail)||'β'}</span></div>
|
| 270 |
+
<div class="f2"><span class="lab">Pothole size</span><span class="dv">${c.potholeSize||'β'}</span></div>
|
| 271 |
+
<div class="f2"><span class="lab">Est. repair cost</span><span class="dv">${estCost(c.potholeSize)}</span></div>
|
| 272 |
+
</div>
|
| 273 |
+
<div class="acts">${['resolved','rejected'].includes((c.status||'').toLowerCase())
|
| 274 |
+
? `<button class="act ghost" onclick="setStatus('${c.caseId}','under review',this)">βΊ Reopen</button>`
|
| 275 |
+
: `<button class="act approve" onclick="setStatus('${c.caseId}','assigned',this)">β Approve</button><button class="act resolve" onclick="setStatus('${c.caseId}','resolved',this)">β Resolve</button><button class="act reject" onclick="setStatus('${c.caseId}','rejected',this)">β Reject</button>`}</div>
|
| 276 |
+
<div class="disp"><input type="email" class="crew-in" id="crew-${c.caseId}" placeholder="crew@email.com" /><button class="dbtn" onclick="dispatchCrew('${c.caseId}',this)">Send to crew</button></div>
|
| 277 |
+
<a class="nb-btn" style="margin-top:8px;display:block;text-align:center;text-decoration:none" href="crew-closeout.html?case=${encodeURIComponent(c.caseId)}" target="_blank">Open Crew Closeout Form</a>
|
| 278 |
+
</div></div>`;
|
| 279 |
+
}
|
| 280 |
+
function renderMap(cs,boundaries){
|
| 281 |
+
if(!map){
|
| 282 |
+
map=L.map('map',{zoomControl:true}).setView([39.99,-75.13],11);
|
| 283 |
+
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',{attribution:'Β© OpenStreetMap Β© CARTO',maxZoom:19}).addTo(map);
|
| 284 |
+
if(boundaries&&boundaries.features){L.geoJSON(boundaries,{style:{color:'#0061d5',weight:1,opacity:.4,fillOpacity:.04}}).addTo(map);}
|
| 285 |
+
}
|
| 286 |
+
if(caseLayer)caseLayer.remove(); caseLayer=L.layerGroup().addTo(map);
|
| 287 |
+
cs.forEach(c=>{if(c.latitude&&c.longitude){L.circleMarker([c.latitude,c.longitude],{radius:9,color:'#fff',weight:2,fillColor:sevColor(c.severityLevel),fillOpacity:.95}).bindPopup(`<b>${c.caseId}</b><br>${c.severityLevel} Β· ${c.severityScore}/100<br>Ward ${c.ward}`).addTo(caseLayer);}});
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
// ---------- filtering ----------
|
| 291 |
+
function applyFilters(){
|
| 292 |
+
const f=allCases.filter(c=>{
|
| 293 |
+
if(filt.sev!=='all' && (c.severityLevel||'').toLowerCase()!==filt.sev) return false;
|
| 294 |
+
if(filt.status!=='all' && (c.status||'').toLowerCase()!==filt.status.toLowerCase()) return false;
|
| 295 |
+
if(filt.ward!=='all' && String(c.ward)!==filt.ward) return false;
|
| 296 |
+
if(filt.q){const q=filt.q.toLowerCase();if(!(`${c.caseId} ${c.address}`.toLowerCase().includes(q)))return false;}
|
| 297 |
+
return true;
|
| 298 |
+
});
|
| 299 |
+
document.getElementById('count').textContent=`${f.length} of ${allCases.length} cases`;
|
| 300 |
+
document.getElementById('cards').innerHTML=f.length?f.sort((a,b)=>(b.severityScore||0)-(a.severityScore||0)).map(card).join(''):'<div class="empty">No cases match these filters.</div>';
|
| 301 |
+
renderMap(f);
|
| 302 |
+
}
|
| 303 |
+
function buildChips(){
|
| 304 |
+
const sevs=[['all','All'],['critical','Critical'],['high','High'],['medium','Medium'],['low','Low']];
|
| 305 |
+
document.getElementById('sevChips').innerHTML=sevs.map(([v,l])=>`<span class="chip ${filt.sev===v?'active':''}" data-k="sev" data-v="${v}">${l}</span>`).join('');
|
| 306 |
+
const stats=[['all','All'],['under review','Review'],['assigned','Assigned'],['In progress','In Progress'],['resolved','Resolved'],['rejected','Rejected']];
|
| 307 |
+
document.getElementById('statChips').innerHTML=stats.map(([v,l])=>`<span class="chip ${filt.status===v?'active':''}" data-k="status" data-v="${v}">${l}</span>`).join('');
|
| 308 |
+
document.querySelectorAll('.chip').forEach(ch=>ch.onclick=()=>{filt[ch.dataset.k]=ch.dataset.v;buildChips();applyFilters();});
|
| 309 |
+
const wards=[...new Set(allCases.map(c=>String(c.ward)))].sort((a,b)=>(parseInt(a)||999)-(parseInt(b)||999));
|
| 310 |
+
document.getElementById('wardSel').innerHTML='<option value="all">All</option>'+wards.map(w=>`<option value="${w}" ${filt.ward===w?'selected':''}>Ward ${w}</option>`).join('');
|
| 311 |
+
document.getElementById('wardSel').onchange=e=>{filt.ward=e.target.value;applyFilters();};
|
| 312 |
+
document.getElementById('search').oninput=e=>{filt.q=e.target.value;applyFilters();};
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
// Click a case photo -> open that case's folder in Box.
|
| 316 |
+
function openBox(folderId){ if(folderId) window.open('https://sednademo.app.box.com/folder/'+folderId,'_blank','noopener'); }
|
| 317 |
+
|
| 318 |
+
// Nearby potholes: closest cases by GPS distance (flags any within the 80 m duplicate range).
|
| 319 |
+
function haversineM(la1,lo1,la2,lo2){const R=6371000,r=x=>x*Math.PI/180;const dla=r(la2-la1),dlo=r(lo2-lo1);const a=Math.sin(dla/2)**2+Math.cos(r(la1))*Math.cos(r(la2))*Math.sin(dlo/2)**2;return 2*R*Math.asin(Math.sqrt(a));}
|
| 320 |
+
function showNearby(caseId){
|
| 321 |
+
const c=allCases.find(x=>x.caseId===caseId);
|
| 322 |
+
if(!c||c.latitude==null||c.longitude==null){alert('No GPS location recorded for '+caseId+'.');return;}
|
| 323 |
+
const fmt=m=>m>=1000?(m/1000).toFixed(2)+' km':m+' m';
|
| 324 |
+
const near=allCases.filter(x=>x.caseId!==caseId&&x.latitude!=null&&x.longitude!=null)
|
| 325 |
+
.map(x=>({c:x,dist:Math.round(haversineM(c.latitude,c.longitude,x.latitude,x.longitude))}))
|
| 326 |
+
.sort((a,b)=>a.dist-b.dist).slice(0,12);
|
| 327 |
+
document.getElementById('nearby-title').textContent='Potholes near '+caseId+' β '+near.length+' closest';
|
| 328 |
+
document.getElementById('nearby-list').innerHTML = near.length
|
| 329 |
+
? near.map(n=>`<div class="nb-row"><span class="nb-dist">${fmt(n.dist)}${n.dist<=80?'<br><span class="nb-dup">β duplicate</span>':''}</span><span class="nb-sev" style="background:${sevColor(n.c.severityLevel)}">${n.c.severityLevel||'β'}</span><div class="nb-info"><b>${n.c.caseId}</b> Β· ${n.c.status||'β'} Β· ${n.c.potholeSize||'β'}<br><span class="muted">π Ward ${n.c.ward} Β· ${n.c.address||'β'}</span></div></div>`).join('')
|
| 330 |
+
: '<div class="nb-info muted" style="padding:14px">No other potholes with a location on record.</div>';
|
| 331 |
+
document.getElementById('nearby-modal').style.display='flex';
|
| 332 |
+
}
|
| 333 |
+
function closeNearby(){document.getElementById('nearby-modal').style.display='none';}
|
| 334 |
+
|
| 335 |
+
// The Box Form (closeout) the crew fills after repair -> filling it triggers WF2.
|
| 336 |
+
const CLOSEOUT_FORM_LINK='https://sednademo.app.box.com/f/bb3c86290ee94d7b8263bb9a33718e20';
|
| 337 |
+
// Enter a crew email -> generate the Work Order, then draft an email to the crew + supervisor
|
| 338 |
+
// with the Work Order link AND the closeout form link.
|
| 339 |
+
async function dispatchCrew(caseId,btn){
|
| 340 |
+
const inp=document.getElementById('crew-'+caseId);
|
| 341 |
+
const email=inp?inp.value.trim():'';
|
| 342 |
+
if(!email||email.indexOf('@')<1){ alert('Enter a valid crew email.'); if(inp)inp.focus(); return; }
|
| 343 |
+
const c=allCases.find(x=>x.caseId===caseId)||{caseId:caseId};
|
| 344 |
+
const meta={caseId:c.caseId,address:c.address,ward:c.ward,district:c.district,maintenanceZone:c.maintenanceZone,
|
| 345 |
+
latitude:c.latitude,longitude:c.longitude,severityScore:c.severityScore,severityScore0100:c.severityScore,
|
| 346 |
+
severityLevel:c.severityLevel,potholeSize:c.potholeSize,weatherRisk:c.weatherRisk,aiReviewStatus:c.aiReviewStatus,
|
| 347 |
+
reporterContact:c.reporterContact,photoFileId:c.photoFileId,submittedAt:c.submittedAt,assignedCrew:email};
|
| 348 |
+
const lbl=btn?btn.textContent:''; if(btn){btn.disabled=true;btn.textContent='Sendingβ¦';}
|
| 349 |
+
try{
|
| 350 |
+
// Box Sign: backend (service account) emails the crew a "review & sign" work order β no mailto, no personal info
|
| 351 |
+
const r=await fetch('/api/box/dispatch-crew',{method:'POST',headers:Object.assign({'Content-Type':'application/json'},authHeaders()),body:JSON.stringify({caseId,crewEmail:email,metadata:meta})}).then(x=>x.json());
|
| 352 |
+
if(!r.ok) throw new Error(r.error||r.reason||'dispatch failed');
|
| 353 |
+
if(btn){btn.textContent='β Sent via Box Sign';}
|
| 354 |
+
if(inp){inp.value='';inp.placeholder='Sent to '+email;}
|
| 355 |
+
}catch(e){ alert('Could not send to crew ('+caseId+'): '+e.message); if(btn){btn.disabled=false;btn.textContent=lbl;} }
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
let _charts={};
|
| 359 |
+
function renderCharts(cs){
|
| 360 |
+
if(typeof Chart==='undefined') return;
|
| 361 |
+
const BLUE='#0061d5';
|
| 362 |
+
const ward={}; cs.forEach(c=>{const w='Ward '+(c.ward||'?'); ward[w]=(ward[w]||0)+1;});
|
| 363 |
+
const we=Object.entries(ward).sort((a,b)=>b[1]-a[1]).slice(0,8);
|
| 364 |
+
const size={small:0,medium:0,large:0}; cs.forEach(c=>{const s=(c.potholeSize||'').toLowerCase(); if(s in size) size[s]++;});
|
| 365 |
+
const day={}; cs.forEach(c=>{if(c.submittedAt){const d=new Date(c.submittedAt); if(!isNaN(d)){const k=d.toISOString().slice(0,10); day[k]=(day[k]||0)+1;}}});
|
| 366 |
+
const days=Object.keys(day).sort();
|
| 367 |
+
const mk=(id,cfg)=>{ if(_charts[id])_charts[id].destroy(); const el=document.getElementById(id); if(el)_charts[id]=new Chart(el,cfg); };
|
| 368 |
+
const barOpts={responsive:true,maintainAspectRatio:false,plugins:{legend:{display:false}},scales:{y:{beginAtZero:true,ticks:{precision:0}}}};
|
| 369 |
+
mk('chWard',{type:'bar',data:{labels:we.map(e=>e[0]),datasets:[{data:we.map(e=>e[1]),backgroundColor:BLUE,borderRadius:5}]},options:barOpts});
|
| 370 |
+
mk('chSize',{type:'doughnut',data:{labels:['Small','Medium','Large'],datasets:[{data:[size.small,size.medium,size.large],backgroundColor:['#8bb8f0','#3b86e8','#0b3d91']}]},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{position:'bottom'}}}});
|
| 371 |
+
mk('chTrend',{type:'line',data:{labels:days,datasets:[{data:days.map(d=>day[d]),borderColor:BLUE,backgroundColor:'rgba(0,97,213,.12)',fill:true,tension:.3,pointRadius:2}]},options:barOpts});
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
async function load(){
|
| 375 |
+
// keep the supervisor session token fresh so the auto-refresh fetch doesn't 401 (and flicker to 0)
|
| 376 |
+
try { const { data } = await sbAuth.auth.getSession(); if (data && data.session) ACCESS_TOKEN = data.session.access_token; } catch(_){}
|
| 377 |
+
const [casesRes,bRes]=await Promise.all([
|
| 378 |
+
fetch('/api/box/cases',{headers:authHeaders()}).then(r=>r.ok?r.json():null).catch(()=>null),
|
| 379 |
+
fetch('/api/gis/boundaries').then(r=>r.json()).catch(()=>null),
|
| 380 |
+
]);
|
| 381 |
+
const cases = casesRes && Array.isArray(casesRes.cases) ? casesRes.cases : null;
|
| 382 |
+
// update on a good fetch; on a failed/empty refresh keep what we already have (don't blank the dashboard)
|
| 383 |
+
if (cases && (cases.length || !allCases.length)) allCases = cases;
|
| 384 |
+
renderKpis(allCases); renderDonut(allCases); renderPipe(allCases); renderMap(allCases,bRes);
|
| 385 |
+
renderCharts(allCases);
|
| 386 |
+
buildChips(); applyFilters();
|
| 387 |
+
}
|
| 388 |
+
(async()=>{ if (await requireSupervisor()){ load(); setInterval(load, 30000); } })(); // auto-refresh every 30s
|
| 389 |
+
</script>
|
| 390 |
+
</body>
|
| 391 |
+
</html>
|
config-store.js
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use strict';
|
| 2 |
+
|
| 3 |
+
const fs = require('fs/promises');
|
| 4 |
+
const path = require('path');
|
| 5 |
+
|
| 6 |
+
const CONFIG_FILE_NAME = 'config.json';
|
| 7 |
+
|
| 8 |
+
const DEFAULT_BOX_CONFIG = {
|
| 9 |
+
authMode: 'oauth_refresh',
|
| 10 |
+
accessToken: '',
|
| 11 |
+
accessTokenExpiresAt: '',
|
| 12 |
+
refreshToken: '',
|
| 13 |
+
clientId: '',
|
| 14 |
+
clientSecret: '',
|
| 15 |
+
subjectType: 'enterprise',
|
| 16 |
+
subjectId: '',
|
| 17 |
+
intakeFolderId: '',
|
| 18 |
+
caseRootFolderId: '',
|
| 19 |
+
metadataScope: '',
|
| 20 |
+
metadataTemplateKey: '',
|
| 21 |
+
updatedAt: '',
|
| 22 |
+
};
|
| 23 |
+
|
| 24 |
+
const DEFAULT_APP_CONFIG = {
|
| 25 |
+
box: { ...DEFAULT_BOX_CONFIG },
|
| 26 |
+
};
|
| 27 |
+
|
| 28 |
+
function nowIso() {
|
| 29 |
+
return new Date().toISOString();
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
function cloneDefaults() {
|
| 33 |
+
return JSON.parse(JSON.stringify(DEFAULT_APP_CONFIG));
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
function normalizeString(value) {
|
| 37 |
+
return String(value ?? '').trim();
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
function maskSecret(secret) {
|
| 41 |
+
const normalized = normalizeString(secret);
|
| 42 |
+
if (!normalized) return '';
|
| 43 |
+
if (normalized.length <= 8) return `${normalized.slice(0, 2)}...${normalized.slice(-2)}`;
|
| 44 |
+
return `${normalized.slice(0, 4)}...${normalized.slice(-4)}`;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
function sanitizeAuthMode(value, fallback = DEFAULT_BOX_CONFIG.authMode) {
|
| 48 |
+
const normalized = normalizeString(value).toLowerCase();
|
| 49 |
+
if (['access_token', 'oauth_refresh', 'client_credentials'].includes(normalized)) {
|
| 50 |
+
return normalized;
|
| 51 |
+
}
|
| 52 |
+
return fallback;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
function sanitizeSubjectType(value, fallback = DEFAULT_BOX_CONFIG.subjectType) {
|
| 56 |
+
const normalized = normalizeString(value).toLowerCase();
|
| 57 |
+
if (normalized === 'user' || normalized === 'enterprise') {
|
| 58 |
+
return normalized;
|
| 59 |
+
}
|
| 60 |
+
return fallback;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
function mergeBoxConfig(existing = {}, incoming = {}, options = {}) {
|
| 64 |
+
const next = {
|
| 65 |
+
...DEFAULT_BOX_CONFIG,
|
| 66 |
+
...(existing || {}),
|
| 67 |
+
};
|
| 68 |
+
|
| 69 |
+
if (options.reset) {
|
| 70 |
+
return {
|
| 71 |
+
...DEFAULT_BOX_CONFIG,
|
| 72 |
+
updatedAt: nowIso(),
|
| 73 |
+
};
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
if (Object.prototype.hasOwnProperty.call(incoming, 'authMode')) {
|
| 77 |
+
next.authMode = sanitizeAuthMode(incoming.authMode, next.authMode);
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
if (Object.prototype.hasOwnProperty.call(incoming, 'subjectType')) {
|
| 81 |
+
next.subjectType = sanitizeSubjectType(incoming.subjectType, next.subjectType);
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
const copyFields = [
|
| 85 |
+
'clientId',
|
| 86 |
+
'subjectId',
|
| 87 |
+
'intakeFolderId',
|
| 88 |
+
'caseRootFolderId',
|
| 89 |
+
'metadataScope',
|
| 90 |
+
'metadataTemplateKey',
|
| 91 |
+
];
|
| 92 |
+
|
| 93 |
+
for (const field of copyFields) {
|
| 94 |
+
if (Object.prototype.hasOwnProperty.call(incoming, field)) {
|
| 95 |
+
next[field] = normalizeString(incoming[field]);
|
| 96 |
+
}
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
if (Object.prototype.hasOwnProperty.call(incoming, 'accessTokenExpiresAt')) {
|
| 100 |
+
next.accessTokenExpiresAt = normalizeString(incoming.accessTokenExpiresAt);
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
const secretFieldMap = [
|
| 104 |
+
['accessToken', 'clearAccessToken'],
|
| 105 |
+
['refreshToken', 'clearRefreshToken'],
|
| 106 |
+
['clientSecret', 'clearClientSecret'],
|
| 107 |
+
];
|
| 108 |
+
|
| 109 |
+
for (const [field, clearField] of secretFieldMap) {
|
| 110 |
+
if (incoming[clearField]) {
|
| 111 |
+
next[field] = '';
|
| 112 |
+
if (field === 'accessToken') next.accessTokenExpiresAt = '';
|
| 113 |
+
continue;
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
if (!Object.prototype.hasOwnProperty.call(incoming, field)) continue;
|
| 117 |
+
const normalized = normalizeString(incoming[field]);
|
| 118 |
+
if (normalized) {
|
| 119 |
+
next[field] = normalized;
|
| 120 |
+
if (field === 'accessToken' && !incoming.accessTokenExpiresAt) {
|
| 121 |
+
next.accessTokenExpiresAt = '';
|
| 122 |
+
}
|
| 123 |
+
}
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
next.updatedAt = nowIso();
|
| 127 |
+
return next;
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
function sanitizeBoxConfigForClient(box = {}) {
|
| 131 |
+
const merged = { ...DEFAULT_BOX_CONFIG, ...(box || {}) };
|
| 132 |
+
return {
|
| 133 |
+
authMode: sanitizeAuthMode(merged.authMode),
|
| 134 |
+
clientId: normalizeString(merged.clientId),
|
| 135 |
+
subjectType: sanitizeSubjectType(merged.subjectType),
|
| 136 |
+
subjectId: normalizeString(merged.subjectId),
|
| 137 |
+
intakeFolderId: normalizeString(merged.intakeFolderId),
|
| 138 |
+
caseRootFolderId: normalizeString(merged.caseRootFolderId),
|
| 139 |
+
metadataScope: normalizeString(merged.metadataScope),
|
| 140 |
+
metadataTemplateKey: normalizeString(merged.metadataTemplateKey),
|
| 141 |
+
accessTokenPreview: maskSecret(merged.accessToken),
|
| 142 |
+
refreshTokenPreview: maskSecret(merged.refreshToken),
|
| 143 |
+
clientSecretPreview: maskSecret(merged.clientSecret),
|
| 144 |
+
hasAccessToken: !!normalizeString(merged.accessToken),
|
| 145 |
+
hasRefreshToken: !!normalizeString(merged.refreshToken),
|
| 146 |
+
hasClientSecret: !!normalizeString(merged.clientSecret),
|
| 147 |
+
accessTokenExpiresAt: normalizeString(merged.accessTokenExpiresAt),
|
| 148 |
+
updatedAt: normalizeString(merged.updatedAt),
|
| 149 |
+
};
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
function createConfigStore({ dataDir }) {
|
| 153 |
+
const configPath = path.join(dataDir, CONFIG_FILE_NAME);
|
| 154 |
+
let writeQueue = Promise.resolve();
|
| 155 |
+
|
| 156 |
+
async function ensureConfigFile() {
|
| 157 |
+
await fs.mkdir(dataDir, { recursive: true });
|
| 158 |
+
try {
|
| 159 |
+
await fs.access(configPath);
|
| 160 |
+
} catch {
|
| 161 |
+
await fs.writeFile(configPath, JSON.stringify(cloneDefaults(), null, 2), 'utf8');
|
| 162 |
+
}
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
async function readConfig() {
|
| 166 |
+
await ensureConfigFile();
|
| 167 |
+
const raw = await fs.readFile(configPath, 'utf8');
|
| 168 |
+
const parsed = JSON.parse(raw);
|
| 169 |
+
return {
|
| 170 |
+
...cloneDefaults(),
|
| 171 |
+
...(parsed || {}),
|
| 172 |
+
box: {
|
| 173 |
+
...DEFAULT_BOX_CONFIG,
|
| 174 |
+
...((parsed && parsed.box) || {}),
|
| 175 |
+
},
|
| 176 |
+
};
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
async function writeConfig(nextConfig) {
|
| 180 |
+
const normalized = {
|
| 181 |
+
...cloneDefaults(),
|
| 182 |
+
...(nextConfig || {}),
|
| 183 |
+
box: {
|
| 184 |
+
...DEFAULT_BOX_CONFIG,
|
| 185 |
+
...((nextConfig && nextConfig.box) || {}),
|
| 186 |
+
},
|
| 187 |
+
};
|
| 188 |
+
const tmpPath = `${configPath}.tmp`;
|
| 189 |
+
await fs.writeFile(tmpPath, JSON.stringify(normalized, null, 2), 'utf8');
|
| 190 |
+
await fs.rename(tmpPath, configPath);
|
| 191 |
+
return normalized;
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
async function queueMutation(mutator) {
|
| 195 |
+
const task = writeQueue.then(async () => {
|
| 196 |
+
const current = await readConfig();
|
| 197 |
+
const next = await mutator(current);
|
| 198 |
+
const written = await writeConfig(next || current);
|
| 199 |
+
return written;
|
| 200 |
+
});
|
| 201 |
+
writeQueue = task.catch(() => undefined);
|
| 202 |
+
return task;
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
return {
|
| 206 |
+
configPath,
|
| 207 |
+
readConfig,
|
| 208 |
+
writeConfig,
|
| 209 |
+
queueMutation,
|
| 210 |
+
mergeBoxConfig,
|
| 211 |
+
sanitizeBoxConfigForClient,
|
| 212 |
+
defaults: {
|
| 213 |
+
box: { ...DEFAULT_BOX_CONFIG },
|
| 214 |
+
},
|
| 215 |
+
};
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
module.exports = {
|
| 219 |
+
createConfigStore,
|
| 220 |
+
mergeBoxConfig,
|
| 221 |
+
sanitizeBoxConfigForClient,
|
| 222 |
+
DEFAULT_BOX_CONFIG,
|
| 223 |
+
};
|
crew-closeout.html
ADDED
|
@@ -0,0 +1,602 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>Crew Closeout β PotholeIQ</title>
|
| 7 |
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 8 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
| 9 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
|
| 10 |
+
<link rel="stylesheet" href="styles.css?v=20260604a" />
|
| 11 |
+
<style>
|
| 12 |
+
body { padding-top: 64px; }
|
| 13 |
+
.closeout-shell {
|
| 14 |
+
min-height: calc(100vh - 64px);
|
| 15 |
+
background: linear-gradient(180deg, #F7F9FC 0%, #ECF2FB 100%);
|
| 16 |
+
padding: 36px 20px 72px;
|
| 17 |
+
}
|
| 18 |
+
.closeout-wrap {
|
| 19 |
+
max-width: 1100px;
|
| 20 |
+
margin: 0 auto;
|
| 21 |
+
display: grid;
|
| 22 |
+
grid-template-columns: 320px minmax(0, 1fr);
|
| 23 |
+
gap: 24px;
|
| 24 |
+
align-items: start;
|
| 25 |
+
}
|
| 26 |
+
.closeout-panel {
|
| 27 |
+
background: #fff;
|
| 28 |
+
border: 1px solid #E4E9F0;
|
| 29 |
+
border-radius: 18px;
|
| 30 |
+
box-shadow: 0 20px 48px rgba(15, 23, 42, 0.08);
|
| 31 |
+
}
|
| 32 |
+
.closeout-panel__head {
|
| 33 |
+
padding: 22px 24px 18px;
|
| 34 |
+
border-bottom: 1px solid #E8EDF5;
|
| 35 |
+
}
|
| 36 |
+
.closeout-panel__title {
|
| 37 |
+
font-size: 20px;
|
| 38 |
+
font-weight: 800;
|
| 39 |
+
color: #162038;
|
| 40 |
+
margin-bottom: 6px;
|
| 41 |
+
}
|
| 42 |
+
.closeout-panel__sub {
|
| 43 |
+
font-size: 13px;
|
| 44 |
+
color: #6B7280;
|
| 45 |
+
line-height: 1.6;
|
| 46 |
+
}
|
| 47 |
+
.closeout-panel__body {
|
| 48 |
+
padding: 22px 24px 24px;
|
| 49 |
+
}
|
| 50 |
+
.detail-stack {
|
| 51 |
+
display: grid;
|
| 52 |
+
gap: 12px;
|
| 53 |
+
}
|
| 54 |
+
.detail-card {
|
| 55 |
+
border: 1px solid #E7ECF4;
|
| 56 |
+
border-radius: 14px;
|
| 57 |
+
padding: 14px 16px;
|
| 58 |
+
background: #FAFCFF;
|
| 59 |
+
}
|
| 60 |
+
.detail-card__label {
|
| 61 |
+
font-size: 11px;
|
| 62 |
+
font-weight: 700;
|
| 63 |
+
text-transform: uppercase;
|
| 64 |
+
letter-spacing: 0.06em;
|
| 65 |
+
color: #7A8396;
|
| 66 |
+
margin-bottom: 4px;
|
| 67 |
+
}
|
| 68 |
+
.detail-card__value {
|
| 69 |
+
font-size: 14px;
|
| 70 |
+
font-weight: 600;
|
| 71 |
+
color: #1A2340;
|
| 72 |
+
line-height: 1.5;
|
| 73 |
+
word-break: break-word;
|
| 74 |
+
}
|
| 75 |
+
.closeout-form {
|
| 76 |
+
display: grid;
|
| 77 |
+
gap: 18px;
|
| 78 |
+
}
|
| 79 |
+
.form-grid {
|
| 80 |
+
display: grid;
|
| 81 |
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 82 |
+
gap: 16px;
|
| 83 |
+
}
|
| 84 |
+
.field-group {
|
| 85 |
+
display: grid;
|
| 86 |
+
gap: 8px;
|
| 87 |
+
}
|
| 88 |
+
.field-group label {
|
| 89 |
+
font-size: 12px;
|
| 90 |
+
font-weight: 700;
|
| 91 |
+
color: #46506A;
|
| 92 |
+
text-transform: uppercase;
|
| 93 |
+
letter-spacing: 0.05em;
|
| 94 |
+
}
|
| 95 |
+
.field-input,
|
| 96 |
+
.field-textarea,
|
| 97 |
+
.field-select {
|
| 98 |
+
width: 100%;
|
| 99 |
+
border: 1.5px solid #D8E0ED;
|
| 100 |
+
border-radius: 12px;
|
| 101 |
+
background: #fff;
|
| 102 |
+
color: #122033;
|
| 103 |
+
font: inherit;
|
| 104 |
+
padding: 12px 14px;
|
| 105 |
+
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
| 106 |
+
}
|
| 107 |
+
.field-textarea { min-height: 130px; resize: vertical; }
|
| 108 |
+
.field-input:focus,
|
| 109 |
+
.field-textarea:focus,
|
| 110 |
+
.field-select:focus {
|
| 111 |
+
outline: none;
|
| 112 |
+
border-color: #0061D5;
|
| 113 |
+
box-shadow: 0 0 0 3px rgba(0, 97, 213, 0.12);
|
| 114 |
+
}
|
| 115 |
+
.field-hint {
|
| 116 |
+
font-size: 12px;
|
| 117 |
+
color: #768195;
|
| 118 |
+
line-height: 1.5;
|
| 119 |
+
}
|
| 120 |
+
.cost-total-box {
|
| 121 |
+
display: flex;
|
| 122 |
+
align-items: center;
|
| 123 |
+
justify-content: space-between;
|
| 124 |
+
gap: 16px;
|
| 125 |
+
padding: 16px 18px;
|
| 126 |
+
border-radius: 14px;
|
| 127 |
+
border: 1.5px solid #FDE2A7;
|
| 128 |
+
background: linear-gradient(135deg, #FFFBF0, #FFF6E0);
|
| 129 |
+
}
|
| 130 |
+
.cost-total-box__label {
|
| 131 |
+
font-size: 12px;
|
| 132 |
+
font-weight: 700;
|
| 133 |
+
text-transform: uppercase;
|
| 134 |
+
letter-spacing: 0.05em;
|
| 135 |
+
color: #9A6608;
|
| 136 |
+
}
|
| 137 |
+
.cost-total-box__hint {
|
| 138 |
+
font-size: 12px;
|
| 139 |
+
color: #B07B2A;
|
| 140 |
+
margin-top: 3px;
|
| 141 |
+
}
|
| 142 |
+
.cost-total-box__amount {
|
| 143 |
+
font-size: 26px;
|
| 144 |
+
font-weight: 800;
|
| 145 |
+
letter-spacing: -0.02em;
|
| 146 |
+
color: #92500C;
|
| 147 |
+
font-variant-numeric: tabular-nums;
|
| 148 |
+
white-space: nowrap;
|
| 149 |
+
}
|
| 150 |
+
.status-banner {
|
| 151 |
+
display: none;
|
| 152 |
+
margin-top: 18px;
|
| 153 |
+
padding: 14px 16px;
|
| 154 |
+
border-radius: 14px;
|
| 155 |
+
font-size: 14px;
|
| 156 |
+
font-weight: 600;
|
| 157 |
+
line-height: 1.6;
|
| 158 |
+
}
|
| 159 |
+
.status-banner.ok {
|
| 160 |
+
display: block;
|
| 161 |
+
background: #EAFAF2;
|
| 162 |
+
border: 1px solid #BFE8D1;
|
| 163 |
+
color: #166C43;
|
| 164 |
+
}
|
| 165 |
+
.status-banner.err {
|
| 166 |
+
display: block;
|
| 167 |
+
background: #FEF2F2;
|
| 168 |
+
border: 1px solid #F6C5C5;
|
| 169 |
+
color: #A12828;
|
| 170 |
+
}
|
| 171 |
+
.action-row {
|
| 172 |
+
display: flex;
|
| 173 |
+
gap: 12px;
|
| 174 |
+
flex-wrap: wrap;
|
| 175 |
+
align-items: center;
|
| 176 |
+
}
|
| 177 |
+
.btn-solid {
|
| 178 |
+
border: none;
|
| 179 |
+
border-radius: 12px;
|
| 180 |
+
padding: 12px 18px;
|
| 181 |
+
font-size: 14px;
|
| 182 |
+
font-weight: 700;
|
| 183 |
+
cursor: pointer;
|
| 184 |
+
transition: transform 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
|
| 185 |
+
}
|
| 186 |
+
.btn-solid.primary {
|
| 187 |
+
background: #0061D5;
|
| 188 |
+
color: #fff;
|
| 189 |
+
box-shadow: 0 10px 24px rgba(0, 97, 213, 0.2);
|
| 190 |
+
}
|
| 191 |
+
.btn-solid.secondary {
|
| 192 |
+
background: #EEF3FB;
|
| 193 |
+
color: #24436B;
|
| 194 |
+
}
|
| 195 |
+
.btn-solid:hover { transform: translateY(-1px); }
|
| 196 |
+
@media (max-width: 900px) {
|
| 197 |
+
.closeout-wrap { grid-template-columns: 1fr; }
|
| 198 |
+
.form-grid { grid-template-columns: 1fr; }
|
| 199 |
+
}
|
| 200 |
+
</style>
|
| 201 |
+
</head>
|
| 202 |
+
<body>
|
| 203 |
+
<nav class="nav-header">
|
| 204 |
+
<div class="nav-header__inner">
|
| 205 |
+
<div class="nav-header__logo">
|
| 206 |
+
<div class="nav-header__logo-icon">P</div>
|
| 207 |
+
<span>PotholeIQ β Crew Closeout</span>
|
| 208 |
+
</div>
|
| 209 |
+
</div>
|
| 210 |
+
</nav>
|
| 211 |
+
|
| 212 |
+
<main class="closeout-shell">
|
| 213 |
+
<div class="closeout-wrap">
|
| 214 |
+
<section class="closeout-panel">
|
| 215 |
+
<div class="closeout-panel__head">
|
| 216 |
+
<div class="closeout-panel__title">Assigned Case Snapshot</div>
|
| 217 |
+
<div class="closeout-panel__sub">Select an assigned case, verify the location and severity context, then submit the field proof and closeout details.</div>
|
| 218 |
+
</div>
|
| 219 |
+
<div class="closeout-panel__body">
|
| 220 |
+
<div class="field-group" style="margin-bottom:18px;">
|
| 221 |
+
<label for="closeout-case">Case</label>
|
| 222 |
+
<select id="closeout-case" class="field-select"></select>
|
| 223 |
+
</div>
|
| 224 |
+
<div class="detail-stack" id="case-detail-stack">
|
| 225 |
+
<div class="detail-card">
|
| 226 |
+
<div class="detail-card__label">Status</div>
|
| 227 |
+
<div class="detail-card__value">No case selected yet.</div>
|
| 228 |
+
</div>
|
| 229 |
+
</div>
|
| 230 |
+
</div>
|
| 231 |
+
</section>
|
| 232 |
+
|
| 233 |
+
<section class="closeout-panel">
|
| 234 |
+
<div class="closeout-panel__head">
|
| 235 |
+
<div class="closeout-panel__title">Crew Completion Form</div>
|
| 236 |
+
<div class="closeout-panel__sub">This records the repair proof outside Box, then saves the after-photo, before/after report, and audit artifacts back into the case folder in Box.</div>
|
| 237 |
+
</div>
|
| 238 |
+
<div class="closeout-panel__body">
|
| 239 |
+
<form class="closeout-form" id="closeout-form">
|
| 240 |
+
<div class="form-grid">
|
| 241 |
+
<div class="field-group">
|
| 242 |
+
<label for="crew-name">Crew Name</label>
|
| 243 |
+
<input id="crew-name" class="field-input" type="text" placeholder="Crew Alpha / Crew 2" />
|
| 244 |
+
</div>
|
| 245 |
+
<div class="field-group">
|
| 246 |
+
<label for="repair-method">Repair Method</label>
|
| 247 |
+
<select id="repair-method" class="field-select">
|
| 248 |
+
<option value="Cold Patch">Cold Patch</option>
|
| 249 |
+
<option value="Hot Mix">Hot Mix</option>
|
| 250 |
+
<option value="Full Depth Repair">Full Depth Repair</option>
|
| 251 |
+
<option value="Temporary Safety Patch">Temporary Safety Patch</option>
|
| 252 |
+
</select>
|
| 253 |
+
</div>
|
| 254 |
+
</div>
|
| 255 |
+
|
| 256 |
+
<div class="form-grid">
|
| 257 |
+
<div class="field-group">
|
| 258 |
+
<label for="materials-used">Materials Used</label>
|
| 259 |
+
<input id="materials-used" class="field-input" type="text" placeholder="2 cold-mix bags, 1 sealant can" />
|
| 260 |
+
</div>
|
| 261 |
+
<div class="field-group">
|
| 262 |
+
<label for="labor-hours">Labor Hours</label>
|
| 263 |
+
<input id="labor-hours" class="field-input" type="number" min="0" step="0.25" placeholder="2.5" />
|
| 264 |
+
</div>
|
| 265 |
+
</div>
|
| 266 |
+
|
| 267 |
+
<div class="form-grid">
|
| 268 |
+
<div class="field-group">
|
| 269 |
+
<label for="labor-rate">Labor Rate ($/hr)</label>
|
| 270 |
+
<input id="labor-rate" class="field-input" type="number" min="0" step="0.01" placeholder="45.00" value="45" />
|
| 271 |
+
</div>
|
| 272 |
+
<div class="field-group">
|
| 273 |
+
<label for="materials-cost">Materials Cost ($)</label>
|
| 274 |
+
<input id="materials-cost" class="field-input" type="number" min="0" step="0.01" placeholder="120.00" />
|
| 275 |
+
</div>
|
| 276 |
+
</div>
|
| 277 |
+
|
| 278 |
+
<div class="cost-total-box">
|
| 279 |
+
<div>
|
| 280 |
+
<div class="cost-total-box__label">Total Spend (labor + materials)</div>
|
| 281 |
+
<div class="cost-total-box__hint" id="cost-breakdown-hint">Enter labor hours, rate, and materials cost.</div>
|
| 282 |
+
</div>
|
| 283 |
+
<div class="cost-total-box__amount" id="total-spend-display">$0.00</div>
|
| 284 |
+
</div>
|
| 285 |
+
|
| 286 |
+
<div class="field-group">
|
| 287 |
+
<label for="resolution-notes">Resolution Notes</label>
|
| 288 |
+
<textarea id="resolution-notes" class="field-textarea" placeholder="Describe the repair, safety observations, and anything the supervisor should know."></textarea>
|
| 289 |
+
</div>
|
| 290 |
+
|
| 291 |
+
<div class="form-grid">
|
| 292 |
+
<div class="field-group">
|
| 293 |
+
<label for="crew-signature">Crew Signature</label>
|
| 294 |
+
<input id="crew-signature" class="field-input" type="text" placeholder="Crew lead full name / badge" />
|
| 295 |
+
</div>
|
| 296 |
+
<div class="field-group">
|
| 297 |
+
<label for="after-photo">After Photo</label>
|
| 298 |
+
<input id="after-photo" class="field-input" type="file" accept="image/*" />
|
| 299 |
+
<div class="field-hint">This uploads directly into the case folder in Box before the closeout record is saved.</div>
|
| 300 |
+
</div>
|
| 301 |
+
</div>
|
| 302 |
+
|
| 303 |
+
<div class="field-group">
|
| 304 |
+
<label for="notify-email">Also Email Report To <span style="font-weight:500;text-transform:none;color:#768195;">β optional, comma-separated</span></label>
|
| 305 |
+
<input id="notify-email" class="field-input" type="text" placeholder="extra.person@city.gov, another@city.gov" autocomplete="off" />
|
| 306 |
+
<div class="field-hint">Every closeout already shares the report with the <strong>default recipients</strong> configured for the team (via Box). Add any extra one-off recipients here, separated by commas. <strong>One-time invite:</strong> each person is invited once to the Box <strong>βCloseout Reportsβ</strong> folder, then keeps standing access β turn on that folderβs notifications in Box to get one clean email per closeout. Requires Box to be connected.</div>
|
| 307 |
+
</div>
|
| 308 |
+
|
| 309 |
+
<div class="action-row">
|
| 310 |
+
<button type="submit" class="btn-solid primary" id="submit-closeout">Submit Closeout & Resolve Case</button>
|
| 311 |
+
</div>
|
| 312 |
+
</form>
|
| 313 |
+
|
| 314 |
+
<div id="closeout-status" class="status-banner"></div>
|
| 315 |
+
</div>
|
| 316 |
+
</section>
|
| 317 |
+
</div>
|
| 318 |
+
</main>
|
| 319 |
+
|
| 320 |
+
<script src="backend-api.js?v=20260604a"></script>
|
| 321 |
+
<script>
|
| 322 |
+
'use strict';
|
| 323 |
+
|
| 324 |
+
const CASES_KEY = 'submitted_cases';
|
| 325 |
+
|
| 326 |
+
function $(id) {
|
| 327 |
+
return document.getElementById(id);
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
+
function readLocalCases() {
|
| 331 |
+
try {
|
| 332 |
+
return JSON.parse(localStorage.getItem(CASES_KEY) || '[]');
|
| 333 |
+
} catch {
|
| 334 |
+
return [];
|
| 335 |
+
}
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
function writeLocalCases(cases) {
|
| 339 |
+
localStorage.setItem(CASES_KEY, JSON.stringify(Array.isArray(cases) ? cases : []));
|
| 340 |
+
}
|
| 341 |
+
|
| 342 |
+
function caseDetailField(label, value) {
|
| 343 |
+
return `
|
| 344 |
+
<div class="detail-card">
|
| 345 |
+
<div class="detail-card__label">${label}</div>
|
| 346 |
+
<div class="detail-card__value">${value || 'β'}</div>
|
| 347 |
+
</div>`;
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
function eligibleCloseoutCases(cases) {
|
| 351 |
+
return (Array.isArray(cases) ? cases : [])
|
| 352 |
+
.filter((item) => item && item.caseId)
|
| 353 |
+
.filter((item) => !['Resolved', 'Rejected'].includes(String(item.status || '')))
|
| 354 |
+
.filter((item) => item.report?.supervisor?.isPothole !== false);
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
+
function renderCaseOptions(cases, selectedCaseId = '') {
|
| 358 |
+
const options = ['<option value="">Select an assigned case</option>'];
|
| 359 |
+
cases.forEach((item) => {
|
| 360 |
+
const selected = item.caseId === selectedCaseId ? ' selected' : '';
|
| 361 |
+
options.push(`<option value="${item.caseId}"${selected}>${item.caseId} β ${item.report?.address || item.report?.areaLabel || 'Unknown address'}</option>`);
|
| 362 |
+
});
|
| 363 |
+
$('closeout-case').innerHTML = options.join('');
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
function updateCaseSummary(caseRecord) {
|
| 367 |
+
if (!caseRecord) {
|
| 368 |
+
$('case-detail-stack').innerHTML = caseDetailField('Status', 'No case selected yet.');
|
| 369 |
+
return;
|
| 370 |
+
}
|
| 371 |
+
|
| 372 |
+
const report = caseRecord.report || {};
|
| 373 |
+
$('case-detail-stack').innerHTML = [
|
| 374 |
+
caseDetailField('Status', caseRecord.status || 'Unknown'),
|
| 375 |
+
caseDetailField('Address', report.address || 'Unknown'),
|
| 376 |
+
caseDetailField('Ward / District', `${report.ward || 'Pending'} / ${report.district || 'Pending'}`),
|
| 377 |
+
caseDetailField('Maintenance Zone', report.maintenanceZone || 'Pending'),
|
| 378 |
+
caseDetailField('Severity', `${report.enrichment?.severityLevel || report.workflowSeverityLevel || 'Pending'} (${report.enrichment?.severityScore ?? report.workflowSeverityScore ?? 'N/A'})`),
|
| 379 |
+
caseDetailField('Assigned Crew', report.dispatchPlan?.crewName || report.crewName || report.assignedTo || 'Pending'),
|
| 380 |
+
].join('');
|
| 381 |
+
|
| 382 |
+
$('crew-name').value = report.dispatchPlan?.crewName || report.crewName || report.assignedTo || '';
|
| 383 |
+
$('repair-method').value = report.repairMethod || 'Cold Patch';
|
| 384 |
+
$('materials-used').value = report.materialsUsed || '';
|
| 385 |
+
$('labor-hours').value = report.laborHours || '';
|
| 386 |
+
$('labor-rate').value = report.laborRate || '45';
|
| 387 |
+
$('materials-cost').value = report.materialsCost || '';
|
| 388 |
+
$('resolution-notes').value = report.resolutionNotes || '';
|
| 389 |
+
$('crew-signature').value = report.crewSignature || '';
|
| 390 |
+
recomputeSpend();
|
| 391 |
+
}
|
| 392 |
+
|
| 393 |
+
function setStatus(message, tone = 'ok') {
|
| 394 |
+
const el = $('closeout-status');
|
| 395 |
+
el.className = `status-banner ${tone}`;
|
| 396 |
+
el.textContent = message;
|
| 397 |
+
}
|
| 398 |
+
|
| 399 |
+
function computeSpend() {
|
| 400 |
+
const hours = parseFloat($('labor-hours').value) || 0;
|
| 401 |
+
const rate = parseFloat($('labor-rate').value) || 0;
|
| 402 |
+
const materials = parseFloat($('materials-cost').value) || 0;
|
| 403 |
+
const labor = hours * rate;
|
| 404 |
+
return { hours, rate, materials, labor, total: labor + materials };
|
| 405 |
+
}
|
| 406 |
+
|
| 407 |
+
function recomputeSpend() {
|
| 408 |
+
const s = computeSpend();
|
| 409 |
+
$('total-spend-display').textContent = `$${s.total.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
| 410 |
+
$('cost-breakdown-hint').textContent = s.total > 0
|
| 411 |
+
? `Labor ${s.hours}h Γ $${s.rate.toFixed(2)} = $${s.labor.toFixed(2)} Β· Materials $${s.materials.toFixed(2)}`
|
| 412 |
+
: 'Enter labor hours, rate, and materials cost.';
|
| 413 |
+
}
|
| 414 |
+
|
| 415 |
+
// Map a Box case (flat, from /api/box/cases) to the { caseId, status, report } shape.
|
| 416 |
+
function boxCaseToRecord(c) {
|
| 417 |
+
return {
|
| 418 |
+
caseId: c.caseId,
|
| 419 |
+
status: c.status || 'Assigned',
|
| 420 |
+
ts: c.submittedAt || Date.now(),
|
| 421 |
+
report: {
|
| 422 |
+
address: c.address || '', ward: c.ward || '', district: c.district || '',
|
| 423 |
+
maintenanceZone: c.maintenanceZone || '',
|
| 424 |
+
location: (c.latitude != null && c.longitude != null) ? { lat: c.latitude, lng: c.longitude } : null,
|
| 425 |
+
photoFileId: c.photoFileId || '', aiResult: c.potholeSize || '', potholeSize: c.potholeSize || '',
|
| 426 |
+
reporterContact: c.reporterContact || '', submittedAt: c.submittedAt || '',
|
| 427 |
+
enrichment: { severityScore: c.severityScore, severityLevel: c.severityLevel, severityBreakdown: c.breakdown || {} },
|
| 428 |
+
},
|
| 429 |
+
};
|
| 430 |
+
}
|
| 431 |
+
|
| 432 |
+
async function fetchBoxCases() {
|
| 433 |
+
try {
|
| 434 |
+
const r = await fetch('/api/box/cases').then((x) => x.json());
|
| 435 |
+
return Array.isArray(r.cases) ? r.cases.map(boxCaseToRecord) : [];
|
| 436 |
+
} catch (_) { return []; }
|
| 437 |
+
}
|
| 438 |
+
|
| 439 |
+
// Load every case (local/DB + Box) so the form can open to any case via ?case=.
|
| 440 |
+
let CASES_BY_ID = {};
|
| 441 |
+
async function loadCases() {
|
| 442 |
+
let remoteCases = [];
|
| 443 |
+
try {
|
| 444 |
+
remoteCases = await window.PotholeBackend.getCases();
|
| 445 |
+
} catch (error) {
|
| 446 |
+
console.warn('[CrewCloseout] remote case load failed:', error);
|
| 447 |
+
}
|
| 448 |
+
const merged = window.PotholeBackend.mergeCaseLists(readLocalCases(), remoteCases);
|
| 449 |
+
const have = new Set(merged.map((c) => c.caseId));
|
| 450 |
+
(await fetchBoxCases()).forEach((bc) => { if (!have.has(bc.caseId)) merged.push(bc); });
|
| 451 |
+
CASES_BY_ID = Object.fromEntries(merged.map((c) => [c.caseId, c]));
|
| 452 |
+
return merged;
|
| 453 |
+
}
|
| 454 |
+
|
| 455 |
+
async function handleSubmit(event) {
|
| 456 |
+
event.preventDefault();
|
| 457 |
+
const caseId = $('closeout-case').value;
|
| 458 |
+
if (!caseId) {
|
| 459 |
+
setStatus('Select an assigned case before submitting closeout.', 'err');
|
| 460 |
+
return;
|
| 461 |
+
}
|
| 462 |
+
|
| 463 |
+
const crewName = $('crew-name').value.trim();
|
| 464 |
+
const resolutionNotes = $('resolution-notes').value.trim();
|
| 465 |
+
const crewSignature = $('crew-signature').value.trim();
|
| 466 |
+
if (!crewName || !resolutionNotes || !crewSignature) {
|
| 467 |
+
setStatus('Crew name, resolution notes, and crew signature are required.', 'err');
|
| 468 |
+
return;
|
| 469 |
+
}
|
| 470 |
+
|
| 471 |
+
const submitBtn = $('submit-closeout');
|
| 472 |
+
submitBtn.disabled = true;
|
| 473 |
+
submitBtn.textContent = 'Submitting closeoutβ¦';
|
| 474 |
+
|
| 475 |
+
try {
|
| 476 |
+
// Ensure the case exists in the backend store (Box-only cases aren't there yet),
|
| 477 |
+
// so the after-photo upload + closeout can find it. The server backfills the rest.
|
| 478 |
+
const selected = CASES_BY_ID[caseId];
|
| 479 |
+
if (selected) {
|
| 480 |
+
await window.PotholeBackend.upsertCase({
|
| 481 |
+
caseId,
|
| 482 |
+
status: selected.status || 'Assigned',
|
| 483 |
+
report: selected.report || {},
|
| 484 |
+
}).catch(() => {});
|
| 485 |
+
}
|
| 486 |
+
|
| 487 |
+
let afterPhotoUpload = null;
|
| 488 |
+
const photoFile = $('after-photo').files && $('after-photo').files[0];
|
| 489 |
+
if (photoFile) {
|
| 490 |
+
const ext = (photoFile.name.split('.').pop() || 'jpg').toLowerCase();
|
| 491 |
+
const safeName = `${caseId}_after_photo.${ext}`;
|
| 492 |
+
afterPhotoUpload = await window.PotholeBackend.uploadCaseFile(caseId, safeName, photoFile, photoFile.type || 'image/jpeg');
|
| 493 |
+
}
|
| 494 |
+
|
| 495 |
+
const spend = computeSpend();
|
| 496 |
+
const notifyEmail = $('notify-email').value.trim();
|
| 497 |
+
if (notifyEmail) localStorage.setItem('closeout_notify_email', notifyEmail);
|
| 498 |
+
const response = await window.PotholeBackend.submitCrewCloseout(caseId, {
|
| 499 |
+
crewName,
|
| 500 |
+
repairMethod: $('repair-method').value,
|
| 501 |
+
materialsUsed: $('materials-used').value.trim(),
|
| 502 |
+
laborHours: $('labor-hours').value.trim(),
|
| 503 |
+
laborRate: $('labor-rate').value.trim(),
|
| 504 |
+
materialsCost: $('materials-cost').value.trim(),
|
| 505 |
+
totalCost: spend.total.toFixed(2),
|
| 506 |
+
notifyEmail,
|
| 507 |
+
resolutionNotes,
|
| 508 |
+
crewSignature,
|
| 509 |
+
afterPhotoFileId: afterPhotoUpload?.fileId || '',
|
| 510 |
+
afterPhotoFileName: afterPhotoUpload?.fileName || '',
|
| 511 |
+
});
|
| 512 |
+
|
| 513 |
+
const updatedCase = response?.case;
|
| 514 |
+
if (!updatedCase) {
|
| 515 |
+
throw new Error('Closeout did not return an updated case.');
|
| 516 |
+
}
|
| 517 |
+
|
| 518 |
+
const allCases = window.PotholeBackend.mergeCaseLists(readLocalCases(), [updatedCase]);
|
| 519 |
+
writeLocalCases(allCases);
|
| 520 |
+
renderCaseOptions(eligibleCloseoutCases(allCases));
|
| 521 |
+
updateCaseSummary(updatedCase);
|
| 522 |
+
$('closeout-form').reset();
|
| 523 |
+
$('closeout-case').value = '';
|
| 524 |
+
|
| 525 |
+
// Summarize the Box notification outcome (one line per recipient).
|
| 526 |
+
const notifications = response?.notifications || [];
|
| 527 |
+
const sharedLink = response?.sharedLink || '';
|
| 528 |
+
const boxLink = sharedLink
|
| 529 |
+
? `<div style="margin-top:6px;"><a href="${sharedLink}" target="_blank" style="color:#0061D5;font-weight:700;">Open report in Box β</a></div>`
|
| 530 |
+
: '';
|
| 531 |
+
let notifyLine = '';
|
| 532 |
+
if (notifications.length) {
|
| 533 |
+
const items = notifications.map((n) => {
|
| 534 |
+
if (n.firstTime) return `π§ <strong>${n.to}</strong> β invited to the Box reports workspace (one-time)`;
|
| 535 |
+
if (n.emailed) return `π§ <strong>${n.to}</strong> β report emailed`;
|
| 536 |
+
if (n.alreadyHasAccess) return `π <strong>${n.to}</strong> β already has access (no new invite)`;
|
| 537 |
+
if (n.reason === 'box_not_configured') return `β οΈ <strong>${n.to}</strong> β Box not connected, not sent`;
|
| 538 |
+
if (n.error) return `β οΈ <strong>${n.to}</strong> β ${n.error}`;
|
| 539 |
+
return `β’ <strong>${n.to}</strong>`;
|
| 540 |
+
});
|
| 541 |
+
notifyLine = `<div style="margin-top:10px;font-weight:700;">Report shared via Box with ${notifications.length} recipient${notifications.length === 1 ? '' : 's'}:</div>
|
| 542 |
+
<ul style="margin:4px 0 0 18px;padding:0;font-weight:500;">${items.map((i) => `<li style="margin:2px 0;">${i}</li>`).join('')}</ul>${boxLink}`;
|
| 543 |
+
}
|
| 544 |
+
|
| 545 |
+
const statusEl = $('closeout-status');
|
| 546 |
+
statusEl.className = 'status-banner ok';
|
| 547 |
+
statusEl.innerHTML = `Closeout submitted for <strong>${caseId}</strong>. The before/after report and audit artifacts have been generated${afterPhotoUpload?.fileId ? ' and the after-photo is stored in Box' : ''}.${notifyLine}
|
| 548 |
+
<a href="case-report.html?case=${encodeURIComponent(caseId)}" target="_blank" style="display:inline-block;margin-top:10px;padding:9px 16px;border-radius:10px;background:#0061D5;color:#fff;text-decoration:none;font-weight:700;">View Before / After Report β</a>`;
|
| 549 |
+
} catch (error) {
|
| 550 |
+
console.error('[CrewCloseout] submit failed:', error);
|
| 551 |
+
setStatus(`Could not submit closeout: ${error.message}`, 'err');
|
| 552 |
+
} finally {
|
| 553 |
+
submitBtn.disabled = false;
|
| 554 |
+
submitBtn.textContent = 'Submit Closeout & Resolve Case';
|
| 555 |
+
}
|
| 556 |
+
}
|
| 557 |
+
|
| 558 |
+
async function init() {
|
| 559 |
+
const allCases = await loadCases();
|
| 560 |
+
const requestedCaseId = new URLSearchParams(window.location.search).get('case') || '';
|
| 561 |
+
|
| 562 |
+
if (requestedCaseId) {
|
| 563 |
+
// Locked mode: this form is for exactly the pothole that was sent to the crew.
|
| 564 |
+
// Show only that case β no picker, no defaulting to some other case.
|
| 565 |
+
const theCase = CASES_BY_ID[requestedCaseId];
|
| 566 |
+
if (!theCase) {
|
| 567 |
+
renderCaseOptions([], '');
|
| 568 |
+
$('closeout-case').disabled = true;
|
| 569 |
+
$('case-detail-stack').innerHTML = caseDetailField('Status', `Case "${requestedCaseId}" was not found.`);
|
| 570 |
+
$('submit-closeout').disabled = true;
|
| 571 |
+
} else {
|
| 572 |
+
renderCaseOptions([theCase], requestedCaseId);
|
| 573 |
+
$('closeout-case').value = requestedCaseId;
|
| 574 |
+
$('closeout-case').disabled = true; // crew can't switch to another case
|
| 575 |
+
updateCaseSummary(theCase);
|
| 576 |
+
}
|
| 577 |
+
} else {
|
| 578 |
+
// No case in the link: don't auto-pick one. Prompt to open from a specific case.
|
| 579 |
+
const eligible = eligibleCloseoutCases(allCases);
|
| 580 |
+
renderCaseOptions(eligible, '');
|
| 581 |
+
updateCaseSummary(null);
|
| 582 |
+
$('closeout-case').addEventListener('change', (event) => {
|
| 583 |
+
updateCaseSummary(CASES_BY_ID[event.target.value] || null);
|
| 584 |
+
});
|
| 585 |
+
}
|
| 586 |
+
|
| 587 |
+
['labor-hours', 'labor-rate', 'materials-cost'].forEach((id) => {
|
| 588 |
+
$(id).addEventListener('input', recomputeSpend);
|
| 589 |
+
});
|
| 590 |
+
recomputeSpend();
|
| 591 |
+
$('notify-email').value = localStorage.getItem('closeout_notify_email') || '';
|
| 592 |
+
|
| 593 |
+
$('closeout-form').addEventListener('submit', handleSubmit);
|
| 594 |
+
}
|
| 595 |
+
|
| 596 |
+
init().catch((error) => {
|
| 597 |
+
console.error('[CrewCloseout] init failed:', error);
|
| 598 |
+
setStatus(`Could not initialize crew closeout: ${error.message}`, 'err');
|
| 599 |
+
});
|
| 600 |
+
</script>
|
| 601 |
+
</body>
|
| 602 |
+
</html>
|
dashcam-demo.html
ADDED
|
@@ -0,0 +1,931 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
| 6 |
+
<title>Dashcam Capture Mode - City Road Reporting</title>
|
| 7 |
+
<meta name="description" content="Real-time pothole detection while driving. Windshield mount your device for hands-free scanning." />
|
| 8 |
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 9 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
| 10 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet" />
|
| 11 |
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css" />
|
| 12 |
+
|
| 13 |
+
<style>
|
| 14 |
+
/* ββ Reset & Variables ββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 15 |
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
| 16 |
+
|
| 17 |
+
:root {
|
| 18 |
+
--bg-dark: #07090e;
|
| 19 |
+
--card-glass: rgba(13, 18, 30, 0.72);
|
| 20 |
+
--card-border: rgba(255, 255, 255, 0.08);
|
| 21 |
+
--accent-glow: rgba(0, 97, 213, 0.3);
|
| 22 |
+
--text-main: #f3f4f6;
|
| 23 |
+
--text-muted: #9ca3af;
|
| 24 |
+
--primary: #0061d5;
|
| 25 |
+
|
| 26 |
+
--color-small: #10b981;
|
| 27 |
+
--color-medium: #f59e0b;
|
| 28 |
+
--color-large: #ef4444;
|
| 29 |
+
--color-not-pothole: #6b7280;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
body {
|
| 33 |
+
font-family: 'Inter', sans-serif;
|
| 34 |
+
background-color: var(--bg-dark);
|
| 35 |
+
color: var(--text-main);
|
| 36 |
+
overflow: hidden;
|
| 37 |
+
height: 100vh;
|
| 38 |
+
width: 100vw;
|
| 39 |
+
user-select: none;
|
| 40 |
+
-webkit-user-select: none;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
/* ββ Fullscreen Camera ββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 44 |
+
.camera-container {
|
| 45 |
+
position: absolute;
|
| 46 |
+
inset: 0;
|
| 47 |
+
z-index: 1;
|
| 48 |
+
display: flex;
|
| 49 |
+
align-items: center;
|
| 50 |
+
justify-content: center;
|
| 51 |
+
background: #000;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
#webcam-feed {
|
| 55 |
+
width: 100%;
|
| 56 |
+
height: 100%;
|
| 57 |
+
object-fit: cover;
|
| 58 |
+
transform: scaleX(1); /* Keep natural camera orientation */
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
/* ββ Live Bounding-Box Detection Overlay ββββββββββββββββββββββββββββββββ */
|
| 62 |
+
#detection-overlay {
|
| 63 |
+
position: absolute;
|
| 64 |
+
inset: 0;
|
| 65 |
+
z-index: 3; /* above camera + flash, below HUD panels */
|
| 66 |
+
width: 100%;
|
| 67 |
+
height: 100%;
|
| 68 |
+
pointer-events: none;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
/* ββ Flash & Warning Vignette Overlay βββββββββββββββββββββββββββββββββββ */
|
| 72 |
+
.alert-overlay {
|
| 73 |
+
position: absolute;
|
| 74 |
+
inset: 0;
|
| 75 |
+
z-index: 2;
|
| 76 |
+
pointer-events: none;
|
| 77 |
+
box-shadow: inset 0 0 0px 0px rgba(239, 68, 68, 0);
|
| 78 |
+
transition: box-shadow 0.15s ease-out;
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
.alert-overlay.flash {
|
| 82 |
+
box-shadow: inset 0 0 80px 30px rgba(239, 68, 68, 0.65);
|
| 83 |
+
animation: alert-pulse 0.4s ease-out;
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
@keyframes alert-pulse {
|
| 87 |
+
0% { box-shadow: inset 0 0 80px 40px rgba(239, 68, 68, 0.85); }
|
| 88 |
+
100% { box-shadow: inset 0 0 0px 0px rgba(239, 68, 68, 0); }
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
/* ββ HUD Dashboard (Overlays) ββββββββββββββββββββββββββββββββββββββββββ */
|
| 92 |
+
.hud-layer {
|
| 93 |
+
position: absolute;
|
| 94 |
+
inset: 0;
|
| 95 |
+
z-index: 10;
|
| 96 |
+
pointer-events: none;
|
| 97 |
+
display: flex;
|
| 98 |
+
flex-direction: column;
|
| 99 |
+
justify-content: space-between;
|
| 100 |
+
padding: 16px;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
/* Make items inside HUD layer clickable */
|
| 104 |
+
.hud-layer * {
|
| 105 |
+
pointer-events: auto;
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
/* Glassmorphism utility */
|
| 109 |
+
.glass-panel {
|
| 110 |
+
background: var(--card-glass);
|
| 111 |
+
backdrop-filter: blur(12px);
|
| 112 |
+
-webkit-backdrop-filter: blur(12px);
|
| 113 |
+
border: 1px solid var(--card-border);
|
| 114 |
+
border-radius: 12px;
|
| 115 |
+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
/* ββ Header HUD βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 119 |
+
.hud-header {
|
| 120 |
+
display: flex;
|
| 121 |
+
align-items: center;
|
| 122 |
+
justify-content: space-between;
|
| 123 |
+
width: 100%;
|
| 124 |
+
margin-bottom: 8px;
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
.header-left {
|
| 128 |
+
display: flex;
|
| 129 |
+
align-items: center;
|
| 130 |
+
gap: 12px;
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
.btn-back {
|
| 134 |
+
width: 40px;
|
| 135 |
+
height: 40px;
|
| 136 |
+
border-radius: 50%;
|
| 137 |
+
border: 1px solid var(--card-border);
|
| 138 |
+
background: rgba(13, 18, 30, 0.6);
|
| 139 |
+
display: flex;
|
| 140 |
+
align-items: center;
|
| 141 |
+
justify-content: center;
|
| 142 |
+
cursor: pointer;
|
| 143 |
+
color: var(--text-main);
|
| 144 |
+
transition: background 0.2s;
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
.btn-back:hover {
|
| 148 |
+
background: var(--primary);
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
.hud-title-wrap {
|
| 152 |
+
display: flex;
|
| 153 |
+
flex-direction: column;
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
.hud-title {
|
| 157 |
+
font-size: 15px;
|
| 158 |
+
font-weight: 800;
|
| 159 |
+
letter-spacing: 0.08em;
|
| 160 |
+
text-transform: uppercase;
|
| 161 |
+
color: var(--text-main);
|
| 162 |
+
text-shadow: 0 2px 4px rgba(0,0,0,0.5);
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
.hud-subtitle {
|
| 166 |
+
font-size: 11px;
|
| 167 |
+
color: var(--text-muted);
|
| 168 |
+
font-weight: 500;
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
.header-right {
|
| 172 |
+
display: flex;
|
| 173 |
+
gap: 8px;
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
.hud-btn {
|
| 177 |
+
padding: 10px 14px;
|
| 178 |
+
border-radius: 8px;
|
| 179 |
+
border: 1px solid var(--card-border);
|
| 180 |
+
background: rgba(13, 18, 30, 0.6);
|
| 181 |
+
color: var(--text-main);
|
| 182 |
+
font-size: 13px;
|
| 183 |
+
font-weight: 600;
|
| 184 |
+
cursor: pointer;
|
| 185 |
+
display: flex;
|
| 186 |
+
align-items: center;
|
| 187 |
+
gap: 6px;
|
| 188 |
+
transition: all 0.2s;
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
.hud-btn:hover {
|
| 192 |
+
background: rgba(255,255,255,0.08);
|
| 193 |
+
border-color: rgba(255,255,255,0.15);
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
/* ββ Middle HUD Info Blocks βββββββββββββββββββββββββββββββββββββββββββββ */
|
| 197 |
+
.hud-middle {
|
| 198 |
+
display: flex;
|
| 199 |
+
flex: 1;
|
| 200 |
+
justify-content: space-between;
|
| 201 |
+
align-items: flex-start;
|
| 202 |
+
margin: 12px 0;
|
| 203 |
+
gap: 16px;
|
| 204 |
+
width: 100%;
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
/* Left Stats Box */
|
| 208 |
+
.hud-stats-panel {
|
| 209 |
+
padding: 16px;
|
| 210 |
+
min-width: 140px;
|
| 211 |
+
display: flex;
|
| 212 |
+
flex-direction: column;
|
| 213 |
+
gap: 12px;
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
.stat-item {
|
| 217 |
+
display: flex;
|
| 218 |
+
flex-direction: column;
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
.stat-label {
|
| 222 |
+
font-size: 10px;
|
| 223 |
+
font-weight: 700;
|
| 224 |
+
text-transform: uppercase;
|
| 225 |
+
letter-spacing: 0.05em;
|
| 226 |
+
color: var(--text-muted);
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
.stat-value {
|
| 230 |
+
font-size: 20px;
|
| 231 |
+
font-weight: 800;
|
| 232 |
+
color: var(--text-main);
|
| 233 |
+
font-family: 'JetBrains Mono', monospace;
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
.stat-unit {
|
| 237 |
+
font-size: 11px;
|
| 238 |
+
font-weight: 500;
|
| 239 |
+
color: var(--text-muted);
|
| 240 |
+
margin-left: 2px;
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
/* Live Target Overlay Box */
|
| 244 |
+
.ai-detection-hud {
|
| 245 |
+
flex: 1;
|
| 246 |
+
max-width: 320px;
|
| 247 |
+
padding: 16px;
|
| 248 |
+
text-align: center;
|
| 249 |
+
align-self: center;
|
| 250 |
+
margin: 0 auto;
|
| 251 |
+
border: 2.5px dashed rgba(255,255,255,0.1);
|
| 252 |
+
transition: all 0.2s;
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
.ai-detection-hud.scanning {
|
| 256 |
+
border-color: rgba(0, 97, 213, 0.4);
|
| 257 |
+
box-shadow: 0 0 16px rgba(0, 97, 213, 0.1);
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
.ai-detection-hud.detected {
|
| 261 |
+
border-style: solid;
|
| 262 |
+
animation: border-glow-pulse 1.5s infinite;
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
@keyframes border-glow-pulse {
|
| 266 |
+
0% { transform: scale(1); }
|
| 267 |
+
50% { transform: scale(1.03); }
|
| 268 |
+
100% { transform: scale(1); }
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
.ai-hud-label {
|
| 272 |
+
font-size: 11px;
|
| 273 |
+
font-weight: 700;
|
| 274 |
+
text-transform: uppercase;
|
| 275 |
+
letter-spacing: 0.08em;
|
| 276 |
+
color: var(--text-muted);
|
| 277 |
+
margin-bottom: 4px;
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
.ai-hud-result {
|
| 281 |
+
font-size: 22px;
|
| 282 |
+
font-weight: 800;
|
| 283 |
+
margin: 6px 0;
|
| 284 |
+
transition: color 0.15s;
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
/* Mini Progress Bar */
|
| 288 |
+
.ai-hud-bar-wrap {
|
| 289 |
+
height: 6px;
|
| 290 |
+
background: rgba(255,255,255,0.1);
|
| 291 |
+
border-radius: 4px;
|
| 292 |
+
overflow: hidden;
|
| 293 |
+
margin: 8px 0;
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
.ai-hud-bar {
|
| 297 |
+
height: 100%;
|
| 298 |
+
width: 0%;
|
| 299 |
+
border-radius: 4px;
|
| 300 |
+
transition: width 0.1s ease-out, background-color 0.15s;
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
.ai-hud-meta {
|
| 304 |
+
font-size: 11px;
|
| 305 |
+
color: var(--text-muted);
|
| 306 |
+
font-family: 'JetBrains Mono', monospace;
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
/* Right Location Box */
|
| 310 |
+
.hud-location-panel {
|
| 311 |
+
padding: 16px;
|
| 312 |
+
max-width: 200px;
|
| 313 |
+
display: flex;
|
| 314 |
+
flex-direction: column;
|
| 315 |
+
gap: 10px;
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
.loc-row {
|
| 319 |
+
display: flex;
|
| 320 |
+
flex-direction: column;
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
.loc-val {
|
| 324 |
+
font-size: 12px;
|
| 325 |
+
font-weight: 600;
|
| 326 |
+
font-family: 'JetBrains Mono', monospace;
|
| 327 |
+
color: var(--text-main);
|
| 328 |
+
word-break: break-all;
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
.badge-status {
|
| 332 |
+
display: inline-flex;
|
| 333 |
+
align-items: center;
|
| 334 |
+
gap: 6px;
|
| 335 |
+
padding: 4px 8px;
|
| 336 |
+
border-radius: 6px;
|
| 337 |
+
font-size: 11px;
|
| 338 |
+
font-weight: 700;
|
| 339 |
+
margin-top: 4px;
|
| 340 |
+
}
|
| 341 |
+
|
| 342 |
+
.badge-status.active {
|
| 343 |
+
background: rgba(16, 185, 129, 0.15);
|
| 344 |
+
color: var(--color-small);
|
| 345 |
+
border: 1px solid rgba(16, 185, 129, 0.3);
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
+
.badge-status.inactive {
|
| 349 |
+
background: rgba(239, 68, 68, 0.15);
|
| 350 |
+
color: var(--color-large);
|
| 351 |
+
border: 1px solid rgba(239, 68, 68, 0.3);
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
/* ββ Bottom HUD controls ββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 355 |
+
.hud-footer {
|
| 356 |
+
display: flex;
|
| 357 |
+
align-items: center;
|
| 358 |
+
justify-content: space-between;
|
| 359 |
+
width: 100%;
|
| 360 |
+
gap: 16px;
|
| 361 |
+
z-index: 11;
|
| 362 |
+
}
|
| 363 |
+
|
| 364 |
+
.footer-left {
|
| 365 |
+
display: flex;
|
| 366 |
+
align-items: center;
|
| 367 |
+
gap: 8px;
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
.btn-sound-toggle {
|
| 371 |
+
width: 44px;
|
| 372 |
+
height: 44px;
|
| 373 |
+
border-radius: 10px;
|
| 374 |
+
border: 1px solid var(--card-border);
|
| 375 |
+
background: var(--card-glass);
|
| 376 |
+
color: var(--text-main);
|
| 377 |
+
display: flex;
|
| 378 |
+
align-items: center;
|
| 379 |
+
justify-content: center;
|
| 380 |
+
cursor: pointer;
|
| 381 |
+
font-size: 18px;
|
| 382 |
+
transition: background 0.2s;
|
| 383 |
+
}
|
| 384 |
+
|
| 385 |
+
.btn-sound-toggle.muted {
|
| 386 |
+
color: var(--color-large);
|
| 387 |
+
}
|
| 388 |
+
|
| 389 |
+
.btn-sound-toggle:not(.muted) {
|
| 390 |
+
color: var(--color-small);
|
| 391 |
+
box-shadow: 0 0 10px rgba(16, 185, 129, 0.2);
|
| 392 |
+
}
|
| 393 |
+
|
| 394 |
+
/* Drawer Toggle handle */
|
| 395 |
+
.btn-gallery-toggle {
|
| 396 |
+
flex: 1;
|
| 397 |
+
max-width: 220px;
|
| 398 |
+
padding: 12px;
|
| 399 |
+
border-radius: 12px;
|
| 400 |
+
border: 1px solid var(--card-border);
|
| 401 |
+
background: var(--card-glass);
|
| 402 |
+
color: var(--text-main);
|
| 403 |
+
font-size: 13px;
|
| 404 |
+
font-weight: 700;
|
| 405 |
+
cursor: pointer;
|
| 406 |
+
display: flex;
|
| 407 |
+
align-items: center;
|
| 408 |
+
justify-content: center;
|
| 409 |
+
gap: 8px;
|
| 410 |
+
margin: 0 auto;
|
| 411 |
+
box-shadow: 0 4px 15px rgba(0,0,0,0.3);
|
| 412 |
+
}
|
| 413 |
+
|
| 414 |
+
.btn-gallery-toggle svg {
|
| 415 |
+
transition: transform 0.3s;
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
.btn-gallery-toggle.active svg {
|
| 419 |
+
transform: rotate(180deg);
|
| 420 |
+
}
|
| 421 |
+
|
| 422 |
+
/* ββ Settings Panel Overlay βββββββββββββββββββββββββββββββββββββββββββββ */
|
| 423 |
+
.settings-overlay {
|
| 424 |
+
position: absolute;
|
| 425 |
+
inset: 0;
|
| 426 |
+
z-index: 100;
|
| 427 |
+
background: rgba(7, 9, 14, 0.85);
|
| 428 |
+
backdrop-filter: blur(16px);
|
| 429 |
+
-webkit-backdrop-filter: blur(16px);
|
| 430 |
+
display: flex;
|
| 431 |
+
align-items: center;
|
| 432 |
+
justify-content: center;
|
| 433 |
+
padding: 20px;
|
| 434 |
+
opacity: 0;
|
| 435 |
+
pointer-events: none;
|
| 436 |
+
transition: opacity 0.25s ease;
|
| 437 |
+
}
|
| 438 |
+
|
| 439 |
+
.settings-overlay.visible {
|
| 440 |
+
opacity: 1;
|
| 441 |
+
pointer-events: auto;
|
| 442 |
+
}
|
| 443 |
+
|
| 444 |
+
.settings-card {
|
| 445 |
+
width: 100%;
|
| 446 |
+
max-width: 400px;
|
| 447 |
+
border: 1px solid var(--card-border);
|
| 448 |
+
border-radius: 16px;
|
| 449 |
+
background: var(--bg-dark);
|
| 450 |
+
padding: 24px;
|
| 451 |
+
box-shadow: 0 20px 50px rgba(0,0,0,0.6);
|
| 452 |
+
}
|
| 453 |
+
|
| 454 |
+
.settings-header {
|
| 455 |
+
display: flex;
|
| 456 |
+
align-items: center;
|
| 457 |
+
justify-content: space-between;
|
| 458 |
+
margin-bottom: 20px;
|
| 459 |
+
padding-bottom: 12px;
|
| 460 |
+
border-bottom: 1px solid rgba(255,255,255,0.06);
|
| 461 |
+
}
|
| 462 |
+
|
| 463 |
+
.settings-title {
|
| 464 |
+
font-size: 18px;
|
| 465 |
+
font-weight: 700;
|
| 466 |
+
}
|
| 467 |
+
|
| 468 |
+
.settings-close {
|
| 469 |
+
background: transparent;
|
| 470 |
+
border: none;
|
| 471 |
+
color: var(--text-muted);
|
| 472 |
+
cursor: pointer;
|
| 473 |
+
font-size: 20px;
|
| 474 |
+
}
|
| 475 |
+
|
| 476 |
+
.setting-group {
|
| 477 |
+
margin-bottom: 20px;
|
| 478 |
+
}
|
| 479 |
+
|
| 480 |
+
.setting-label-row {
|
| 481 |
+
display: flex;
|
| 482 |
+
justify-content: space-between;
|
| 483 |
+
align-items: center;
|
| 484 |
+
margin-bottom: 8px;
|
| 485 |
+
}
|
| 486 |
+
|
| 487 |
+
.setting-label {
|
| 488 |
+
font-size: 13px;
|
| 489 |
+
font-weight: 600;
|
| 490 |
+
color: var(--text-main);
|
| 491 |
+
}
|
| 492 |
+
|
| 493 |
+
.setting-value {
|
| 494 |
+
font-size: 12px;
|
| 495 |
+
font-weight: 700;
|
| 496 |
+
color: var(--primary);
|
| 497 |
+
font-family: 'JetBrains Mono', monospace;
|
| 498 |
+
}
|
| 499 |
+
|
| 500 |
+
.slider-input {
|
| 501 |
+
width: 100%;
|
| 502 |
+
height: 6px;
|
| 503 |
+
background: rgba(255,255,255,0.1);
|
| 504 |
+
border-radius: 4px;
|
| 505 |
+
outline: none;
|
| 506 |
+
-webkit-appearance: none;
|
| 507 |
+
}
|
| 508 |
+
|
| 509 |
+
.slider-input::-webkit-slider-thumb {
|
| 510 |
+
-webkit-appearance: none;
|
| 511 |
+
width: 18px;
|
| 512 |
+
height: 18px;
|
| 513 |
+
border-radius: 50%;
|
| 514 |
+
background: var(--primary);
|
| 515 |
+
cursor: pointer;
|
| 516 |
+
box-shadow: 0 0 8px rgba(0,97,213,0.5);
|
| 517 |
+
}
|
| 518 |
+
|
| 519 |
+
.setting-desc {
|
| 520 |
+
font-size: 11px;
|
| 521 |
+
color: var(--text-muted);
|
| 522 |
+
margin-top: 4px;
|
| 523 |
+
line-height: 1.4;
|
| 524 |
+
}
|
| 525 |
+
|
| 526 |
+
.btn-apply-settings {
|
| 527 |
+
width: 100%;
|
| 528 |
+
padding: 12px;
|
| 529 |
+
border-radius: 8px;
|
| 530 |
+
background: var(--primary);
|
| 531 |
+
color: #fff;
|
| 532 |
+
border: none;
|
| 533 |
+
font-size: 14px;
|
| 534 |
+
font-weight: 700;
|
| 535 |
+
cursor: pointer;
|
| 536 |
+
transition: background 0.2s;
|
| 537 |
+
}
|
| 538 |
+
|
| 539 |
+
.btn-apply-settings:hover {
|
| 540 |
+
background: #0052b3;
|
| 541 |
+
}
|
| 542 |
+
|
| 543 |
+
/* ββ Sliding Session Log Drawer βββββββββββββββββββββββββββββββββββββββββ */
|
| 544 |
+
.gallery-drawer {
|
| 545 |
+
position: absolute;
|
| 546 |
+
left: 0;
|
| 547 |
+
right: 0;
|
| 548 |
+
bottom: 0;
|
| 549 |
+
height: 380px;
|
| 550 |
+
z-index: 50;
|
| 551 |
+
background: #0c121e;
|
| 552 |
+
border-top: 1px solid var(--card-border);
|
| 553 |
+
border-top-left-radius: 20px;
|
| 554 |
+
border-top-right-radius: 20px;
|
| 555 |
+
box-shadow: 0 -10px 40px rgba(0, 0, 0, 0.8);
|
| 556 |
+
transform: translateY(100%);
|
| 557 |
+
transition: transform 0.35s cubic-bezier(0.19, 1, 0.22, 1);
|
| 558 |
+
display: flex;
|
| 559 |
+
flex-direction: column;
|
| 560 |
+
overflow: hidden;
|
| 561 |
+
}
|
| 562 |
+
|
| 563 |
+
.gallery-drawer.open {
|
| 564 |
+
transform: translateY(0);
|
| 565 |
+
}
|
| 566 |
+
|
| 567 |
+
.drawer-header {
|
| 568 |
+
padding: 16px 20px;
|
| 569 |
+
display: flex;
|
| 570 |
+
align-items: center;
|
| 571 |
+
justify-content: space-between;
|
| 572 |
+
border-bottom: 1px solid rgba(255,255,255,0.06);
|
| 573 |
+
}
|
| 574 |
+
|
| 575 |
+
.drawer-title {
|
| 576 |
+
font-size: 15px;
|
| 577 |
+
font-weight: 700;
|
| 578 |
+
display: flex;
|
| 579 |
+
align-items: center;
|
| 580 |
+
gap: 8px;
|
| 581 |
+
}
|
| 582 |
+
|
| 583 |
+
.drawer-count-badge {
|
| 584 |
+
background: rgba(255,255,255,0.1);
|
| 585 |
+
color: var(--text-main);
|
| 586 |
+
padding: 2px 8px;
|
| 587 |
+
border-radius: 12px;
|
| 588 |
+
font-size: 11px;
|
| 589 |
+
font-weight: 700;
|
| 590 |
+
}
|
| 591 |
+
|
| 592 |
+
.btn-clear-session {
|
| 593 |
+
font-size: 12px;
|
| 594 |
+
color: var(--color-large);
|
| 595 |
+
background: transparent;
|
| 596 |
+
border: none;
|
| 597 |
+
cursor: pointer;
|
| 598 |
+
font-weight: 600;
|
| 599 |
+
}
|
| 600 |
+
|
| 601 |
+
.drawer-body {
|
| 602 |
+
flex: 1;
|
| 603 |
+
overflow-y: auto;
|
| 604 |
+
padding: 16px;
|
| 605 |
+
-webkit-overflow-scrolling: touch;
|
| 606 |
+
}
|
| 607 |
+
|
| 608 |
+
/* Empty State */
|
| 609 |
+
.drawer-empty-state {
|
| 610 |
+
height: 100%;
|
| 611 |
+
display: flex;
|
| 612 |
+
flex-direction: column;
|
| 613 |
+
align-items: center;
|
| 614 |
+
justify-content: center;
|
| 615 |
+
color: var(--text-muted);
|
| 616 |
+
font-size: 13px;
|
| 617 |
+
text-align: center;
|
| 618 |
+
padding: 40px;
|
| 619 |
+
}
|
| 620 |
+
|
| 621 |
+
.empty-icon {
|
| 622 |
+
font-size: 32px;
|
| 623 |
+
margin-bottom: 12px;
|
| 624 |
+
opacity: 0.6;
|
| 625 |
+
}
|
| 626 |
+
|
| 627 |
+
/* Gallery List Grid */
|
| 628 |
+
.gallery-grid {
|
| 629 |
+
display: grid;
|
| 630 |
+
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
| 631 |
+
gap: 12px;
|
| 632 |
+
}
|
| 633 |
+
|
| 634 |
+
.gallery-card {
|
| 635 |
+
background: rgba(255,255,255,0.03);
|
| 636 |
+
border: 1px solid rgba(255,255,255,0.06);
|
| 637 |
+
border-radius: 10px;
|
| 638 |
+
overflow: hidden;
|
| 639 |
+
display: flex;
|
| 640 |
+
flex-direction: column;
|
| 641 |
+
}
|
| 642 |
+
|
| 643 |
+
.gallery-thumb-container {
|
| 644 |
+
position: relative;
|
| 645 |
+
height: 90px;
|
| 646 |
+
background: #000;
|
| 647 |
+
}
|
| 648 |
+
|
| 649 |
+
.gallery-thumb {
|
| 650 |
+
width: 100%;
|
| 651 |
+
height: 100%;
|
| 652 |
+
object-fit: cover;
|
| 653 |
+
}
|
| 654 |
+
|
| 655 |
+
.gallery-size-tag {
|
| 656 |
+
position: absolute;
|
| 657 |
+
top: 6px;
|
| 658 |
+
left: 6px;
|
| 659 |
+
padding: 2px 6px;
|
| 660 |
+
border-radius: 4px;
|
| 661 |
+
font-size: 9px;
|
| 662 |
+
font-weight: 700;
|
| 663 |
+
color: #fff;
|
| 664 |
+
}
|
| 665 |
+
|
| 666 |
+
.gallery-info {
|
| 667 |
+
padding: 8px;
|
| 668 |
+
display: flex;
|
| 669 |
+
flex-direction: column;
|
| 670 |
+
gap: 4px;
|
| 671 |
+
}
|
| 672 |
+
|
| 673 |
+
.gallery-time {
|
| 674 |
+
font-size: 10px;
|
| 675 |
+
color: var(--text-muted);
|
| 676 |
+
font-weight: 500;
|
| 677 |
+
}
|
| 678 |
+
|
| 679 |
+
.gallery-coords {
|
| 680 |
+
font-size: 9px;
|
| 681 |
+
font-family: 'JetBrains Mono', monospace;
|
| 682 |
+
color: var(--primary);
|
| 683 |
+
overflow: hidden;
|
| 684 |
+
text-overflow: ellipsis;
|
| 685 |
+
white-space: nowrap;
|
| 686 |
+
}
|
| 687 |
+
|
| 688 |
+
/* ββ Hide canvas for frames βββββββββββββββββββββββββββββββββββββββββββββ */
|
| 689 |
+
#frame-canvas {
|
| 690 |
+
display: none;
|
| 691 |
+
}
|
| 692 |
+
|
| 693 |
+
/* ββ Screen layout support for desktop landscape testing βββββββββββββββ */
|
| 694 |
+
@media (min-width: 768px) {
|
| 695 |
+
.hud-layer {
|
| 696 |
+
padding: 24px;
|
| 697 |
+
}
|
| 698 |
+
.hud-stats-panel {
|
| 699 |
+
min-width: 180px;
|
| 700 |
+
}
|
| 701 |
+
.hud-location-panel {
|
| 702 |
+
max-width: 280px;
|
| 703 |
+
}
|
| 704 |
+
}
|
| 705 |
+
</style>
|
| 706 |
+
</head>
|
| 707 |
+
<body>
|
| 708 |
+
|
| 709 |
+
<!-- Hidden canvas for grabbing frames -->
|
| 710 |
+
<canvas id="frame-canvas" width="400" height="300"></canvas>
|
| 711 |
+
|
| 712 |
+
<!-- Fullscreen Camera Preview -->
|
| 713 |
+
<div class="camera-container">
|
| 714 |
+
<video id="webcam-feed" autoplay playsinline muted></video>
|
| 715 |
+
</div>
|
| 716 |
+
|
| 717 |
+
<!-- Live bounding-box overlay (drawn by Roboflow detection engine) -->
|
| 718 |
+
<canvas id="detection-overlay"></canvas>
|
| 719 |
+
|
| 720 |
+
<!-- Screen Red Warning Vignette Flash on Detection -->
|
| 721 |
+
<div class="alert-overlay" id="alert-overlay"></div>
|
| 722 |
+
|
| 723 |
+
<!-- HUD Dashboard UI Overlays -->
|
| 724 |
+
<div class="hud-layer">
|
| 725 |
+
|
| 726 |
+
<!-- HEADER BAR -->
|
| 727 |
+
<header class="hud-header">
|
| 728 |
+
<div class="header-left">
|
| 729 |
+
<button class="btn-back" id="btn-exit" title="Back to submission portal">
|
| 730 |
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
| 731 |
+
<line x1="19" y1="12" x2="5" y2="12"></line>
|
| 732 |
+
<polyline points="12 19 5 12 12 5"></polyline>
|
| 733 |
+
</svg>
|
| 734 |
+
</button>
|
| 735 |
+
<div class="hud-title-wrap">
|
| 736 |
+
<h1 class="hud-title" id="hud-title-text">Dashcam Mode</h1>
|
| 737 |
+
<span class="hud-subtitle">Automatic Moving Vehicle Capture</span>
|
| 738 |
+
</div>
|
| 739 |
+
</div>
|
| 740 |
+
<div class="header-right">
|
| 741 |
+
<button class="hud-btn" id="btn-settings-open">
|
| 742 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
| 743 |
+
<circle cx="12" cy="12" r="3"></circle>
|
| 744 |
+
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
| 745 |
+
</svg>
|
| 746 |
+
Settings
|
| 747 |
+
</button>
|
| 748 |
+
</div>
|
| 749 |
+
</header>
|
| 750 |
+
|
| 751 |
+
<!-- MIDDLE STATS & LIVE AI HUD overlay -->
|
| 752 |
+
<div class="hud-middle">
|
| 753 |
+
|
| 754 |
+
<!-- Left Panel: Session Stats -->
|
| 755 |
+
<div class="glass-panel hud-stats-panel">
|
| 756 |
+
<div class="stat-item">
|
| 757 |
+
<span class="stat-label">Captured</span>
|
| 758 |
+
<span class="stat-value" id="stats-total-potholes">0</span>
|
| 759 |
+
</div>
|
| 760 |
+
<div class="stat-item">
|
| 761 |
+
<span class="stat-label">GPS Speed</span>
|
| 762 |
+
<span class="stat-value" id="stats-speed">0<span class="stat-unit">mph</span></span>
|
| 763 |
+
</div>
|
| 764 |
+
<div class="stat-item">
|
| 765 |
+
<span class="stat-label">Latency</span>
|
| 766 |
+
<span class="stat-value" id="stats-ai-latency">--<span class="stat-unit">ms</span></span>
|
| 767 |
+
</div>
|
| 768 |
+
</div>
|
| 769 |
+
|
| 770 |
+
<!-- Center Overlay: AI Live Bounding Target Box -->
|
| 771 |
+
<div class="glass-panel ai-detection-hud scanning" id="ai-detection-box">
|
| 772 |
+
<div class="ai-hud-label" id="ai-hud-status-text">Continuous Scanning</div>
|
| 773 |
+
<div class="ai-hud-result" id="ai-hud-result-label" style="color:var(--text-muted)">Searching...</div>
|
| 774 |
+
<div class="ai-hud-bar-wrap">
|
| 775 |
+
<div class="ai-hud-bar" id="ai-confidence-bar"></div>
|
| 776 |
+
</div>
|
| 777 |
+
<div class="ai-hud-meta" id="ai-hud-meta-text">Sample Rate: 1.0s</div>
|
| 778 |
+
</div>
|
| 779 |
+
|
| 780 |
+
<!-- Right Panel: GPS Live Location -->
|
| 781 |
+
<div class="glass-panel hud-location-panel">
|
| 782 |
+
<div class="loc-row">
|
| 783 |
+
<span class="stat-label">GPS Lock</span>
|
| 784 |
+
<div class="badge-status inactive" id="gps-lock-badge">
|
| 785 |
+
<span style="width: 6px; height: 6px; background: currentColor; border-radius: 50%;"></span>
|
| 786 |
+
No Signal
|
| 787 |
+
</div>
|
| 788 |
+
</div>
|
| 789 |
+
<div class="loc-row">
|
| 790 |
+
<span class="stat-label">Coordinates</span>
|
| 791 |
+
<span class="loc-val" id="hud-gps-coordinates">Waiting...</span>
|
| 792 |
+
</div>
|
| 793 |
+
<div class="loc-row">
|
| 794 |
+
<span class="stat-label">Precision</span>
|
| 795 |
+
<span class="loc-val" id="hud-gps-precision">--</span>
|
| 796 |
+
</div>
|
| 797 |
+
<div class="loc-row">
|
| 798 |
+
<span class="stat-label">Detected Ward</span>
|
| 799 |
+
<span class="loc-val" id="hud-detected-ward">--</span>
|
| 800 |
+
</div>
|
| 801 |
+
</div>
|
| 802 |
+
|
| 803 |
+
</div>
|
| 804 |
+
|
| 805 |
+
<!-- FOOTER HUD BAR -->
|
| 806 |
+
<footer class="hud-footer">
|
| 807 |
+
<div class="footer-left">
|
| 808 |
+
<!-- Audio trigger -->
|
| 809 |
+
<button class="btn-sound-toggle" id="btn-sound-toggle" title="Toggle audio feedback beeps">
|
| 810 |
+
π
|
| 811 |
+
</button>
|
| 812 |
+
</div>
|
| 813 |
+
|
| 814 |
+
<!-- Drawer Slide Handle -->
|
| 815 |
+
<button class="btn-gallery-toggle" id="btn-gallery-toggle">
|
| 816 |
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
| 817 |
+
<polyline points="18 15 12 9 6 15"></polyline>
|
| 818 |
+
</svg>
|
| 819 |
+
Session Log
|
| 820 |
+
<span class="drawer-count-badge" id="stats-drawer-count">0</span>
|
| 821 |
+
</button>
|
| 822 |
+
|
| 823 |
+
<!-- Balanced spacing spacer -->
|
| 824 |
+
<div style="width:44px;"></div>
|
| 825 |
+
</footer>
|
| 826 |
+
|
| 827 |
+
</div>
|
| 828 |
+
|
| 829 |
+
<!-- Sliding Session Log Drawer Gallery -->
|
| 830 |
+
<div class="gallery-drawer" id="gallery-drawer">
|
| 831 |
+
<div class="drawer-header">
|
| 832 |
+
<div class="drawer-title">
|
| 833 |
+
Captured Session Log
|
| 834 |
+
<span class="drawer-count-badge" id="drawer-count-badge-total">0 items</span>
|
| 835 |
+
</div>
|
| 836 |
+
<button class="btn-clear-session" id="btn-clear-session-action">Clear Log</button>
|
| 837 |
+
</div>
|
| 838 |
+
|
| 839 |
+
<div class="drawer-body">
|
| 840 |
+
<!-- Empty state initially -->
|
| 841 |
+
<div class="drawer-empty-state" id="drawer-empty-state">
|
| 842 |
+
<div class="empty-icon">π₯</div>
|
| 843 |
+
<p>No potholes auto-captured yet.</p>
|
| 844 |
+
<p style="font-size: 11px; margin-top: 4px; color: var(--text-muted)">Mount your phone on the dash and start driving. Detections will appear here in real time.</p>
|
| 845 |
+
</div>
|
| 846 |
+
|
| 847 |
+
<!-- Gallery Grid -->
|
| 848 |
+
<div class="gallery-grid" id="gallery-grid" style="display:none;"></div>
|
| 849 |
+
</div>
|
| 850 |
+
</div>
|
| 851 |
+
|
| 852 |
+
<!-- Settings Panel Modal -->
|
| 853 |
+
<div class="settings-overlay" id="settings-overlay">
|
| 854 |
+
<div class="settings-card">
|
| 855 |
+
<div class="settings-header">
|
| 856 |
+
<h3 class="settings-title">Dashcam Setup</h3>
|
| 857 |
+
<button class="settings-close" id="btn-settings-close">×</button>
|
| 858 |
+
</div>
|
| 859 |
+
|
| 860 |
+
<!-- Sample Interval -->
|
| 861 |
+
<div class="setting-group">
|
| 862 |
+
<div class="setting-label-row">
|
| 863 |
+
<span class="setting-label">AI Scanning Rate</span>
|
| 864 |
+
<span class="setting-value" id="settings-label-rate">1.0s</span>
|
| 865 |
+
</div>
|
| 866 |
+
<input type="range" class="slider-input" id="settings-rate" min="0.5" max="5.0" step="0.5" value="1.0" />
|
| 867 |
+
<p class="setting-desc">How often the camera feeds a frame to the AI. Lower means more frequent checks, consuming more battery.</p>
|
| 868 |
+
</div>
|
| 869 |
+
|
| 870 |
+
<!-- Confidence Threshold -->
|
| 871 |
+
<div class="setting-group">
|
| 872 |
+
<div class="setting-label-row">
|
| 873 |
+
<span class="setting-label">Capture Threshold</span>
|
| 874 |
+
<span class="setting-value" id="settings-label-threshold">72%</span>
|
| 875 |
+
</div>
|
| 876 |
+
<input type="range" class="slider-input" id="settings-threshold" min="30" max="95" step="5" value="72" />
|
| 877 |
+
<p class="setting-desc">Minimum confidence percentage required to auto-record a pothole detection.</p>
|
| 878 |
+
</div>
|
| 879 |
+
|
| 880 |
+
<!-- Detection engine selector -->
|
| 881 |
+
<div class="setting-group">
|
| 882 |
+
<label class="setting-label" style="display:block; margin-bottom: 8px;" for="settings-engine">Detection Engine</label>
|
| 883 |
+
<select id="settings-engine" style="width:100%; padding:10px; border-radius:6px; background:rgba(255,255,255,0.05); border:1px solid rgba(255,255,255,0.1); color:#fff; font-size:12px; outline:none;">
|
| 884 |
+
<option value="roboflow">Roboflow β Object Detection (boxes)</option>
|
| 885 |
+
<option value="teachable">Teachable Machine β Classification</option>
|
| 886 |
+
</select>
|
| 887 |
+
<p class="setting-desc">Roboflow draws real bounding boxes around each pothole. Teachable Machine classifies the whole frame.</p>
|
| 888 |
+
</div>
|
| 889 |
+
|
| 890 |
+
<!-- Roboflow config (shown when engine = roboflow) -->
|
| 891 |
+
<div class="setting-group" id="settings-roboflow-group">
|
| 892 |
+
<label class="setting-label" style="display:block; margin-bottom: 8px;" for="settings-rf-key">Roboflow Publishable Key</label>
|
| 893 |
+
<input type="text" id="settings-rf-key" placeholder="rf_xxx (publishable key, not the secret API key)" autocomplete="off" style="width:100%; padding:10px; border-radius:6px; background:rgba(255,255,255,0.05); border:1px solid rgba(255,255,255,0.1); color:#fff; font-size:12px; outline:none; margin-bottom:10px;" />
|
| 894 |
+
<div style="display:flex; gap:8px;">
|
| 895 |
+
<input type="text" id="settings-rf-model" placeholder="model id (e.g. pothole-detection-yolov8-ehkp9)" autocomplete="off" style="flex:1; min-width:0; padding:10px; border-radius:6px; background:rgba(255,255,255,0.05); border:1px solid rgba(255,255,255,0.1); color:#fff; font-size:12px; outline:none;" />
|
| 896 |
+
<input type="number" id="settings-rf-version" min="1" step="1" placeholder="ver" style="width:64px; padding:10px; border-radius:6px; background:rgba(255,255,255,0.05); border:1px solid rgba(255,255,255,0.1); color:#fff; font-size:12px; outline:none;" />
|
| 897 |
+
</div>
|
| 898 |
+
<p class="setting-desc">Get a free publishable key at roboflow.com (Settings β API Keys). Find a model id + version on any Roboflow Universe pothole project, or train your own. Runs fully in-browser.</p>
|
| 899 |
+
</div>
|
| 900 |
+
|
| 901 |
+
<!-- Teachable Machine config (shown when engine = teachable) -->
|
| 902 |
+
<div class="setting-group" id="settings-teachable-group">
|
| 903 |
+
<label class="setting-label" style="display:block; margin-bottom: 8px;" for="settings-model-url">Teachable Machine URL</label>
|
| 904 |
+
<input type="text" id="settings-model-url" style="width:100%; padding:10px; border-radius:6px; background:rgba(255,255,255,0.05); border:1px solid rgba(255,255,255,0.1); color:#fff; font-size:12px; outline:none;" />
|
| 905 |
+
<p class="setting-desc">Load custom Google Teachable Machine image classification models.</p>
|
| 906 |
+
</div>
|
| 907 |
+
|
| 908 |
+
<button class="btn-apply-settings" id="btn-settings-apply">Apply Configurations</button>
|
| 909 |
+
</div>
|
| 910 |
+
</div>
|
| 911 |
+
|
| 912 |
+
<!-- ββ EXTERNAL LIBRARIES ββββββββββββββββββββββββββββββββββββββββββββββββββ -->
|
| 913 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js"></script>
|
| 914 |
+
<!--
|
| 915 |
+
Computer-vision engines are loaded lazily by dashcam-demo.js, on demand,
|
| 916 |
+
so only the selected engine's libraries are downloaded:
|
| 917 |
+
β’ Roboflow detection β https://cdn.roboflow.com/0.2.26/roboflow.js (bundles its own TFJS)
|
| 918 |
+
β’ Teachable Machine β @tensorflow/tfjs + @teachablemachine/image
|
| 919 |
+
Loading both statically pulls two TensorFlow.js copies that clash, so we don't.
|
| 920 |
+
-->
|
| 921 |
+
|
| 922 |
+
<!-- ββ DOMAIN HELPER SCRIPTS ββββββββββββββββββββββββββββββββββββββββββββββββ -->
|
| 923 |
+
<script src="gis.js"></script>
|
| 924 |
+
<script src="backend-api.js"></script>
|
| 925 |
+
<script src="box-upload.js"></script>
|
| 926 |
+
|
| 927 |
+
<!-- ββ CORE APPLICATION CONTROLLER βββββββββββββββββββββββββββββββββββββββββ -->
|
| 928 |
+
<script src="dashcam-demo.js"></script>
|
| 929 |
+
|
| 930 |
+
</body>
|
| 931 |
+
</html>
|
dashcam-demo.js
ADDED
|
@@ -0,0 +1,989 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* dashcam-demo.js β Real-Time Mobile Capture Controller
|
| 3 |
+
* Performs edge-inference AI classification on continuous video feed,
|
| 4 |
+
* tracks live coordinates, and handles spatial deduplication.
|
| 5 |
+
*/
|
| 6 |
+
|
| 7 |
+
'use strict';
|
| 8 |
+
|
| 9 |
+
(function () {
|
| 10 |
+
// βββ CONFIGURATION & LOCAL STORAGE βββββββββββββββββββββββββββββββββββββββ
|
| 11 |
+
const CONFIG = {
|
| 12 |
+
engine: localStorage.getItem('dashcam_engine') || 'roboflow', // 'roboflow' | 'teachable'
|
| 13 |
+
modelUrl: localStorage.getItem('tm_model_url') || 'https://teachablemachine.withgoogle.com/models/3HQTi9DMo/',
|
| 14 |
+
rfKey: localStorage.getItem('roboflow_publishable_key') || '',
|
| 15 |
+
rfModel: localStorage.getItem('roboflow_model_id') || 'pothole-detection-yolov8-ehkp9',
|
| 16 |
+
rfVersion: parseInt(localStorage.getItem('roboflow_model_version') || '1', 10),
|
| 17 |
+
sampleRate: parseFloat(localStorage.getItem('dashcam_sample_rate') || '1.0'),
|
| 18 |
+
threshold: parseFloat(localStorage.getItem('dashcam_threshold') || '72'),
|
| 19 |
+
soundEnabled: localStorage.getItem('dashcam_sound_enabled') !== 'false'
|
| 20 |
+
};
|
| 21 |
+
|
| 22 |
+
// βββ APPLICATION STATE βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 23 |
+
const state = {
|
| 24 |
+
stream: null,
|
| 25 |
+
tmModel: null,
|
| 26 |
+
rfModel: null, // Loaded Roboflow detection model
|
| 27 |
+
gpsWatcherId: null,
|
| 28 |
+
currentGPS: null,
|
| 29 |
+
lastDetections: [], // Cache for spatial deduplication { lat, lng, timestamp }
|
| 30 |
+
sessionCount: 0,
|
| 31 |
+
aiTimer: null,
|
| 32 |
+
isProcessing: false,
|
| 33 |
+
audioCtx: null
|
| 34 |
+
};
|
| 35 |
+
|
| 36 |
+
// βββ DOM REFERENCES ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 37 |
+
const DOM = {
|
| 38 |
+
video: document.getElementById('webcam-feed'),
|
| 39 |
+
canvas: document.getElementById('frame-canvas'),
|
| 40 |
+
overlay: document.getElementById('alert-overlay'),
|
| 41 |
+
boxOverlay: document.getElementById('detection-overlay'),
|
| 42 |
+
|
| 43 |
+
// Stats HUD
|
| 44 |
+
statsTotal: document.getElementById('stats-total-potholes'),
|
| 45 |
+
statsSpeed: document.getElementById('stats-speed'),
|
| 46 |
+
statsLatency: document.getElementById('stats-ai-latency'),
|
| 47 |
+
|
| 48 |
+
// Detection Box HUD
|
| 49 |
+
aiBox: document.getElementById('ai-detection-box'),
|
| 50 |
+
aiStatusText: document.getElementById('ai-hud-status-text'),
|
| 51 |
+
aiResultLabel: document.getElementById('ai-hud-result-label'),
|
| 52 |
+
aiBar: document.getElementById('ai-confidence-bar'),
|
| 53 |
+
aiMetaText: document.getElementById('ai-hud-meta-text'),
|
| 54 |
+
|
| 55 |
+
// Location HUD
|
| 56 |
+
gpsBadge: document.getElementById('gps-lock-badge'),
|
| 57 |
+
gpsCoords: document.getElementById('hud-gps-coordinates'),
|
| 58 |
+
gpsPrecision: document.getElementById('hud-gps-precision'),
|
| 59 |
+
detectedWard: document.getElementById('hud-detected-ward'),
|
| 60 |
+
|
| 61 |
+
// Footer Controls
|
| 62 |
+
btnSound: document.getElementById('btn-sound-toggle'),
|
| 63 |
+
btnGallery: document.getElementById('btn-gallery-toggle'),
|
| 64 |
+
drawerCount: document.getElementById('stats-drawer-count'),
|
| 65 |
+
|
| 66 |
+
// Drawer Gallery
|
| 67 |
+
drawer: document.getElementById('gallery-drawer'),
|
| 68 |
+
drawerCountTotal: document.getElementById('drawer-count-badge-total'),
|
| 69 |
+
drawerEmpty: document.getElementById('drawer-empty-state'),
|
| 70 |
+
galleryGrid: document.getElementById('gallery-grid'),
|
| 71 |
+
btnClearSession: document.getElementById('btn-clear-session-action'),
|
| 72 |
+
|
| 73 |
+
// Settings Panel
|
| 74 |
+
settingsOverlay: document.getElementById('settings-overlay'),
|
| 75 |
+
btnSettingsOpen: document.getElementById('btn-settings-open'),
|
| 76 |
+
btnSettingsClose: document.getElementById('btn-settings-close'),
|
| 77 |
+
btnSettingsApply: document.getElementById('btn-settings-apply'),
|
| 78 |
+
|
| 79 |
+
sliderRate: document.getElementById('settings-rate'),
|
| 80 |
+
sliderThreshold: document.getElementById('settings-threshold'),
|
| 81 |
+
inputModelUrl: document.getElementById('settings-model-url'),
|
| 82 |
+
selectEngine: document.getElementById('settings-engine'),
|
| 83 |
+
inputRfKey: document.getElementById('settings-rf-key'),
|
| 84 |
+
inputRfModel: document.getElementById('settings-rf-model'),
|
| 85 |
+
inputRfVersion: document.getElementById('settings-rf-version'),
|
| 86 |
+
groupRoboflow: document.getElementById('settings-roboflow-group'),
|
| 87 |
+
groupTeachable: document.getElementById('settings-teachable-group'),
|
| 88 |
+
|
| 89 |
+
labelRate: document.getElementById('settings-label-rate'),
|
| 90 |
+
labelThreshold: document.getElementById('settings-label-threshold'),
|
| 91 |
+
|
| 92 |
+
btnExit: document.getElementById('btn-exit')
|
| 93 |
+
};
|
| 94 |
+
|
| 95 |
+
// βββ INIT SOUND (WEB AUDIO API) ββββββββββββββββββββββββββββββββββββββββββ
|
| 96 |
+
function initAudioContext() {
|
| 97 |
+
if (!state.audioCtx) {
|
| 98 |
+
state.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
| 99 |
+
}
|
| 100 |
+
if (state.audioCtx.state === 'suspended') {
|
| 101 |
+
state.audioCtx.resume();
|
| 102 |
+
}
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
function playAlertBeep() {
|
| 106 |
+
if (!CONFIG.soundEnabled) return;
|
| 107 |
+
try {
|
| 108 |
+
initAudioContext();
|
| 109 |
+
const ctx = state.audioCtx;
|
| 110 |
+
|
| 111 |
+
// Dual-tone high-frequency notification beep
|
| 112 |
+
const osc1 = ctx.createOscillator();
|
| 113 |
+
const osc2 = ctx.createOscillator();
|
| 114 |
+
const gainNode = ctx.createGain();
|
| 115 |
+
|
| 116 |
+
osc1.type = 'sine';
|
| 117 |
+
osc1.frequency.setValueAtTime(880, ctx.currentTime); // A5 note
|
| 118 |
+
osc1.frequency.exponentialRampToValueAtTime(1200, ctx.currentTime + 0.15);
|
| 119 |
+
|
| 120 |
+
osc2.type = 'triangle';
|
| 121 |
+
osc2.frequency.setValueAtTime(440, ctx.currentTime); // A4 note harmony
|
| 122 |
+
|
| 123 |
+
gainNode.gain.setValueAtTime(0.25, ctx.currentTime);
|
| 124 |
+
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.2);
|
| 125 |
+
|
| 126 |
+
osc1.connect(gainNode);
|
| 127 |
+
osc2.connect(gainNode);
|
| 128 |
+
gainNode.connect(ctx.destination);
|
| 129 |
+
|
| 130 |
+
osc1.start();
|
| 131 |
+
osc2.start();
|
| 132 |
+
|
| 133 |
+
osc1.stop(ctx.currentTime + 0.2);
|
| 134 |
+
osc2.stop(ctx.currentTime + 0.2);
|
| 135 |
+
} catch (e) {
|
| 136 |
+
console.warn('[Sound] Web Audio play failed:', e);
|
| 137 |
+
}
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
// βββ DYNAMIC SETTINGS SYNC βββββββββββββββββββββββββββββββββββββββββββββββ
|
| 141 |
+
function syncSettingsUI() {
|
| 142 |
+
DOM.sliderRate.value = CONFIG.sampleRate;
|
| 143 |
+
DOM.sliderThreshold.value = CONFIG.threshold;
|
| 144 |
+
DOM.inputModelUrl.value = CONFIG.modelUrl;
|
| 145 |
+
|
| 146 |
+
if (DOM.selectEngine) DOM.selectEngine.value = CONFIG.engine;
|
| 147 |
+
if (DOM.inputRfKey) DOM.inputRfKey.value = CONFIG.rfKey;
|
| 148 |
+
if (DOM.inputRfModel) DOM.inputRfModel.value = CONFIG.rfModel;
|
| 149 |
+
if (DOM.inputRfVersion) DOM.inputRfVersion.value = CONFIG.rfVersion;
|
| 150 |
+
toggleEngineSettingGroups();
|
| 151 |
+
|
| 152 |
+
DOM.labelRate.textContent = CONFIG.sampleRate + 's';
|
| 153 |
+
DOM.labelThreshold.textContent = CONFIG.threshold + '%';
|
| 154 |
+
|
| 155 |
+
DOM.aiMetaText.textContent = `Sample Rate: ${CONFIG.sampleRate}s`;
|
| 156 |
+
|
| 157 |
+
if (CONFIG.soundEnabled) {
|
| 158 |
+
DOM.btnSound.classList.remove('muted');
|
| 159 |
+
DOM.btnSound.textContent = 'π';
|
| 160 |
+
} else {
|
| 161 |
+
DOM.btnSound.classList.add('muted');
|
| 162 |
+
DOM.btnSound.textContent = 'π';
|
| 163 |
+
}
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
function toggleEngineSettingGroups() {
|
| 167 |
+
const engine = DOM.selectEngine ? DOM.selectEngine.value : CONFIG.engine;
|
| 168 |
+
if (DOM.groupRoboflow) DOM.groupRoboflow.style.display = engine === 'roboflow' ? 'block' : 'none';
|
| 169 |
+
if (DOM.groupTeachable) DOM.groupTeachable.style.display = engine === 'teachable' ? 'block' : 'none';
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
// βββ CAMERA ACCESS βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 173 |
+
async function startCamera() {
|
| 174 |
+
if (state.stream) {
|
| 175 |
+
state.stream.getTracks().forEach(track => track.stop());
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
const constraints = {
|
| 179 |
+
audio: false,
|
| 180 |
+
video: {
|
| 181 |
+
facingMode: { ideal: 'environment' }, // Request back camera primarily
|
| 182 |
+
width: { ideal: 1280 },
|
| 183 |
+
height: { ideal: 720 }
|
| 184 |
+
}
|
| 185 |
+
};
|
| 186 |
+
|
| 187 |
+
try {
|
| 188 |
+
state.stream = await navigator.mediaDevices.getUserMedia(constraints);
|
| 189 |
+
DOM.video.srcObject = state.stream;
|
| 190 |
+
console.info('[Camera] Rear-facing environment camera stream loaded.');
|
| 191 |
+
} catch (err) {
|
| 192 |
+
console.warn('[Camera] Exact environment mode failed, falling back to basic camera constraints.', err);
|
| 193 |
+
try {
|
| 194 |
+
state.stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false });
|
| 195 |
+
DOM.video.srcObject = state.stream;
|
| 196 |
+
} catch (fallbackErr) {
|
| 197 |
+
console.error('[Camera] WebRTC access denied or unavailable.', fallbackErr);
|
| 198 |
+
alert('Could not access device camera. Please verify camera permissions in settings.');
|
| 199 |
+
}
|
| 200 |
+
}
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
// βββ AI INFERENCE LOOP βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 204 |
+
// Lazily inject a <script> once and resolve when it has loaded. Used to pull
|
| 205 |
+
// each CV engine's libraries on demand so we never load two TFJS copies at once.
|
| 206 |
+
const loadedScripts = {};
|
| 207 |
+
function loadScriptOnce(src) {
|
| 208 |
+
if (loadedScripts[src]) return loadedScripts[src];
|
| 209 |
+
loadedScripts[src] = new Promise((resolve, reject) => {
|
| 210 |
+
const el = document.createElement('script');
|
| 211 |
+
el.src = src;
|
| 212 |
+
el.async = true;
|
| 213 |
+
el.onload = () => resolve();
|
| 214 |
+
el.onerror = () => { delete loadedScripts[src]; reject(new Error(`Failed to load ${src}`)); };
|
| 215 |
+
document.head.appendChild(el);
|
| 216 |
+
});
|
| 217 |
+
return loadedScripts[src];
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
const TFJS_SRC = 'https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@latest/dist/tf.min.js';
|
| 221 |
+
const TEACHABLE_SRC = 'https://cdn.jsdelivr.net/npm/@teachablemachine/image@latest/dist/teachablemachine-image.min.js';
|
| 222 |
+
const ROBOFLOW_SRC = 'https://cdn.roboflow.com/0.2.26/roboflow.js';
|
| 223 |
+
|
| 224 |
+
async function loadAIModel() {
|
| 225 |
+
// Reset any previously-loaded models so a fresh engine starts clean
|
| 226 |
+
state.tmModel = null;
|
| 227 |
+
state.rfModel = null;
|
| 228 |
+
clearDetectionOverlay();
|
| 229 |
+
|
| 230 |
+
if (CONFIG.engine === 'roboflow') {
|
| 231 |
+
await loadRoboflowModel();
|
| 232 |
+
} else {
|
| 233 |
+
await loadTeachableModel();
|
| 234 |
+
}
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
function setAiStatus(status, result, resultColor) {
|
| 238 |
+
DOM.aiStatusText.textContent = status;
|
| 239 |
+
DOM.aiResultLabel.textContent = result;
|
| 240 |
+
DOM.aiResultLabel.style.color = resultColor || 'var(--text-muted)';
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
async function loadTeachableModel() {
|
| 244 |
+
setAiStatus('Loading AI Model...', 'Initialising...');
|
| 245 |
+
|
| 246 |
+
const url = CONFIG.modelUrl.replace(/\/?$/, '/');
|
| 247 |
+
const modelURL = url + 'model.json';
|
| 248 |
+
const metadataURL = url + 'metadata.json';
|
| 249 |
+
|
| 250 |
+
try {
|
| 251 |
+
// Pull TFJS + Teachable Machine on demand (tfjs must load first)
|
| 252 |
+
await loadScriptOnce(TFJS_SRC);
|
| 253 |
+
await loadScriptOnce(TEACHABLE_SRC);
|
| 254 |
+
if (typeof tmImage === 'undefined') {
|
| 255 |
+
throw new Error('Teachable Machine CDN scripts did not load successfully.');
|
| 256 |
+
}
|
| 257 |
+
state.tmModel = await tmImage.load(modelURL, metadataURL);
|
| 258 |
+
console.info('[AI Model] Teachable Machine model loaded successfully from URL:', url);
|
| 259 |
+
|
| 260 |
+
setAiStatus('Continuous Scanning', 'Searching...');
|
| 261 |
+
DOM.aiBox.classList.remove('scanning');
|
| 262 |
+
DOM.aiBox.classList.add('scanning');
|
| 263 |
+
} catch (err) {
|
| 264 |
+
console.error('[AI Model] Teachable Machine loading failed:', err);
|
| 265 |
+
setAiStatus('AI Unavailable', 'Error Loading Model', 'var(--color-large)');
|
| 266 |
+
}
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
async function loadRoboflowModel() {
|
| 270 |
+
if (!CONFIG.rfKey) {
|
| 271 |
+
setAiStatus('Setup Required', 'Add Roboflow Key', 'var(--color-medium)');
|
| 272 |
+
DOM.aiMetaText.textContent = 'Open Settings β paste publishable key';
|
| 273 |
+
DOM.aiBox.classList.remove('scanning');
|
| 274 |
+
return;
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
setAiStatus('Loading AI Model...', `${CONFIG.rfModel} v${CONFIG.rfVersion}`);
|
| 278 |
+
|
| 279 |
+
try {
|
| 280 |
+
// Pull the Roboflow SDK on demand (bundles its own TFJS)
|
| 281 |
+
await loadScriptOnce(ROBOFLOW_SRC);
|
| 282 |
+
if (typeof roboflow === 'undefined') {
|
| 283 |
+
throw new Error('roboflow.js SDK did not initialise.');
|
| 284 |
+
}
|
| 285 |
+
const publishable = CONFIG.rfKey;
|
| 286 |
+
const model = await roboflow
|
| 287 |
+
.auth({ publishable_key: publishable })
|
| 288 |
+
.load({ model: CONFIG.rfModel, version: CONFIG.rfVersion });
|
| 289 |
+
|
| 290 |
+
// Map the capture threshold (e.g. 72%) into the model's confidence filter
|
| 291 |
+
if (typeof model.configure === 'function') {
|
| 292 |
+
model.configure({ threshold: Math.max(0.05, CONFIG.threshold / 100), overlap: 0.5, max_objects: 20 });
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
state.rfModel = model;
|
| 296 |
+
console.info('[AI Model] Roboflow model loaded:', CONFIG.rfModel, 'v' + CONFIG.rfVersion);
|
| 297 |
+
|
| 298 |
+
setAiStatus('Continuous Scanning', 'Searching...');
|
| 299 |
+
DOM.aiBox.classList.remove('scanning');
|
| 300 |
+
DOM.aiBox.classList.add('scanning');
|
| 301 |
+
} catch (err) {
|
| 302 |
+
console.error('[AI Model] Roboflow loading failed:', err);
|
| 303 |
+
setAiStatus('AI Unavailable', 'Model Load Failed', 'var(--color-large)');
|
| 304 |
+
DOM.aiMetaText.textContent = 'Check key, model id, and version in Settings';
|
| 305 |
+
}
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
function startPredictionTimer() {
|
| 309 |
+
if (state.aiTimer) clearInterval(state.aiTimer);
|
| 310 |
+
|
| 311 |
+
state.aiTimer = setInterval(async () => {
|
| 312 |
+
const ready = CONFIG.engine === 'roboflow' ? state.rfModel : state.tmModel;
|
| 313 |
+
if (!ready || state.isProcessing || DOM.video.paused || DOM.video.ended) return;
|
| 314 |
+
|
| 315 |
+
state.isProcessing = true;
|
| 316 |
+
try {
|
| 317 |
+
if (CONFIG.engine === 'roboflow') {
|
| 318 |
+
await runRoboflowPrediction();
|
| 319 |
+
} else {
|
| 320 |
+
await runTeachablePrediction();
|
| 321 |
+
}
|
| 322 |
+
} catch (err) {
|
| 323 |
+
console.warn('[Inference] Prediction loop encountered an error:', err);
|
| 324 |
+
} finally {
|
| 325 |
+
state.isProcessing = false;
|
| 326 |
+
}
|
| 327 |
+
}, CONFIG.sampleRate * 1000);
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
+
async function runTeachablePrediction() {
|
| 331 |
+
const t0 = performance.now();
|
| 332 |
+
|
| 333 |
+
// Draw video frame to hidden canvas
|
| 334 |
+
const ctx = DOM.canvas.getContext('2d');
|
| 335 |
+
ctx.drawImage(DOM.video, 0, 0, DOM.canvas.width, DOM.canvas.height);
|
| 336 |
+
|
| 337 |
+
// Run model prediction
|
| 338 |
+
const predictions = await state.tmModel.predict(DOM.canvas);
|
| 339 |
+
DOM.statsLatency.innerHTML = `${Math.round(performance.now() - t0)}<span class="stat-unit">ms</span>`;
|
| 340 |
+
|
| 341 |
+
const sorted = [...predictions].sort((a, b) => b.probability - a.probability);
|
| 342 |
+
const best = sorted[0];
|
| 343 |
+
const confPct = Math.round(best.probability * 100);
|
| 344 |
+
|
| 345 |
+
updateHUDClassification(best.className, confPct);
|
| 346 |
+
|
| 347 |
+
if (confPct >= CONFIG.threshold && ['Small', 'Medium', 'Large'].includes(best.className)) {
|
| 348 |
+
triggerPotholeDetection(best.className, best.probability);
|
| 349 |
+
}
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
async function runRoboflowPrediction() {
|
| 353 |
+
const t0 = performance.now();
|
| 354 |
+
|
| 355 |
+
// Roboflow detects directly on the live <video> element
|
| 356 |
+
const predictions = await state.rfModel.detect(DOM.video);
|
| 357 |
+
DOM.statsLatency.innerHTML = `${Math.round(performance.now() - t0)}<span class="stat-unit">ms</span>`;
|
| 358 |
+
|
| 359 |
+
// Keep only confident pothole boxes
|
| 360 |
+
const boxes = (Array.isArray(predictions) ? predictions : [])
|
| 361 |
+
.filter(p => p && p.bbox && Number.isFinite(p.confidence))
|
| 362 |
+
.sort((a, b) => b.confidence - a.confidence);
|
| 363 |
+
|
| 364 |
+
// Draw every box on the live overlay
|
| 365 |
+
drawDetectionBoxes(boxes);
|
| 366 |
+
|
| 367 |
+
if (!boxes.length) {
|
| 368 |
+
updateHUDClassification('Searching...', 0, true);
|
| 369 |
+
return;
|
| 370 |
+
}
|
| 371 |
+
|
| 372 |
+
const best = boxes[0];
|
| 373 |
+
const confPct = Math.round(best.confidence * 100);
|
| 374 |
+
const size = sizeFromBoundingBox(best.bbox);
|
| 375 |
+
|
| 376 |
+
updateHUDClassification(`${size} pothole`, confPct);
|
| 377 |
+
|
| 378 |
+
// Capture the strongest detection once it clears the threshold
|
| 379 |
+
if (confPct >= CONFIG.threshold) {
|
| 380 |
+
triggerPotholeDetection(size, best.confidence);
|
| 381 |
+
}
|
| 382 |
+
}
|
| 383 |
+
|
| 384 |
+
// Derive a Small/Medium/Large label from how much of the frame the box covers,
|
| 385 |
+
// so Roboflow detections flow through the same pipeline as Teachable Machine.
|
| 386 |
+
function sizeFromBoundingBox(bbox) {
|
| 387 |
+
const frameW = DOM.video.videoWidth || DOM.video.clientWidth || 1;
|
| 388 |
+
const frameH = DOM.video.videoHeight || DOM.video.clientHeight || 1;
|
| 389 |
+
const areaFraction = (bbox.width * bbox.height) / (frameW * frameH);
|
| 390 |
+
if (areaFraction >= 0.06) return 'Large';
|
| 391 |
+
if (areaFraction >= 0.02) return 'Medium';
|
| 392 |
+
return 'Small';
|
| 393 |
+
}
|
| 394 |
+
|
| 395 |
+
// βββ LIVE BOUNDING-BOX OVERLAY ββββββββββββββββββββββββββββββββββββββββββββ
|
| 396 |
+
const SIZE_BOX_COLORS = { Small: '#10b981', Medium: '#f59e0b', Large: '#ef4444' };
|
| 397 |
+
|
| 398 |
+
function clearDetectionOverlay() {
|
| 399 |
+
if (!DOM.boxOverlay) return;
|
| 400 |
+
const ctx = DOM.boxOverlay.getContext('2d');
|
| 401 |
+
ctx.clearRect(0, 0, DOM.boxOverlay.width, DOM.boxOverlay.height);
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
function drawDetectionBoxes(boxes) {
|
| 405 |
+
if (!DOM.boxOverlay) return;
|
| 406 |
+
const canvas = DOM.boxOverlay;
|
| 407 |
+
const ctx = canvas.getContext('2d');
|
| 408 |
+
|
| 409 |
+
// Match the canvas backing store to the displayed video size
|
| 410 |
+
const dispW = DOM.video.clientWidth || canvas.width;
|
| 411 |
+
const dispH = DOM.video.clientHeight || canvas.height;
|
| 412 |
+
if (canvas.width !== dispW || canvas.height !== dispH) {
|
| 413 |
+
canvas.width = dispW;
|
| 414 |
+
canvas.height = dispH;
|
| 415 |
+
}
|
| 416 |
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
| 417 |
+
if (!boxes || !boxes.length) return;
|
| 418 |
+
|
| 419 |
+
// The video uses object-fit: cover β replicate that mapping so boxes line up
|
| 420 |
+
const vidW = DOM.video.videoWidth || dispW;
|
| 421 |
+
const vidH = DOM.video.videoHeight || dispH;
|
| 422 |
+
const scale = Math.max(dispW / vidW, dispH / vidH);
|
| 423 |
+
const offsetX = (dispW - vidW * scale) / 2;
|
| 424 |
+
const offsetY = (dispH - vidH * scale) / 2;
|
| 425 |
+
|
| 426 |
+
ctx.font = '700 13px Inter, sans-serif';
|
| 427 |
+
ctx.textBaseline = 'bottom';
|
| 428 |
+
|
| 429 |
+
boxes.forEach(p => {
|
| 430 |
+
const size = sizeFromBoundingBox(p.bbox);
|
| 431 |
+
const color = SIZE_BOX_COLORS[size] || '#0061d5';
|
| 432 |
+
// bbox x/y is the box CENTER in source-video pixels
|
| 433 |
+
const w = p.bbox.width * scale;
|
| 434 |
+
const h = p.bbox.height * scale;
|
| 435 |
+
const x = offsetX + (p.bbox.x * scale) - w / 2;
|
| 436 |
+
const y = offsetY + (p.bbox.y * scale) - h / 2;
|
| 437 |
+
|
| 438 |
+
ctx.strokeStyle = color;
|
| 439 |
+
ctx.lineWidth = 3;
|
| 440 |
+
ctx.strokeRect(x, y, w, h);
|
| 441 |
+
|
| 442 |
+
const label = `${size} ${Math.round((p.confidence || 0) * 100)}%`;
|
| 443 |
+
ctx.font = '700 13px Inter, sans-serif';
|
| 444 |
+
const padX = 6;
|
| 445 |
+
const tw = ctx.measureText(label).width + padX * 2;
|
| 446 |
+
const labelY = y > 20 ? y : y + h + 20;
|
| 447 |
+
ctx.fillStyle = color;
|
| 448 |
+
ctx.fillRect(x, labelY - 18, tw, 18);
|
| 449 |
+
ctx.fillStyle = '#fff';
|
| 450 |
+
ctx.fillText(label, x + padX, labelY - 3);
|
| 451 |
+
});
|
| 452 |
+
}
|
| 453 |
+
|
| 454 |
+
function updateHUDClassification(label, confidence, idle = false) {
|
| 455 |
+
if (idle) {
|
| 456 |
+
DOM.aiResultLabel.textContent = label;
|
| 457 |
+
DOM.aiResultLabel.style.color = 'var(--text-muted)';
|
| 458 |
+
DOM.aiBar.style.width = '0%';
|
| 459 |
+
DOM.aiBar.style.backgroundColor = 'var(--color-not-pothole)';
|
| 460 |
+
DOM.aiBox.classList.remove('detected');
|
| 461 |
+
return;
|
| 462 |
+
}
|
| 463 |
+
|
| 464 |
+
DOM.aiResultLabel.textContent = `${label} (${confidence}%)`;
|
| 465 |
+
DOM.aiBar.style.width = `${confidence}%`;
|
| 466 |
+
|
| 467 |
+
// Color by detected size (works for both 'Large' and 'Large pothole' labels)
|
| 468 |
+
let sizeColor = null;
|
| 469 |
+
if (/small/i.test(label)) sizeColor = 'var(--color-small)';
|
| 470 |
+
else if (/medium/i.test(label)) sizeColor = 'var(--color-medium)';
|
| 471 |
+
else if (/large/i.test(label)) sizeColor = 'var(--color-large)';
|
| 472 |
+
|
| 473 |
+
if (confidence < CONFIG.threshold || !sizeColor) {
|
| 474 |
+
DOM.aiResultLabel.style.color = 'var(--text-muted)';
|
| 475 |
+
DOM.aiBar.style.backgroundColor = 'var(--color-not-pothole)';
|
| 476 |
+
DOM.aiBox.classList.remove('detected');
|
| 477 |
+
} else {
|
| 478 |
+
DOM.aiResultLabel.style.color = sizeColor;
|
| 479 |
+
DOM.aiBar.style.backgroundColor = sizeColor;
|
| 480 |
+
DOM.aiBox.classList.add('detected');
|
| 481 |
+
}
|
| 482 |
+
}
|
| 483 |
+
|
| 484 |
+
// βββ GEOLOCATION WATCHER ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 485 |
+
function startGPSWatcher() {
|
| 486 |
+
if (!navigator.geolocation) {
|
| 487 |
+
console.warn('[GPS] Geolocation API not supported by browser.');
|
| 488 |
+
updateGPSHUDStatus(false);
|
| 489 |
+
return;
|
| 490 |
+
}
|
| 491 |
+
|
| 492 |
+
updateGPSHUDStatus(false);
|
| 493 |
+
DOM.gpsCoords.textContent = 'Locking...';
|
| 494 |
+
|
| 495 |
+
state.gpsWatcherId = navigator.geolocation.watchPosition(
|
| 496 |
+
async (position) => {
|
| 497 |
+
const lat = position.coords.latitude;
|
| 498 |
+
const lng = position.coords.longitude;
|
| 499 |
+
const accuracy = position.coords.accuracy || 0;
|
| 500 |
+
const speedMps = position.coords.speed || 0; // Speed in meters per second
|
| 501 |
+
|
| 502 |
+
state.currentGPS = { lat, lng, accuracy, speedMps };
|
| 503 |
+
|
| 504 |
+
updateGPSHUDStatus(true);
|
| 505 |
+
DOM.gpsCoords.textContent = `${lat.toFixed(6)}, ${lng.toFixed(6)}`;
|
| 506 |
+
DOM.gpsPrecision.textContent = `Β± ${accuracy.toFixed(1)} m`;
|
| 507 |
+
|
| 508 |
+
// Calculate speed in MPH
|
| 509 |
+
const speedMph = Math.round(speedMps * 2.23694);
|
| 510 |
+
DOM.statsSpeed.innerHTML = `${speedMph}<span class="stat-unit">mph</span>`;
|
| 511 |
+
|
| 512 |
+
// Retrieve regional boundary ward info if GIS config exists
|
| 513 |
+
try {
|
| 514 |
+
if (typeof window.resolveGISMapping === 'function') {
|
| 515 |
+
const gisMapping = await window.resolveGISMapping(lat, lng, { fallbackArea: '' });
|
| 516 |
+
const detectedWard = gisMapping.ward || gisMapping.district || gisMapping.zone || 'Global Domain';
|
| 517 |
+
DOM.detectedWard.textContent = detectedWard;
|
| 518 |
+
} else {
|
| 519 |
+
// Local simple Philly ward bounds detection
|
| 520 |
+
const localWard = detectPhillyWardLocal(lat, lng);
|
| 521 |
+
DOM.detectedWard.textContent = localWard || 'Metro Area';
|
| 522 |
+
}
|
| 523 |
+
} catch (e) {
|
| 524 |
+
DOM.detectedWard.textContent = 'Metro Region';
|
| 525 |
+
}
|
| 526 |
+
},
|
| 527 |
+
(err) => {
|
| 528 |
+
console.warn('[GPS] Error locked or denied:', err);
|
| 529 |
+
updateGPSHUDStatus(false);
|
| 530 |
+
DOM.gpsCoords.textContent = 'No GPS Signal';
|
| 531 |
+
DOM.gpsPrecision.textContent = '--';
|
| 532 |
+
DOM.statsSpeed.innerHTML = `0<span class="stat-unit">mph</span>`;
|
| 533 |
+
},
|
| 534 |
+
{ enableHighAccuracy: true, timeout: 8000, maximumAge: 0 }
|
| 535 |
+
);
|
| 536 |
+
}
|
| 537 |
+
|
| 538 |
+
function updateGPSHUDStatus(isLocked) {
|
| 539 |
+
if (isLocked) {
|
| 540 |
+
DOM.gpsBadge.className = 'badge-status active';
|
| 541 |
+
DOM.gpsBadge.innerHTML = `<span style="width:6px;height:6px;background:currentColor;border-radius:50%;"></span>GPS Locked`;
|
| 542 |
+
} else {
|
| 543 |
+
DOM.gpsBadge.className = 'badge-status inactive';
|
| 544 |
+
DOM.gpsBadge.innerHTML = `<span style="width:6px;height:6px;background:currentColor;border-radius:50%;"></span>No Signal`;
|
| 545 |
+
}
|
| 546 |
+
}
|
| 547 |
+
|
| 548 |
+
function detectPhillyWardLocal(lat, lng) {
|
| 549 |
+
if (lat > 40.04 && lng > -75.07) return 'Northeast';
|
| 550 |
+
if (lat > 40.02 && lng < -75.17) return 'Northwest';
|
| 551 |
+
if (lat > 40.02 && lng >= -75.17 && lng <= -75.07) return 'North';
|
| 552 |
+
if (lat >= 39.97 && lat <= 40.02 && lng >= -75.20 && lng <= -75.14) return 'Lower North';
|
| 553 |
+
if (lat >= 39.94 && lat <= 40.04 && lng < -75.18) return 'West';
|
| 554 |
+
if (lat >= 39.95 && lat <= 40.02 && lng > -75.09) return 'River Wards';
|
| 555 |
+
if (lat >= 39.94 && lat <= 40.02 && lng >= -75.18 && lng <= -75.09) return 'Central';
|
| 556 |
+
if (lat >= 39.90 && lat <= 39.95 && lng >= -75.20 && lng <= -75.10) return 'South';
|
| 557 |
+
if (lat < 39.94 && lng < -75.15) return 'Southwest';
|
| 558 |
+
return null;
|
| 559 |
+
}
|
| 560 |
+
|
| 561 |
+
// βββ SPATIAL DEDUPLICATION & DETECTIONS βββββββββββββββββββββββββββββββββββ
|
| 562 |
+
function haversineDistance(lat1, lon1, lat2, lon2) {
|
| 563 |
+
const R = 6371000; // Earth radius in meters
|
| 564 |
+
const phi1 = lat1 * Math.PI / 180;
|
| 565 |
+
const phi2 = lat2 * Math.PI / 180;
|
| 566 |
+
const deltaPhi = (lat2 - lat1) * Math.PI / 180;
|
| 567 |
+
const deltaLambda = (lon2 - lon1) * Math.PI / 180;
|
| 568 |
+
|
| 569 |
+
const a = Math.sin(deltaPhi / 2) ** 2 + Math.cos(phi1) * Math.cos(phi2) * Math.sin(deltaLambda / 2) ** 2;
|
| 570 |
+
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
| 571 |
+
}
|
| 572 |
+
|
| 573 |
+
function checkDeduplication(newLat, newLng) {
|
| 574 |
+
const now = Date.now();
|
| 575 |
+
const COOL_DOWN_RADIUS = 15; // meters
|
| 576 |
+
const COOL_DOWN_TIME = 4000; // milliseconds
|
| 577 |
+
|
| 578 |
+
// Clean expired cooldown entries
|
| 579 |
+
state.lastDetections = state.lastDetections.filter(d => now - d.timestamp < COOL_DOWN_TIME);
|
| 580 |
+
|
| 581 |
+
// Scan for collisions
|
| 582 |
+
for (const d of state.lastDetections) {
|
| 583 |
+
const dist = haversineDistance(d.lat, d.lng, newLat, newLng);
|
| 584 |
+
if (dist <= COOL_DOWN_RADIUS) {
|
| 585 |
+
return false; // Suppressed: duplicate found in close proximity and timeframe
|
| 586 |
+
}
|
| 587 |
+
}
|
| 588 |
+
return true; // Approved: new unique detection
|
| 589 |
+
}
|
| 590 |
+
|
| 591 |
+
// Helper: Convert base64 data to Blob object for box-upload
|
| 592 |
+
function dataURLtoBlob(dataurl) {
|
| 593 |
+
const arr = dataurl.split(',');
|
| 594 |
+
const mime = arr[0].match(/:(.*?);/)[1];
|
| 595 |
+
const bstr = atob(arr[1]);
|
| 596 |
+
let n = bstr.length;
|
| 597 |
+
const u8arr = new Uint8Array(n);
|
| 598 |
+
while (n--) {
|
| 599 |
+
u8arr[n] = bstr.charCodeAt(n);
|
| 600 |
+
}
|
| 601 |
+
return new Blob([u8arr], { type: mime });
|
| 602 |
+
}
|
| 603 |
+
|
| 604 |
+
function generateCaseId() {
|
| 605 |
+
const city = localStorage.getItem('city_name') || '';
|
| 606 |
+
const prefix = (city.replace(/[^A-Za-z]/g, '').slice(0, 3) || 'POT').toUpperCase();
|
| 607 |
+
const datePart = new Date().toISOString().slice(0, 10).replace(/-/g, '');
|
| 608 |
+
const randomPart = Math.random().toString(36).substring(2, 8).toUpperCase();
|
| 609 |
+
return `${prefix}-${datePart}-${randomPart}`;
|
| 610 |
+
}
|
| 611 |
+
|
| 612 |
+
// βββ OFFLINE PERSISTENT UPLOAD QUEUE βββββββββββββββββββββββββββββββββββββ
|
| 613 |
+
let isQueueRunning = false;
|
| 614 |
+
|
| 615 |
+
function getPendingUploads() {
|
| 616 |
+
try {
|
| 617 |
+
return JSON.parse(localStorage.getItem('dashcam_pending_uploads') || '[]');
|
| 618 |
+
} catch (e) {
|
| 619 |
+
return [];
|
| 620 |
+
}
|
| 621 |
+
}
|
| 622 |
+
|
| 623 |
+
function savePendingUploads(queue) {
|
| 624 |
+
localStorage.setItem('dashcam_pending_uploads', JSON.stringify(queue));
|
| 625 |
+
}
|
| 626 |
+
|
| 627 |
+
function saveToHistory(record) {
|
| 628 |
+
try {
|
| 629 |
+
const history = JSON.parse(localStorage.getItem('submitted_cases') || '[]');
|
| 630 |
+
const idx = history.findIndex(x => x.caseId === record.caseId);
|
| 631 |
+
if (idx >= 0) {
|
| 632 |
+
history[idx] = record;
|
| 633 |
+
} else {
|
| 634 |
+
history.push(record);
|
| 635 |
+
}
|
| 636 |
+
localStorage.setItem('submitted_cases', JSON.stringify(history));
|
| 637 |
+
} catch (e) {
|
| 638 |
+
console.warn('[Queue] Failed saving to history:', e);
|
| 639 |
+
}
|
| 640 |
+
}
|
| 641 |
+
|
| 642 |
+
async function processUploadQueue() {
|
| 643 |
+
if (isQueueRunning) return;
|
| 644 |
+
const queue = getPendingUploads();
|
| 645 |
+
if (queue.length === 0) return;
|
| 646 |
+
|
| 647 |
+
isQueueRunning = true;
|
| 648 |
+
console.log(`[Queue] Processing ${queue.length} pending dashcam uploads...`);
|
| 649 |
+
|
| 650 |
+
for (const item of queue) {
|
| 651 |
+
updateGalleryCardStatus(item.caseId, 'syncing');
|
| 652 |
+
try {
|
| 653 |
+
const blob = dataURLtoBlob(item.photoDataUrl);
|
| 654 |
+
|
| 655 |
+
// 1. Submit to Box (uploads photo and metadata sidecar)
|
| 656 |
+
let uploadResult = null;
|
| 657 |
+
if (window.BoxUpload) {
|
| 658 |
+
uploadResult = await window.BoxUpload.submitToBox(blob, item.metadata);
|
| 659 |
+
}
|
| 660 |
+
|
| 661 |
+
const success = uploadResult ? uploadResult.success : false;
|
| 662 |
+
|
| 663 |
+
if (success) {
|
| 664 |
+
console.log(`[Queue] Box upload success for case: ${item.caseId}`);
|
| 665 |
+
|
| 666 |
+
// 2. Build full case record for backend database
|
| 667 |
+
const caseRecord = {
|
| 668 |
+
caseId: item.caseId,
|
| 669 |
+
status: 'Submitted',
|
| 670 |
+
ts: item.timestampMs,
|
| 671 |
+
timestamp: item.timestampIso,
|
| 672 |
+
report: {
|
| 673 |
+
...item.metadata,
|
| 674 |
+
photoFileId: uploadResult.photoFileId || '',
|
| 675 |
+
sidecarFileId: uploadResult.sidecarFileId || ''
|
| 676 |
+
}
|
| 677 |
+
};
|
| 678 |
+
|
| 679 |
+
// 3. Upsert to backend database so app stays fast
|
| 680 |
+
if (window.PotholeBackend) {
|
| 681 |
+
await window.PotholeBackend.upsertCase(caseRecord);
|
| 682 |
+
console.log(`[Queue] Backend database sync success for case: ${item.caseId}`);
|
| 683 |
+
}
|
| 684 |
+
|
| 685 |
+
// 4. Update local submission history
|
| 686 |
+
saveToHistory(caseRecord);
|
| 687 |
+
|
| 688 |
+
// Remove from pending queue
|
| 689 |
+
const currentQueue = getPendingUploads().filter(x => x.caseId !== item.caseId);
|
| 690 |
+
savePendingUploads(currentQueue);
|
| 691 |
+
|
| 692 |
+
updateGalleryCardStatus(item.caseId, 'uploaded');
|
| 693 |
+
} else {
|
| 694 |
+
// If Box config is disabled/broken, we fallback to uploading directly to Node database!
|
| 695 |
+
const errorMsg = uploadResult?.error || '';
|
| 696 |
+
if (errorMsg.includes('Box is not configured') || errorMsg.includes('Box authentication')) {
|
| 697 |
+
console.log(`[Queue] Box config unavailable. Saving directly to backend database for: ${item.caseId}`);
|
| 698 |
+
|
| 699 |
+
const caseRecord = {
|
| 700 |
+
caseId: item.caseId,
|
| 701 |
+
status: 'Submitted',
|
| 702 |
+
ts: item.timestampMs,
|
| 703 |
+
timestamp: item.timestampIso,
|
| 704 |
+
report: item.metadata
|
| 705 |
+
};
|
| 706 |
+
|
| 707 |
+
if (window.PotholeBackend) {
|
| 708 |
+
await window.PotholeBackend.upsertCase(caseRecord);
|
| 709 |
+
console.log(`[Queue] Saved directly to database successfully: ${item.caseId}`);
|
| 710 |
+
}
|
| 711 |
+
|
| 712 |
+
saveToHistory(caseRecord);
|
| 713 |
+
|
| 714 |
+
const currentQueue = getPendingUploads().filter(x => x.caseId !== item.caseId);
|
| 715 |
+
savePendingUploads(currentQueue);
|
| 716 |
+
|
| 717 |
+
updateGalleryCardStatus(item.caseId, 'uploaded');
|
| 718 |
+
} else {
|
| 719 |
+
throw new Error(errorMsg || 'Box upload failed.');
|
| 720 |
+
}
|
| 721 |
+
}
|
| 722 |
+
} catch (err) {
|
| 723 |
+
console.warn(`[Queue] Upload failed for case ${item.caseId}. Retrying later.`, err);
|
| 724 |
+
updateGalleryCardStatus(item.caseId, 'failed');
|
| 725 |
+
// Stop queue runner immediately on failure (network is likely offline or weak)
|
| 726 |
+
break;
|
| 727 |
+
}
|
| 728 |
+
}
|
| 729 |
+
isQueueRunning = false;
|
| 730 |
+
}
|
| 731 |
+
|
| 732 |
+
function triggerPotholeDetection(size, confidence) {
|
| 733 |
+
const lat = state.currentGPS ? state.currentGPS.lat : 39.9526; // Default to Philly center if offline
|
| 734 |
+
const lng = state.currentGPS ? state.currentGPS.lng : -75.1652;
|
| 735 |
+
|
| 736 |
+
// Evaluate spatial deduplication
|
| 737 |
+
const isNewUnique = checkDeduplication(lat, lng);
|
| 738 |
+
if (!isNewUnique) {
|
| 739 |
+
console.log(`[Deduplication] Pothole capture suppressed inside 15m radius cooldown at (${lat.toFixed(5)}, ${lng.toFixed(5)}).`);
|
| 740 |
+
return;
|
| 741 |
+
}
|
| 742 |
+
|
| 743 |
+
// Capture success β register coordinates in spatial index
|
| 744 |
+
state.lastDetections.push({ lat, lng, timestamp: Date.now() });
|
| 745 |
+
|
| 746 |
+
// Snap a fresh frame from the live video into the hidden canvas, then export.
|
| 747 |
+
// (The Roboflow engine detects on the <video> directly and never touches this
|
| 748 |
+
// canvas, so we must draw the current frame here for an accurate snapshot.)
|
| 749 |
+
try {
|
| 750 |
+
const snapCtx = DOM.canvas.getContext('2d');
|
| 751 |
+
snapCtx.drawImage(DOM.video, 0, 0, DOM.canvas.width, DOM.canvas.height);
|
| 752 |
+
} catch (e) {
|
| 753 |
+
console.warn('[Capture] could not draw video frame to snapshot canvas:', e);
|
| 754 |
+
}
|
| 755 |
+
const imageBase64 = DOM.canvas.toDataURL('image/jpeg', 0.82);
|
| 756 |
+
|
| 757 |
+
// Audio & Screen Flash visual alert
|
| 758 |
+
playAlertBeep();
|
| 759 |
+
DOM.overlay.classList.remove('flash');
|
| 760 |
+
void DOM.overlay.offsetWidth; // Force CSS reflow
|
| 761 |
+
DOM.overlay.classList.add('flash');
|
| 762 |
+
|
| 763 |
+
state.sessionCount++;
|
| 764 |
+
DOM.statsTotal.textContent = state.sessionCount;
|
| 765 |
+
DOM.drawerCount.textContent = state.sessionCount;
|
| 766 |
+
DOM.drawerCountTotal.textContent = `${state.sessionCount} items`;
|
| 767 |
+
|
| 768 |
+
const caseId = generateCaseId();
|
| 769 |
+
const timestampIso = new Date().toISOString();
|
| 770 |
+
const timestampMs = Date.now();
|
| 771 |
+
|
| 772 |
+
// Build Case Metadata
|
| 773 |
+
const metadata = {
|
| 774 |
+
caseId,
|
| 775 |
+
timestamp: timestampIso,
|
| 776 |
+
city: localStorage.getItem('city_name') || 'Philadelphia',
|
| 777 |
+
department: localStorage.getItem('dept_name') || 'Department of Streets',
|
| 778 |
+
description: 'Auto-captured from moving vehicle.',
|
| 779 |
+
anonymous: true,
|
| 780 |
+
location: { lat, lng, source: 'device' },
|
| 781 |
+
locationSource: 'device',
|
| 782 |
+
address: DOM.gpsCoords.textContent !== 'Waiting...' ? (state.address || DOM.gpsCoords.textContent) : 'Windshield Capture',
|
| 783 |
+
ward: DOM.detectedWard.textContent !== '--' ? DOM.detectedWard.textContent : 'Metro Area',
|
| 784 |
+
aiResult: size,
|
| 785 |
+
aiConfidence: parseFloat(confidence.toFixed(4))
|
| 786 |
+
};
|
| 787 |
+
|
| 788 |
+
// Add entry to persistent upload queue
|
| 789 |
+
const queue = getPendingUploads();
|
| 790 |
+
queue.push({
|
| 791 |
+
caseId,
|
| 792 |
+
metadata,
|
| 793 |
+
photoDataUrl: imageBase64,
|
| 794 |
+
timestampIso,
|
| 795 |
+
timestampMs
|
| 796 |
+
});
|
| 797 |
+
savePendingUploads(queue);
|
| 798 |
+
|
| 799 |
+
// Add entry to UI gallery (starts as queued)
|
| 800 |
+
addPotholeToGallery(caseId, size, confidence, lat, lng, imageBase64, 'queued');
|
| 801 |
+
|
| 802 |
+
// Trigger queue runner immediately
|
| 803 |
+
processUploadQueue();
|
| 804 |
+
}
|
| 805 |
+
|
| 806 |
+
function addPotholeToGallery(caseId, size, confidence, lat, lng, imageData, status = 'queued') {
|
| 807 |
+
// Hide empty state
|
| 808 |
+
DOM.drawerEmpty.style.display = 'none';
|
| 809 |
+
DOM.galleryGrid.style.display = 'grid';
|
| 810 |
+
|
| 811 |
+
const timestamp = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
| 812 |
+
const sizeColors = { Small: '#10b981', Medium: '#f59e0b', Large: '#ef4444' };
|
| 813 |
+
const color = sizeColors[size] || '#6b7280';
|
| 814 |
+
const confPct = Math.round(confidence * 100);
|
| 815 |
+
|
| 816 |
+
const card = document.createElement('div');
|
| 817 |
+
card.className = 'gallery-card';
|
| 818 |
+
card.id = `card-${caseId}`;
|
| 819 |
+
|
| 820 |
+
const statusText = status === 'uploaded' ? 'βοΈ Sync Complete' : status === 'syncing' ? 'π Syncing' : status === 'failed' ? 'β Weak Signal' : 'β³ Queued';
|
| 821 |
+
const statusBg = status === 'uploaded' ? 'rgba(16, 185, 129, 0.15)' : status === 'syncing' ? 'rgba(245, 158, 11, 0.15)' : status === 'failed' ? 'rgba(239, 68, 68, 0.15)' : 'rgba(255, 255, 255, 0.08)';
|
| 822 |
+
const statusColor = status === 'uploaded' ? '#10b981' : status === 'syncing' ? '#f59e0b' : status === 'failed' ? '#ef4444' : '#9ca3af';
|
| 823 |
+
|
| 824 |
+
card.innerHTML = `
|
| 825 |
+
<div class="gallery-thumb-container">
|
| 826 |
+
<img src="${imageData}" class="gallery-thumb" alt="Captured Pothole" />
|
| 827 |
+
<span class="gallery-size-tag" style="background:${color}">${size} (${confPct}%)</span>
|
| 828 |
+
</div>
|
| 829 |
+
<div class="gallery-info">
|
| 830 |
+
<span class="gallery-time">π ${timestamp}</span>
|
| 831 |
+
<span class="gallery-coords">${lat.toFixed(5)}, ${lng.toFixed(5)}</span>
|
| 832 |
+
<span id="sync-tag-${caseId}" style="display:inline-block; font-size:10px; font-weight:700; padding:2px 6px; border-radius:4px; margin-top:6px; background:${statusBg}; color:${statusColor}; text-transform:uppercase; text-align:center;">
|
| 833 |
+
${statusText}
|
| 834 |
+
</span>
|
| 835 |
+
</div>
|
| 836 |
+
`;
|
| 837 |
+
|
| 838 |
+
// Prepend to display latest first
|
| 839 |
+
DOM.galleryGrid.insertBefore(card, DOM.galleryGrid.firstChild);
|
| 840 |
+
}
|
| 841 |
+
|
| 842 |
+
function updateGalleryCardStatus(caseId, status) {
|
| 843 |
+
const el = document.getElementById(`sync-tag-${caseId}`);
|
| 844 |
+
if (!el) return;
|
| 845 |
+
|
| 846 |
+
const statusText = status === 'uploaded' ? 'βοΈ Sync Complete' : status === 'syncing' ? 'π Syncing' : status === 'failed' ? 'β Weak Signal' : 'β³ Queued';
|
| 847 |
+
const statusBg = status === 'uploaded' ? 'rgba(16, 185, 129, 0.15)' : status === 'syncing' ? 'rgba(245, 158, 11, 0.15)' : status === 'failed' ? 'rgba(239, 68, 68, 0.15)' : 'rgba(255, 255, 255, 0.08)';
|
| 848 |
+
const statusColor = status === 'uploaded' ? '#10b981' : status === 'syncing' ? '#f59e0b' : status === 'failed' ? '#ef4444' : '#9ca3af';
|
| 849 |
+
|
| 850 |
+
el.textContent = statusText;
|
| 851 |
+
el.style.background = statusBg;
|
| 852 |
+
el.style.color = statusColor;
|
| 853 |
+
}
|
| 854 |
+
|
| 855 |
+
// βββ USER INTERFACE CONTROLS & EVENTS βββββββββββββββββββββββββββββββββββ
|
| 856 |
+
function wireEvents() {
|
| 857 |
+
// Exit button
|
| 858 |
+
DOM.btnExit.addEventListener('click', () => {
|
| 859 |
+
// Clear interval and GPS watchers
|
| 860 |
+
if (state.aiTimer) clearInterval(state.aiTimer);
|
| 861 |
+
if (state.gpsWatcherId) navigator.geolocation.clearWatch(state.gpsWatcherId);
|
| 862 |
+
|
| 863 |
+
// Stop camera tracks
|
| 864 |
+
if (state.stream) {
|
| 865 |
+
state.stream.getTracks().forEach(track => track.stop());
|
| 866 |
+
}
|
| 867 |
+
|
| 868 |
+
// Navigate back
|
| 869 |
+
window.location.href = 'index.html';
|
| 870 |
+
});
|
| 871 |
+
|
| 872 |
+
// Sound toggle button
|
| 873 |
+
DOM.btnSound.addEventListener('click', () => {
|
| 874 |
+
CONFIG.soundEnabled = !CONFIG.soundEnabled;
|
| 875 |
+
localStorage.setItem('dashcam_sound_enabled', CONFIG.soundEnabled);
|
| 876 |
+
initAudioContext();
|
| 877 |
+
syncSettingsUI();
|
| 878 |
+
});
|
| 879 |
+
|
| 880 |
+
// Drawer gallery slide toggle
|
| 881 |
+
DOM.btnGallery.addEventListener('click', () => {
|
| 882 |
+
DOM.drawer.classList.toggle('open');
|
| 883 |
+
DOM.btnGallery.classList.toggle('active');
|
| 884 |
+
});
|
| 885 |
+
|
| 886 |
+
// Settings overlay toggle
|
| 887 |
+
DOM.btnSettingsOpen.addEventListener('click', () => {
|
| 888 |
+
DOM.settingsOverlay.classList.add('visible');
|
| 889 |
+
});
|
| 890 |
+
|
| 891 |
+
DOM.btnSettingsClose.addEventListener('click', () => {
|
| 892 |
+
DOM.settingsOverlay.classList.remove('visible');
|
| 893 |
+
});
|
| 894 |
+
|
| 895 |
+
// Engine selector toggles which config block is visible
|
| 896 |
+
if (DOM.selectEngine) {
|
| 897 |
+
DOM.selectEngine.addEventListener('change', toggleEngineSettingGroups);
|
| 898 |
+
}
|
| 899 |
+
|
| 900 |
+
// Apply Settings
|
| 901 |
+
DOM.btnSettingsApply.addEventListener('click', () => {
|
| 902 |
+
const newRate = parseFloat(DOM.sliderRate.value);
|
| 903 |
+
const newThreshold = parseFloat(DOM.sliderThreshold.value);
|
| 904 |
+
const newUrl = DOM.inputModelUrl.value.trim();
|
| 905 |
+
const newEngine = DOM.selectEngine ? DOM.selectEngine.value : CONFIG.engine;
|
| 906 |
+
const newRfKey = DOM.inputRfKey ? DOM.inputRfKey.value.trim() : CONFIG.rfKey;
|
| 907 |
+
const newRfModel = DOM.inputRfModel ? DOM.inputRfModel.value.trim() : CONFIG.rfModel;
|
| 908 |
+
const newRfVersion = DOM.inputRfVersion ? (parseInt(DOM.inputRfVersion.value, 10) || 1) : CONFIG.rfVersion;
|
| 909 |
+
|
| 910 |
+
let modelChanged = false;
|
| 911 |
+
if (newEngine !== CONFIG.engine) { CONFIG.engine = newEngine; localStorage.setItem('dashcam_engine', newEngine); modelChanged = true; }
|
| 912 |
+
if (newUrl !== CONFIG.modelUrl) { CONFIG.modelUrl = newUrl; localStorage.setItem('tm_model_url', newUrl); modelChanged = true; }
|
| 913 |
+
if (newRfKey !== CONFIG.rfKey) { CONFIG.rfKey = newRfKey; localStorage.setItem('roboflow_publishable_key', newRfKey); modelChanged = true; }
|
| 914 |
+
if (newRfModel !== CONFIG.rfModel) { CONFIG.rfModel = newRfModel; localStorage.setItem('roboflow_model_id', newRfModel); modelChanged = true; }
|
| 915 |
+
if (newRfVersion !== CONFIG.rfVersion) { CONFIG.rfVersion = newRfVersion; localStorage.setItem('roboflow_model_version', String(newRfVersion)); modelChanged = true; }
|
| 916 |
+
|
| 917 |
+
const thresholdChanged = newThreshold !== CONFIG.threshold;
|
| 918 |
+
CONFIG.sampleRate = newRate;
|
| 919 |
+
CONFIG.threshold = newThreshold;
|
| 920 |
+
localStorage.setItem('dashcam_sample_rate', newRate);
|
| 921 |
+
localStorage.setItem('dashcam_threshold', newThreshold);
|
| 922 |
+
|
| 923 |
+
syncSettingsUI();
|
| 924 |
+
DOM.settingsOverlay.classList.remove('visible');
|
| 925 |
+
|
| 926 |
+
if (modelChanged) {
|
| 927 |
+
loadAIModel();
|
| 928 |
+
} else if (thresholdChanged && CONFIG.engine === 'roboflow' && state.rfModel && typeof state.rfModel.configure === 'function') {
|
| 929 |
+
// Re-apply confidence filter without re-downloading the model
|
| 930 |
+
state.rfModel.configure({ threshold: Math.max(0.05, CONFIG.threshold / 100), overlap: 0.5, max_objects: 20 });
|
| 931 |
+
}
|
| 932 |
+
startPredictionTimer();
|
| 933 |
+
});
|
| 934 |
+
|
| 935 |
+
// Live slider values display
|
| 936 |
+
DOM.sliderRate.addEventListener('input', () => {
|
| 937 |
+
DOM.labelRate.textContent = DOM.sliderRate.value + 's';
|
| 938 |
+
});
|
| 939 |
+
|
| 940 |
+
DOM.sliderThreshold.addEventListener('input', () => {
|
| 941 |
+
DOM.labelThreshold.textContent = DOM.sliderThreshold.value + '%';
|
| 942 |
+
});
|
| 943 |
+
|
| 944 |
+
// Clear session log
|
| 945 |
+
DOM.btnClearSession.addEventListener('click', () => {
|
| 946 |
+
if (confirm('Are you sure you want to clear the captured session log?')) {
|
| 947 |
+
DOM.galleryGrid.innerHTML = '';
|
| 948 |
+
DOM.galleryGrid.style.display = 'none';
|
| 949 |
+
DOM.drawerEmpty.style.display = 'flex';
|
| 950 |
+
state.sessionCount = 0;
|
| 951 |
+
|
| 952 |
+
DOM.statsTotal.textContent = '0';
|
| 953 |
+
DOM.drawerCount.textContent = '0';
|
| 954 |
+
DOM.drawerCountTotal.textContent = '0 items';
|
| 955 |
+
|
| 956 |
+
// Also clear any unsent uploads
|
| 957 |
+
savePendingUploads([]);
|
| 958 |
+
}
|
| 959 |
+
});
|
| 960 |
+
|
| 961 |
+
// Tap target on video to trigger audio permission initialization
|
| 962 |
+
DOM.video.addEventListener('click', () => {
|
| 963 |
+
initAudioContext();
|
| 964 |
+
});
|
| 965 |
+
}
|
| 966 |
+
|
| 967 |
+
// βββ STARTUP βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 968 |
+
async function init() {
|
| 969 |
+
syncSettingsUI();
|
| 970 |
+
wireEvents();
|
| 971 |
+
|
| 972 |
+
await startCamera();
|
| 973 |
+
await loadAIModel();
|
| 974 |
+
|
| 975 |
+
startPredictionTimer();
|
| 976 |
+
startGPSWatcher();
|
| 977 |
+
|
| 978 |
+
// Start background queue processing runner loop (every 12 seconds)
|
| 979 |
+
setInterval(processUploadQueue, 12000);
|
| 980 |
+
// Process anything remaining from prior session on boot
|
| 981 |
+
processUploadQueue();
|
| 982 |
+
}
|
| 983 |
+
|
| 984 |
+
if (document.readyState === 'loading') {
|
| 985 |
+
document.addEventListener('DOMContentLoaded', init);
|
| 986 |
+
} else {
|
| 987 |
+
init();
|
| 988 |
+
}
|
| 989 |
+
})();
|