Files
workorders/CLAUDE.md
T

2558 lines
117 KiB
Markdown

# Work Order System — Build TODO
> Hand this file to Claude Code. All frontend files use `.mjs` extensions.
> Stack: Go · Pure JS + Web Components · SQL Server · Docker
---
## Table of Contents
- [Design System](#design-system)
- [Project Structure](#project-structure)
- [Phase 1 — Foundation](#phase-1--foundation)
- [Phase 2 — Field Features](#phase-2--field-features)
- [Phase 3 — Integrations & Admin](#phase-3--integrations--admin)
- [Phase 4 — Polish & Optimization](#phase-4--polish--optimization)
- [Component Inventory](#component-inventory)
- [API Endpoints](#api-endpoints)
- [Database Schema](#database-schema)
- [Conventions](#conventions)
---
## Design System
### Color Palette
```css
:root {
/* Brand */
--navy: #0D2137; /* Primary dark — sidebar, headers */
--teal: #0A7EA4; /* Primary accent — buttons, links, active states */
--teal-lt: #14B8D4; /* Light accent — hover states, highlights */
--teal-dk: #075E7A; /* Dark accent — pressed states */
/* Surfaces */
--bg: #F0F6FA; /* Page background */
--surface: #FFFFFF; /* Cards, panels */
--surface-2: #E8F0F5; /* Inset areas, table stripes */
--sidebar-bg: #0D2137; /* Sidebar background */
--sidebar-hover: #153248;
--sidebar-active: #0A7EA4;
/* Text */
--text: #1A2E3B; /* Primary text */
--text-muted: #64748B; /* Secondary / helper text */
--text-inv: #FFFFFF; /* Text on dark backgrounds */
/* Semantic */
--success: #1D9D6C;
--warning: #E07B39;
--danger: #C0392B;
--info: #0A7EA4;
/* Borders & Shadows */
--border: #D1DDE6;
--border-lt: #E8F0F5;
--shadow-sm: 0 1px 3px rgba(0,0,0,.08);
--shadow-md: 0 4px 12px rgba(0,0,0,.10);
--shadow-lg: 0 8px 24px rgba(0,0,0,.12);
/* Status Pills */
--status-draft: #94A3B8;
--status-assigned: #0A7EA4;
--status-scheduled: #8B5CF6;
--status-in_progress: #E07B39;
--status-pending_review: #D97706;
--status-closed: #1D9D6C;
/* Priority */
--priority-low: #64748B;
--priority-normal: #0A7EA4;
--priority-high: #E07B39;
--priority-urgent: #C0392B;
/* Spacing */
--radius-sm: 4px;
--radius: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
/* Sidebar width */
--sidebar-w: 260px;
--sidebar-collapsed: 64px;
}
```
### Typography
Load via `<link>` in `index.html` — no build step needed:
```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
```
```css
:root {
--font-body: 'Inter', 'Segoe UI', system-ui, sans-serif;
--font-mono: 'JetBrains Mono', 'Cascadia Code', monospace;
--text-xs: 0.70rem; /* 11px — labels, caps */
--text-sm: 0.813rem; /* 13px — helper, meta */
--text-base: 0.938rem; /* 15px — body */
--text-md: 1.063rem; /* 17px — subheadings */
--text-lg: 1.25rem; /* 20px — section headers */
--text-xl: 1.5rem; /* 24px — page titles */
--text-2xl: 2rem; /* 32px — dashboard hero numbers */
--weight-normal: 400;
--weight-medium: 500;
--weight-semibold: 600;
--weight-bold: 700;
}
```
### Component Design Patterns
- Cards: white background, `var(--border)` border, `var(--radius)` corners, `var(--shadow-sm)`
- Buttons: filled primary = `--teal`, ghost = transparent + `--teal` border, danger = `--danger`
- Inputs: `--border` border, focus ring `--teal` 2px outline-offset, `--radius-sm` corners
- Tables: header row `--surface-2`, alternating rows, sticky header on scroll
- Status pills: colored dot + label, pill shape, light background tint of the status color
- Icon + label pattern everywhere — use Lucide icons loaded via CDN (no npm)
### Icons
```html
<!-- index.html — Lucide icon CDN, no API key, tree-shakeable -->
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
```
Call `lucide.createIcons()` after each render. Use named icons:
`clipboard-list`, `users`, `truck`, `wrench`, `map-pin`, `camera`, `file-invoice`,
`layout-dashboard`, `settings`, `bell`, `chevron-left`, `plus`, `search`, `filter`
---
## Project Structure
```
workorder/
├── CLAUDE.md ← !! READ FIRST — conventions for Claude Code
├── cmd/server/main.go
├── internal/
│ ├── api/
│ │ ├── router.go
│ │ ├── middleware/
│ │ │ ├── auth.go
│ │ │ ├── cors.go
│ │ │ └── logger.go
│ │ └── handlers/
│ │ ├── auth.go
│ │ ├── workorder.go
│ │ ├── step.go
│ │ ├── resource.go
│ │ ├── attachment.go
│ │ ├── accounting.go
│ │ ├── user.go
│ │ ├── registry.go ← people/vehicle/equipment/material master lists
│ │ ├── dashboard.go
│ │ └── report.go
│ ├── service/
│ │ ├── workorder.go
│ │ ├── notification.go
│ │ ├── spatial.go
│ │ └── export.go ← CSV/Excel export
│ ├── repository/
│ │ ├── db.go
│ │ ├── workorder.go
│ │ ├── step.go
│ │ ├── resource.go
│ │ ├── attachment.go
│ │ ├── accounting.go
│ │ ├── user.go
│ │ └── registry.go
│ ├── model/
│ │ ├── workorder.go
│ │ ├── step.go
│ │ ├── resource.go
│ │ ├── attachment.go
│ │ ├── accounting.go
│ │ ├── user.go
│ │ └── dashboard.go
│ └── config/config.go
│
├── web/ ← All frontend — served as static files by Go
│ ├── index.html ← App shell, loads fonts, Lucide, Leaflet
│ ├── app.mjs ← <app-root> custom element + client router
│ │
│ ├── components/
│ │ ├── layout/
│ │ │ ├── app-sidebar.mjs ← Left nav — collapsible, mobile drawer
│ │ │ ├── app-topbar.mjs ← Top bar — breadcrumb, notifications bell, user avatar
│ │ │ ├── app-root.mjs ← Shell that wires sidebar + topbar + <main>
│ │ │ └── app-mobile-nav.mjs ← Bottom tab bar for mobile (≤768px)
│ │ │
│ │ ├── work-orders/
│ │ │ ├── wo-list.mjs ← Searchable, filterable WO list
│ │ │ ├── wo-kanban.mjs ← Kanban board view (by status column)
│ │ │ ├── wo-form.mjs ← Create / edit full form
│ │ │ ├── wo-detail.mjs ← Read-only detail — tabs for each section
│ │ │ ├── wo-checklist.mjs ← Step checklist with check-off + progress bar
│ │ │ ├── wo-resource-panel.mjs← Assign people, vehicles, equipment, materials
│ │ │ ├── wo-photo-panel.mjs ← Photo gallery, capture, before/during/after
│ │ │ ├── wo-map.mjs ← Leaflet map + directions button
│ │ │ ├── wo-accounting.mjs ← GL, cost center, WBS, billing ref fields
│ │ │ ├── wo-timeline.mjs ← Audit/activity log feed
│ │ │ └── wo-print.mjs ← Print-friendly layout for field packets
│ │ │
│ │ ├── dashboard/
│ │ │ ├── dash-root.mjs ← Dashboard page shell
│ │ │ ├── dash-kpi-card.mjs ← Reusable stat card (number + trend + icon)
│ │ │ ├── dash-status-chart.mjs← Doughnut chart — WOs by status
│ │ │ ├── dash-priority-bar.mjs← Bar chart — WOs by priority
│ │ │ └── dash-recent-feed.mjs ← Recent activity feed
│ │ │
│ │ ├── registry/
│ │ │ ├── people-list.mjs ← Manage crew / technician records
│ │ │ ├── people-form.mjs
│ │ │ ├── vehicle-list.mjs ← Manage fleet / vehicles
│ │ │ ├── vehicle-form.mjs
│ │ │ ├── equipment-list.mjs ← Manage tools & equipment
│ │ │ ├── equipment-form.mjs
│ │ │ ├── material-list.mjs ← Manage materials / inventory
│ │ │ └── material-form.mjs
│ │ │
│ │ ├── users/
│ │ │ ├── user-list.mjs ← User management (admin only)
│ │ │ ├── user-form.mjs ← Create / edit user, assign role
│ │ │ └── user-profile.mjs ← Current user profile + password change
│ │ │
│ │ ├── reports/
│ │ │ ├── report-root.mjs ← Reports landing page
│ │ │ ├── report-by-status.mjs
│ │ │ ├── report-by-cost.mjs
│ │ │ └── report-export.mjs ← CSV / Excel download triggers
│ │ │
│ │ └── shared/
│ │ ├── ui-badge.mjs ← <ui-badge> — status + priority pills
│ │ ├── ui-button.mjs ← <ui-button> — primary/ghost/danger variants
│ │ ├── ui-card.mjs ← <ui-card> — surface container
│ │ ├── ui-dialog.mjs ← <ui-dialog> — modal with backdrop
│ │ ├── ui-drawer.mjs ← <ui-drawer> — slide-in panel (mobile forms)
│ │ ├── ui-toast.mjs ← <ui-toast> — success/error notifications
│ │ ├── ui-spinner.mjs ← <ui-spinner> — loading state
│ │ ├── ui-empty.mjs ← <ui-empty> — empty state illustration + CTA
│ │ ├── ui-confirm.mjs ← <ui-confirm> — "Are you sure?" dialog
│ │ ├── ui-search.mjs ← <ui-search> — debounced search input
│ │ ├── ui-tabs.mjs ← <ui-tabs> — tab bar + panels
│ │ ├── ui-avatar.mjs ← <ui-avatar> — initials or photo avatar
│ │ └── ui-tooltip.mjs ← <ui-tooltip> — hover tooltip
│ │
│ ├── lib/
│ │ ├── api.mjs ← Fetch wrapper, auth header, error handling
│ │ ├── router.mjs ← Hash/history client router
│ │ ├── store.mjs ← Reactive state (lightweight signal pattern)
│ │ ├── auth.mjs ← JWT storage, decode, role checks
│ │ ├── format.mjs ← Date, currency, phone formatters
│ │ ├── validate.mjs ← Form field validators
│ │ └── utils.mjs ← Misc helpers, debounce, deepMerge
│ │
│ └── styles/
│ ├── global.css ← CSS custom properties (design tokens above)
│ ├── reset.css ← Modern CSS reset
│ ├── typography.css ← Base font rules
│ ├── forms.css ← Shared input/select/textarea styles
│ ├── tables.css ← Shared table styles
│ └── print.css ← Print overrides for wo-print.mjs
│
├── migrations/
│ ├── 001_initial.sql
│ ├── 002_resources.sql
│ ├── 003_attachments.sql
│ ├── 004_accounting.sql
│ ├── 005_users_roles.sql
│ └── 006_audit_log.sql
│
├── uploads/ ← Bind-mounted in Docker
├── Dockerfile
├── docker-compose.yml
├── .env.example
└── go.mod
```
---
## Phase 1 — Foundation
**Goal:** Running app with shell, sidebar, auth, and WO CRUD.
### 1.1 CLAUDE.md (create first)
- [ ] Document `.mjs` extension convention for all frontend JS modules
- [ ] Document Go conventions: package names, error wrapping, named DB params
- [ ] Document CSS convention: always use design token vars, never hardcoded hex
- [ ] Document component convention: Shadow DOM, `#private` fields, custom events
- [ ] List all CDN dependencies and their globals (`L` for Leaflet, `lucide`, `Chart`)
### 1.2 Go Project Bootstrap
- [ ] `go mod init github.com/yourorg/workorder`
- [ ] Add dependencies: `chi/v5`, `sqlx`, `go-mssqldb`, `golang-jwt/jwt/v5`, `google/uuid`, `joho/godotenv`
- [ ] `internal/config/config.go` — load from env: `ADDR`, `DB_DSN`, `JWT_SECRET`, `UPLOAD_PATH`, `BASE_URL`
- [ ] `internal/repository/db.go` — `sqlx.Connect`, pool settings (max open 25, max idle 5, lifetime 5min)
- [ ] `cmd/server/main.go` — wire config → db → router → `http.ListenAndServe`
### 1.3 Database — Migration 001
```sql
-- work_orders table (full schema as planned)
-- Computed wo_number column: 'WO-' + zero-padded id
-- status CHECK constraint
-- priority CHECK constraint
-- Indexes on status, parent, scheduled_start
```
### 1.4 Auth
- [ ] `migrations/005_users_roles.sql`
```sql
CREATE TABLE users (
id INT IDENTITY PRIMARY KEY,
username NVARCHAR(100) NOT NULL UNIQUE,
email NVARCHAR(200) NOT NULL UNIQUE,
display_name NVARCHAR(200),
password_hash NVARCHAR(200) NOT NULL, -- bcrypt
role NVARCHAR(30) NOT NULL DEFAULT 'viewer',
-- admin | dispatcher | field_tech | viewer
avatar_url NVARCHAR(500),
active BIT NOT NULL DEFAULT 1,
last_login DATETIME2,
created_at DATETIME2 NOT NULL DEFAULT GETUTCDATE()
);
-- Seed one admin user
```
- [ ] `POST /api/auth/login` — bcrypt compare, return `{ token, user }` with 8hr JWT
- [ ] `POST /api/auth/refresh` — accepts valid token, returns new token
- [ ] `GET /api/auth/me` — current user from token claims
- [ ] JWT middleware: attach user to `context.Context`, return 401 JSON on failure
- [ ] Role helper: `RequireRole(roles ...string)` middleware
### 1.5 Work Order Backend (CRUD)
- [ ] `model/workorder.go` — `WorkOrder`, `WorkOrderListItem`, `WorkOrderDetail` structs with `db:` and `json:` tags
- [ ] `repository/workorder.go`
- `List(ctx, filters)` — status, search, priority, parentType, page/limit
- `GetByID(ctx, id)` — single WO
- `Create(ctx, wo)` — insert + return generated ID
- `Update(ctx, wo)` — partial update, always set `updated_at = GETUTCDATE()`
- `UpdateStatus(ctx, id, status, userID)` — transition + audit log insert
- `Delete(ctx, id)` — soft delete (add `deleted_at` column)
- `GetDetail(ctx, id)` — joins WO + steps + resources + attachments + accounting in one call
- [ ] Handlers: `List`, `Get`, `Create`, `Update`, `Delete`, `UpdateStatus`
- [ ] Paginated response: `{ data: [...], meta: { page, per_page, total } }`
### 1.6 Frontend Shell
- [ ] `web/index.html`
- Load Google Fonts (Inter + JetBrains Mono)
- Load Lucide CDN
- Load Leaflet CSS + JS CDN
- Load Chart.js CDN
- `<script type="module" src="app.mjs">`
- Viewport meta tag: `width=device-width, initial-scale=1`
- [ ] `web/styles/reset.css` — modern reset (box-sizing, margin 0, img max-width)
- [ ] `web/styles/global.css` — all design tokens, base body/font rules
- [ ] `web/styles/forms.css` — input, select, textarea, label, fieldset base styles
- [ ] `web/lib/api.mjs` — fetch wrapper, auto-inject `Authorization`, handle 401 → emit `auth:expired`
- [ ] `web/lib/auth.mjs` — `getToken()`, `setToken()`, `clearToken()`, `getUser()`, `hasRole()`
- [ ] `web/lib/router.mjs` — hash router, `on(pattern, handler)`, `navigate(path)`, `start()`
- [ ] `web/lib/format.mjs` — `formatDate()`, `formatDateTime()`, `formatRelative()`, `formatPhone()`
- [ ] `web/lib/store.mjs` — reactive signal: `createStore(initial)` returns `{ get, set, subscribe }`
### 1.7 Sidebar Layout
- [ ] **`web/components/layout/app-sidebar.mjs`**
- Dark navy background (`var(--sidebar-bg)`)
- Top: app logo / wordmark
- Nav items with Lucide icon + label (see nav items below)
- Active item: left accent bar + `--teal` background
- Hover: `--sidebar-hover` background, smooth transition
- Bottom: current user avatar + name + logout button
- **Collapsible:** click toggle collapses to icon-only mode (`--sidebar-collapsed` width)
- Collapse state stored in `localStorage`
- Collapsed: show only icons with `<ui-tooltip>` on hover showing label
- [ ] **Nav items (in order):**
```
─── Main ────────────────
Dashboard / (layout-dashboard icon)
Work Orders /work-orders (clipboard-list icon)
─── Resources ───────────
People /registry/people (users icon)
Vehicles /registry/vehicles (truck icon)
Equipment /registry/equipment (wrench icon)
Materials /registry/materials (package icon)
─── Operations ──────────
Reports /reports (bar-chart-2 icon)
─── Admin ──────────────── (only shown if role = admin)
Users /users (user-cog icon)
Settings /settings (settings icon)
```
- [ ] **`web/components/layout/app-topbar.mjs`**
- Left: hamburger menu button (mobile) / breadcrumb (desktop)
- Right: notification bell with unread count badge, user avatar dropdown
- Dropdown: "My Profile", "Change Password", divider, "Sign Out"
- Topbar is fixed/sticky at top, `z-index` above content
- [ ] **`web/components/layout/app-mobile-nav.mjs`**
- Fixed bottom tab bar on screens ≤ 768px
- Show 5 main items: Dashboard, Work Orders, People, Reports, Menu (opens drawer)
- Active tab: `--teal` color + underline indicator
- Safe area padding for iOS notch: `padding-bottom: env(safe-area-inset-bottom)`
- [ ] **`web/components/layout/app-root.mjs`**
- Orchestrates sidebar + topbar + `<main>` content slot
- Desktop: sidebar left, content fills remaining width
- Mobile: sidebar hidden (drawer), topbar + bottom tab bar
- CSS Grid layout:
```css
/* Desktop */
display: grid;
grid-template-columns: var(--sidebar-w) 1fr;
grid-template-rows: 56px 1fr;
/* Mobile ≤768px */
grid-template-columns: 1fr;
grid-template-rows: 56px 1fr 56px;
```
- [ ] **`web/app.mjs`**
- Register all components
- Initialize router: map hash routes to `<main>` innerHTML swaps
- Listen for `auth:expired` → clear token → navigate to `/login`
### 1.8 Shared UI Components (needed in Phase 1)
- [ ] **`ui-badge.mjs`** — status & priority pills
```html
<ui-badge type="status" value="in_progress">In Progress</ui-badge>
<ui-badge type="priority" value="urgent">Urgent</ui-badge>
```
Colored dot + label, background is 15% opacity of the status color
- [ ] **`ui-button.mjs`** — `variant="primary|ghost|danger|icon"`, `size="sm|md|lg"`, `loading` attr shows spinner
- [ ] **`ui-spinner.mjs`** — CSS animated ring, `size` attr
- [ ] **`ui-toast.mjs`** — fixed top-right, slide-in animation, auto-dismiss after 4s, `type="success|error|info"`
- [ ] **`ui-empty.mjs`** — centered illustration + heading + body + optional CTA button
- [ ] **`ui-dialog.mjs`** — `<dialog>` element, backdrop, close on Escape + backdrop click, `size="sm|md|lg"`
### 1.9 WO List Page
- [ ] **`wo-list.mjs`**
- Page header: "Work Orders" title + "New Work Order" primary button
- Filter bar: search input, status dropdown, priority dropdown, date range pickers
- View toggle: List view | Kanban view (icon buttons, remember choice in localStorage)
- **List view:** table on desktop, card stack on mobile
- Columns: WO#, Title, Site, Status, Priority, Scheduled, Steps progress, Photos count, Actions
- Row click → navigate to detail
- Actions column: Edit (pencil), delete (trash, confirm dialog)
- Sticky header, sortable columns (client-side sort for Phase 1)
- Loading: skeleton rows (CSS animation, not a spinner)
- Empty state: `<ui-empty>` with clipboard illustration + "Create your first Work Order"
- Pagination: page controls at bottom
- [ ] **`wo-kanban.mjs`**
- Columns: Draft | Assigned | Scheduled | In Progress | Pending Review | Closed
- Each column header: status color + count badge
- Cards: WO#, title, site name, priority badge, assignee avatars, step progress bar
- Horizontal scroll on mobile (each column is scrollable vertically)
- Drag-and-drop to change status (HTML5 drag API, no lib needed)
### 1.10 WO Form Page
- [ ] **`wo-form.mjs`** — used for both Create and Edit
- Page header: "New Work Order" or "Edit WO-000123" + breadcrumb
- Layout: two-column on desktop (`grid-template-columns: 2fr 1fr`), single column mobile
- **Left column — main fields:**
- Title (required, full width)
- Description (textarea, 3 rows)
- Instructions (textarea, 5 rows — will become rich text in Phase 4)
- Site Name
- Address (with optional geocode button → fills Lat/Lng)
- Lat / Lng (read-only display, set by geocoder or map click)
- Access Notes (gate codes, road conditions)
- **Right column — metadata:**
- Status (select)
- Priority (select with colored indicator)
- Parent Type (select: None / Project / Trouble Ticket / Service Order)
- Parent ID (text field, shown only when parent type selected)
- Scheduled Start (datetime-local input)
- Scheduled End (datetime-local input)
- Footer: Cancel (ghost) | Save Draft | Save & Open (primary)
- Validation: highlight invalid fields, scroll to first error, toast on success
- Unsaved-changes guard: warn before navigating away if form is dirty
### 1.11 WO Detail Page
- [ ] **`wo-detail.mjs`** — tabbed layout
- Header section: WO number (mono font), title, status badge, priority badge
- Metadata strip: site, scheduled date, created by, parent link (clickable)
- Action buttons: Edit, Print, Change Status (dropdown), Close WO
- Tabs: **Overview | Checklist | Resources | Photos | Accounting | Activity**
- Each tab is its own component loaded lazily when tab is clicked
- Tab indicator: `--teal` underline, smooth slide transition
---
## Phase 2 — Field Features
**Goal:** Everything a crew needs on their phone in the field.
### 2.1 Database Migrations
- [ ] `002_resources.sql` — `wo_steps`, `wo_resources`, `resource_people`, `resource_vehicles`, `resource_equipment`, `resource_materials`
- [ ] `003_attachments.sql` — `wo_attachments`
### 2.2 Checklist / Steps
**Backend:**
- [ ] `GET /api/work-orders/{id}/steps` — ordered list
- [ ] `POST /api/work-orders/{id}/steps` — add step `{ title, description, step_order, required }`
- [ ] `PUT /api/work-orders/{id}/steps/{sid}` — edit step (title, description, reorder)
- [ ] `PUT /api/work-orders/{id}/steps/reorder` — bulk reorder `{ steps: [{id, order}] }`
- [ ] `POST /api/work-orders/{id}/steps/{sid}/complete` — check off, record `completed_by`, `completed_at`, optional `notes`
- [ ] `POST /api/work-orders/{id}/steps/{sid}/uncomplete` — undo check (dispatcher+ only)
- [ ] `DELETE /api/work-orders/{id}/steps/{sid}`
**Frontend `wo-checklist.mjs`:**
- [ ] Progress bar at top: `X of Y steps complete` with animated fill
- [ ] Each step row:
- Checkbox (large, touch-friendly — min 44px tap target)
- Step number + title (strikethrough when complete)
- Description (collapsed, expand on tap)
- "Add note" button → inline textarea for completion note
- Photo camera button → triggers photo capture attached to this step
- Completed by + timestamp (shown when done, muted)
- [ ] Add Step form at bottom: title field + optional description + Add button
- [ ] Drag handle for reordering (desktop; long-press on mobile)
- [ ] Steps auto-saved — no save button, immediate API call on check
- [ ] If all steps complete → confetti animation + prompt to move status to Pending Review
### 2.3 Resource Registry (Master Lists)
**Backend:**
- [ ] `GET /api/registry/people?search=&active=true` — paginated list
- [ ] `POST /api/registry/people` — create person record
- [ ] `PUT /api/registry/people/{id}` — update
- [ ] `DELETE /api/registry/people/{id}` — soft delete (set `active = 0`)
- [ ] Same CRUD for `/registry/vehicles`, `/registry/equipment`, `/registry/materials`
**Frontend — People (`people-list.mjs`, `people-form.mjs`):**
- [ ] List page: avatar + name + role + phone + email + active badge + edit/deactivate actions
- [ ] Form fields: display name, role/title, email, phone, notes, active toggle
- [ ] Avatar: show initials in colored circle (hash name to color), or upload photo
**Frontend — Vehicles (`vehicle-list.mjs`, `vehicle-form.mjs`):**
- [ ] List: unit number, description, type, license plate, active status
- [ ] Form fields: unit number, description, vehicle type (bucket truck / van / trailer / other), license plate, notes, active toggle
**Frontend — Equipment (`equipment-list.mjs`, `equipment-form.mjs`):**
- [ ] List: name, asset tag, category, last assigned WO, active status
- [ ] Form fields: name, asset tag, category (fusion splicer / OTDR / blower / reel / other), serial number, notes, active toggle
**Frontend — Materials (`material-list.mjs`, `material-form.mjs`):**
- [ ] List: name, part number, unit of measure, active status
- [ ] Form fields: name, part number, unit (ft / ea / box / roll), notes, active toggle
### 2.4 Resource Assignment Panel
**Backend:**
- [ ] `GET /api/work-orders/{id}/resources`
- [ ] `POST /api/work-orders/{id}/resources` — `{ resource_type, resource_id, quantity?, notes? }`
- [ ] `DELETE /api/work-orders/{id}/resources/{rid}`
- [ ] `GET /api/registry/people?search=` — for typeahead pickers
**Frontend `wo-resource-panel.mjs`:**
- [ ] Four sections: People · Vehicles · Equipment · Materials
- [ ] Each section:
- Assigned list: avatar/icon + name + remove (×) button
- "Add" button → opens `<ui-dialog>` with searchable picker
- Picker: search input + scrollable list with checkboxes → "Add Selected" button
- Materials: quantity field next to each assigned item
- [ ] Empty section shows `<ui-empty>` inline: "No people assigned yet"
- [ ] Changes reflect immediately, API call on each add/remove
- [ ] Total counts shown in section header: "People (3)"
### 2.5 Photo Panel
**Backend:**
- [ ] `POST /api/work-orders/{id}/attachments` — multipart, fields: `file`, `phase`, `caption`, `step_id?`
- [ ] `GET /api/work-orders/{id}/attachments`
- [ ] `PUT /api/work-orders/{id}/attachments/{aid}` — update caption/phase
- [ ] `DELETE /api/work-orders/{id}/attachments/{aid}`
- [ ] Extract EXIF GPS on server if present in JPEG
- [ ] Generate thumbnail on upload (use `imaging` Go package, 400px wide JPEG)
- [ ] Serve originals at `/uploads/{path}`, thumbs at `/uploads/thumbs/{path}`
**Frontend `wo-photo-panel.mjs`:**
- [ ] Phase tabs at top: **All · Before · During · After** — filter photos by phase
- [ ] Photo grid: 3 columns desktop, 2 columns tablet, 2 columns mobile
- [ ] Each photo tile: thumbnail, hover overlay with caption + phase badge
- [ ] Click → lightbox modal with full-size image, prev/next arrows, swipe on mobile
- [ ] Upload button:
- Desktop: file picker (multi-select, drag-and-drop zone)
- Mobile: `<input type="file" accept="image/*" capture="environment">` for camera
- [ ] Upload flow: show thumbnail preview + phase selector + caption field before confirm
- [ ] Upload progress: per-file progress bar, queue multiple uploads
- [ ] Geo-tagged indicator: small pin icon if lat/lng present
- [ ] Empty state per phase: "No 'before' photos yet — capture site condition before starting"
### 2.6 Map & Location
**Backend:**
- [ ] `GET /api/work-orders/{id}/location` — return `{ lat, lng, address, site_name, access_notes }`
- [ ] `PUT /api/work-orders/{id}/location` — update coordinates + address
**Frontend `wo-map.mjs`:**
- [ ] Leaflet map, 300px height (expandable to full-screen on mobile)
- [ ] OpenStreetMap tile layer (no API key)
- [ ] Custom pin: teal SVG marker with WO number label
- [ ] "Get Directions" button → deeplink:
- iOS: `maps://maps.apple.com/?daddr={lat},{lng}`
- Android/other: `https://maps.google.com/maps?daddr={lat},{lng}`
- [ ] "Set Location" mode: click map to drop pin → updates lat/lng fields in WO form
- [ ] Geocode button in WO form: type address → calls OpenStreetMap Nominatim → fills lat/lng (free, no key)
- [ ] Access notes shown below map in an info box with lock icon
### 2.7 Mobile Responsiveness Audit
- [ ] All tap targets ≥ 44×44px
- [ ] No horizontal scroll on any page at 375px width
- [ ] Forms: single column, full-width inputs
- [ ] Tables: horizontal scroll container or card-based list on mobile
- [ ] Sidebar: hidden, accessible via hamburger → slides in as drawer with backdrop
- [ ] Bottom nav `app-mobile-nav.mjs` visible on ≤ 768px, hidden on desktop
- [ ] Photo capture uses `capture="environment"` on mobile
- [ ] Map "Get Directions" opens native maps app
- [ ] Dialogs: full-screen on mobile (`position: fixed; inset: 0`)
- [ ] Test breakpoints: 375px (iPhone SE), 390px (iPhone 14), 768px (iPad), 1024px, 1440px
---
## Phase 3 — Integrations & Admin
### 3.1 Accounting Codes
**Backend:**
- [ ] `004_accounting.sql` — `wo_accounting` table (wo_id, code_type, code_value, description)
- [ ] `GET /api/work-orders/{id}/accounting`
- [ ] `PUT /api/work-orders/{id}/accounting` — upsert all codes in one request `[{ code_type, code_value, description }]`
**Frontend `wo-accounting.mjs`:**
- [ ] Four fields in a clean two-column grid:
- GL Account Code (text + description)
- Cost Center / Department (text + description)
- Project WBS / Phase (text + description)
- Billing Reference (text + description)
- [ ] Each field: code input + description input on same row
- [ ] Auto-save on blur (no save button)
- [ ] Show "auto-populated from parent" chip if code came from parent record
- [ ] Future: lookup/typeahead from your chart of accounts
### 3.2 Polymorphic Parent Linking
- [ ] WO form: parent type select (None / Project / Trouble Ticket / Service Order)
- [ ] When type selected: show search/ID input for the parent record
- [ ] In WO detail header: linked parent shown as a chip with icon
```
📋 Project: Elm Street FTTH Build →
```
- [ ] Clicking the chip opens a drawer with parent record summary (Phase 4: deep link)
- [ ] Backend: `GET /api/work-orders?parent_type=project&parent_id=42` — list WOs for a given parent
### 3.3 Audit Log / Activity Timeline
**Backend:**
- [ ] `006_audit_log.sql` — `wo_audit_log` (id, wo_id, action, old_value, new_value, performed_by, performed_at)
- [ ] Log every status change, resource assignment, step completion, photo upload, accounting change
- [ ] `GET /api/work-orders/{id}/activity` — paginated, newest first
**Frontend `wo-timeline.mjs`:**
- [ ] Vertical timeline feed: dot + connector line
- [ ] Each entry: user avatar + action description + timestamp (relative + absolute on hover)
- [ ] Status changes: colored badge showing old → new
- [ ] Photo uploads: thumbnail preview inline
- [ ] Step completions: step title + optional note
- [ ] Load more button at bottom (pagination)
- [ ] Auto-refresh every 30s if WO is `in_progress`
### 3.4 User Management (Admin Only)
**Backend:**
- [ ] `GET /api/users` — list all users (admin only)
- [ ] `POST /api/users` — create user, auto-send invite email
- [ ] `PUT /api/users/{id}` — update name, role, active status
- [ ] `PUT /api/users/{id}/password` — admin reset
- [ ] `DELETE /api/users/{id}` — deactivate (soft delete)
- [ ] `GET /api/users/me` — current user profile
- [ ] `PUT /api/users/me` — update own display name, avatar
- [ ] `PUT /api/users/me/password` — change own password (requires current password)
**Frontend:**
- [ ] **`user-list.mjs`** — table: avatar, name, email, role badge, last login, active toggle, edit button
- [ ] **`user-form.mjs`** — dialog: display name, email, role select, active toggle, force password reset checkbox
- [ ] **`user-profile.mjs`** — own profile: avatar upload, display name, current password + new password fields
- [ ] Role badges with colors: Admin=danger, Dispatcher=teal, Field Tech=warning, Viewer=muted
### 3.5 Notifications
**Backend:**
- [ ] `wo_notifications` table: (id, user_id, wo_id, type, message, read, created_at)
- [ ] Create notifications when:
- WO assigned to you
- WO status changes (for creator and assignees)
- All steps completed (for dispatcher/admin)
- WO approaching scheduled time (30 min warning, cron job)
- [ ] `GET /api/notifications?unread=true` — list notifications
- [ ] `PUT /api/notifications/{id}/read` — mark read
- [ ] `PUT /api/notifications/read-all` — mark all read
- [ ] Email notification: Go `net/smtp` with HTML template, send on assignment
**Frontend:**
- [ ] Bell icon in topbar: unread count badge (red dot with number)
- [ ] Click bell → dropdown panel with notification list
- [ ] Each notification: icon, message, WO link, relative time, unread dot
- [ ] "Mark all read" button at top
- [ ] Click notification → navigate to WO detail + mark read
- [ ] Poll `/api/notifications?unread=true` every 60s for new count
### 3.6 Print / Field Packet
**Frontend `wo-print.mjs`:**
- [ ] Triggered by "Print" button in WO detail header
- [ ] Opens print-preview page (new tab or print dialog)
- [ ] Printed layout includes:
- WO number, title, site info, scheduled date
- All resource assignments (people, vehicles, equipment, materials)
- Full instructions + access notes
- Map static image (use Leaflet's `map.printPlugin` or OpenStreetMap static tiles)
- Numbered step checklist (checkbox squares for pen check-off)
- Accounting codes
- Photo thumbnails (before photos only, 2×3 grid)
- Signature / completion line
- [ ] `print.css`: hide sidebar, topbar, action buttons; show print-only elements; break pages sensibly
### 3.7 Reports
**Backend:**
- [ ] `GET /api/reports/summary` — total WOs by status, by priority, by date range
- [ ] `GET /api/reports/by-cost-center` — WO count and open/closed by cost center
- [ ] `GET /api/reports/by-resource` — WOs per person/vehicle over date range
- [ ] `GET /api/reports/overdue` — WOs past scheduled end still not closed
- [ ] `GET /api/reports/export?format=csv&status=closed&from=&to=` — CSV download
**Frontend:**
- [ ] **`report-root.mjs`** — filter bar: date range, status, cost center, parent type
- [ ] **`report-by-status.mjs`** — doughnut chart (Chart.js) + summary table
- [ ] **`report-by-cost.mjs`** — bar chart by cost center + table with WO list
- [ ] **`report-export.mjs`** — "Export to CSV" button + date range picker + format select
---
## Phase 4 — Polish & Optimization
### 4.1 Dashboard
**Backend:**
- [ ] `GET /api/dashboard` — single endpoint returns all KPIs:
```json
{
"open_count": 42,
"in_progress_count": 12,
"overdue_count": 5,
"closed_today": 8,
"by_status": [...],
"by_priority": [...],
"recent_activity": [...],
"upcoming": [...]
}
```
**Frontend `dash-root.mjs`:**
- [ ] **KPI row** — four `<dash-kpi-card>` components:
- Open Work Orders (blue, clipboard icon)
- In Progress (orange, activity icon)
- Overdue (red, alert-circle icon)
- Closed Today (green, check-circle icon)
- Each card: large number, trend indicator (↑↓ vs yesterday), icon, click → filtered WO list
- [ ] **Charts row** (2 columns):
- Doughnut: WOs by Status (Chart.js, use status colors from design system)
- Bar: WOs by Priority (horizontal bars)
- [ ] **Bottom row** (2 columns):
- Recent Activity feed (`<dash-recent-feed>`)
- Upcoming WOs this week (compact list, sorted by scheduled_start)
- [ ] Dashboard auto-refreshes every 2 minutes
- [ ] Skeleton loading state while data loads
### 4.2 Offline / PWA Basics
- [ ] `manifest.json` — app name, icons, `display: standalone`, theme color `#0D2137`
- [ ] `sw.js` — Service Worker:
- Cache shell (index.html, CSS, .mjs files) on install
- Cache API GET responses in IndexedDB for offline read
- Queue photo uploads when offline, sync on reconnect (Background Sync API)
- [ ] Offline indicator banner: "You're offline — viewing cached data"
- [ ] "Add to Home Screen" prompt on mobile
### 4.3 Rich Text Instructions
- [ ] Swap `<textarea>` for [Trix editor](https://trix-editor.org/) (no build needed, CDN available)
- [ ] Store as HTML in `instructions` column (`NVARCHAR(MAX)`)
- [ ] Display with sanitized `innerHTML` (strip `<script>` tags server-side)
### 4.4 Full-Text Search
- [ ] SQL Server `CONTAINS` or `FREETEXT` on `work_orders.title`, `description`, `instructions`, `site_name`
- [ ] Global search bar in topbar — results show WOs, people, equipment matching query
- [ ] Keyboard shortcut: `Ctrl+K` / `Cmd+K` opens search spotlight dialog
### 4.5 Scheduling Calendar
- [ ] **`wo-calendar.mjs`** — monthly/weekly view
- [ ] Each WO block: title, priority color stripe, assignee avatars
- [ ] Click WO block → detail drawer slides in without leaving calendar
- [ ] Drag WO block to reschedule (updates `scheduled_start`, `scheduled_end`)
### 4.6 Crew Scheduling View
- [ ] Gantt-style view: rows = people, columns = days
- [ ] Each WO shown as a bar on the assigned person's row
- [ ] Spot conflicts: overlapping WOs on same person highlighted in red
### 4.7 Additional Polish
- [ ] Dark mode: add `[data-theme="dark"]` overrides for all CSS vars, toggle stored in localStorage
- [ ] Keyboard navigation: all interactive elements focusable, visible focus rings, tab order logical
- [ ] Error boundaries: if a component throws, show error card instead of crashing whole page
- [ ] Rate limiting on API (chi middleware: 100 req/min per IP)
- [ ] API response compression (`gzip` middleware)
- [ ] Input sanitization: strip HTML from all text fields server-side before saving
- [ ] `ETag` / `Last-Modified` headers on GET responses for browser caching
---
## Component Inventory
| Component | File | Status | Phase |
|-----------|------|--------|-------|
| App shell | `app-root.mjs` | ⬜ | 1 |
| Sidebar | `app-sidebar.mjs` | ⬜ | 1 |
| Topbar | `app-topbar.mjs` | ⬜ | 1 |
| Mobile nav | `app-mobile-nav.mjs` | ⬜ | 1 |
| WO List | `wo-list.mjs` | ⬜ | 1 |
| WO Kanban | `wo-kanban.mjs` | ⬜ | 1 |
| WO Form | `wo-form.mjs` | ⬜ | 1 |
| WO Detail | `wo-detail.mjs` | ⬜ | 1 |
| Status Badge | `ui-badge.mjs` | ⬜ | 1 |
| Button | `ui-button.mjs` | ⬜ | 1 |
| Dialog | `ui-dialog.mjs` | ⬜ | 1 |
| Toast | `ui-toast.mjs` | ⬜ | 1 |
| Spinner | `ui-spinner.mjs` | ⬜ | 1 |
| Empty state | `ui-empty.mjs` | ⬜ | 1 |
| Checklist | `wo-checklist.mjs` | ⬜ | 2 |
| Resource panel | `wo-resource-panel.mjs` | ⬜ | 2 |
| Photo panel | `wo-photo-panel.mjs` | ⬜ | 2 |
| Map | `wo-map.mjs` | ⬜ | 2 |
| People list/form | `people-list.mjs` | ⬜ | 2 |
| Vehicle list/form | `vehicle-list.mjs` | ⬜ | 2 |
| Equipment list/form | `equipment-list.mjs` | ⬜ | 2 |
| Material list/form | `material-list.mjs` | ⬜ | 2 |
| Accounting | `wo-accounting.mjs` | ⬜ | 3 |
| Timeline | `wo-timeline.mjs` | ⬜ | 3 |
| Print packet | `wo-print.mjs` | ⬜ | 3 |
| User list/form | `user-list.mjs` | ⬜ | 3 |
| User profile | `user-profile.mjs` | ⬜ | 3 |
| Reports | `report-root.mjs` | ⬜ | 3 |
| Dashboard | `dash-root.mjs` | ⬜ | 4 |
| KPI card | `dash-kpi-card.mjs` | ⬜ | 4 |
| Calendar | `wo-calendar.mjs` | ⬜ | 4 |
| Search spotlight | (in `app-topbar.mjs`) | ⬜ | 4 |
---
## API Endpoints
### Auth
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/api/auth/login` | ✗ | Login, returns JWT + user |
| POST | `/api/auth/refresh` | ✓ | Refresh token |
| GET | `/api/auth/me` | ✓ | Current user |
### Work Orders
| Method | Path | Role | Description |
|--------|------|------|-------------|
| GET | `/api/work-orders` | viewer+ | List (filter: status, priority, search, parent, date) |
| POST | `/api/work-orders` | dispatcher+ | Create |
| GET | `/api/work-orders/{id}` | viewer+ | Full detail |
| PUT | `/api/work-orders/{id}` | dispatcher+ | Update |
| DELETE | `/api/work-orders/{id}` | admin | Soft delete |
| PUT | `/api/work-orders/{id}/status` | field_tech+ | Status transition |
| GET | `/api/work-orders/{id}/steps` | viewer+ | List steps |
| POST | `/api/work-orders/{id}/steps` | dispatcher+ | Add step |
| PUT | `/api/work-orders/{id}/steps/{sid}` | dispatcher+ | Edit step |
| PUT | `/api/work-orders/{id}/steps/reorder` | dispatcher+ | Bulk reorder |
| POST | `/api/work-orders/{id}/steps/{sid}/complete` | field_tech+ | Check off |
| POST | `/api/work-orders/{id}/steps/{sid}/uncomplete` | dispatcher+ | Undo check |
| DELETE | `/api/work-orders/{id}/steps/{sid}` | dispatcher+ | Delete step |
| GET | `/api/work-orders/{id}/resources` | viewer+ | List assignments |
| POST | `/api/work-orders/{id}/resources` | dispatcher+ | Assign resource |
| DELETE | `/api/work-orders/{id}/resources/{rid}` | dispatcher+ | Remove assignment |
| GET | `/api/work-orders/{id}/attachments` | viewer+ | List photos/files |
| POST | `/api/work-orders/{id}/attachments` | field_tech+ | Upload photo/file |
| PUT | `/api/work-orders/{id}/attachments/{aid}` | field_tech+ | Update caption/phase |
| DELETE | `/api/work-orders/{id}/attachments/{aid}` | dispatcher+ | Delete attachment |
| GET | `/api/work-orders/{id}/accounting` | viewer+ | Get accounting codes |
| PUT | `/api/work-orders/{id}/accounting` | dispatcher+ | Upsert accounting codes |
| GET | `/api/work-orders/{id}/activity` | viewer+ | Audit log feed |
| GET | `/api/work-orders/{id}/location` | viewer+ | Location data |
| PUT | `/api/work-orders/{id}/location` | dispatcher+ | Update location |
### Registries (Master Lists)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/registry/people` | List people (search, active filter) |
| POST | `/api/registry/people` | Create person |
| PUT | `/api/registry/people/{id}` | Update person |
| DELETE | `/api/registry/people/{id}` | Deactivate person |
| GET/POST/PUT/DELETE | `/api/registry/vehicles` | Same CRUD for vehicles |
| GET/POST/PUT/DELETE | `/api/registry/equipment` | Same CRUD for equipment |
| GET/POST/PUT/DELETE | `/api/registry/materials` | Same CRUD for materials |
### Users & Notifications
| Method | Path | Role | Description |
|--------|------|------|-------------|
| GET | `/api/users` | admin | List all users |
| POST | `/api/users` | admin | Create user |
| PUT | `/api/users/{id}` | admin | Update user |
| DELETE | `/api/users/{id}` | admin | Deactivate |
| GET | `/api/users/me` | any | Own profile |
| PUT | `/api/users/me` | any | Update own profile |
| PUT | `/api/users/me/password` | any | Change password |
| GET | `/api/notifications` | any | List notifications |
| PUT | `/api/notifications/{id}/read` | any | Mark read |
| PUT | `/api/notifications/read-all` | any | Mark all read |
### Reports & Dashboard
| Method | Path | Role | Description |
|--------|------|------|-------------|
| GET | `/api/dashboard` | viewer+ | All KPIs in one call |
| GET | `/api/reports/summary` | viewer+ | WOs by status/priority |
| GET | `/api/reports/by-cost-center` | dispatcher+ | Cost center breakdown |
| GET | `/api/reports/by-resource` | dispatcher+ | Resource utilization |
| GET | `/api/reports/overdue` | dispatcher+ | Overdue WOs |
| GET | `/api/reports/export` | dispatcher+ | CSV download |
---
## Database Schema
### Full Table List
| Table | Purpose |
|-------|---------|
| `work_orders` | Core WO records |
| `wo_steps` | Ordered checklist steps |
| `wo_resources` | Resource assignments (polymorphic) |
| `wo_attachments` | Photos and file uploads |
| `wo_accounting` | GL/cost center/WBS codes |
| `wo_audit_log` | All change events |
| `wo_notifications` | User notification records |
| `resource_people` | People master list |
| `resource_vehicles` | Vehicle master list |
| `resource_equipment` | Equipment master list |
| `resource_materials` | Material/inventory master list |
| `users` | App users with roles |
### Key Patterns
- Soft deletes: `deleted_at DATETIME2 NULL` — never hard delete, always filter `WHERE deleted_at IS NULL`
- Audit timestamps: every table has `created_at`, `updated_at` (auto via `DEFAULT GETUTCDATE()`)
- Polymorphic parent: `parent_type NVARCHAR(50)`, `parent_id INT` — handles any parent record type
- Named params only: never string-concatenate SQL — always use `@param_name` or `@p1`
---
## Conventions
### CLAUDE.md Content (create this file in project root)
```markdown
# Work Order System — Claude Code Conventions
## Frontend
- ALL frontend JavaScript module files use `.mjs` extension — no exceptions
- No build step, no npm for frontend — pure ES modules via <script type="module">
- CDN dependencies: Lucide (icons), Leaflet (maps), Chart.js (charts), Google Fonts
- Always use CSS custom property vars — never hardcode hex colors
- Component pattern: Shadow DOM, #private class fields, custom events with namespaces
- Custom event naming: colon-namespaced — 'wo:select', 'steps:updated', 'auth:expired'
- Imports always include .mjs extension: import { api } from '../lib/api.mjs'
## Go Backend
- Package names: lowercase single word (handlers, repository, model, service)
- Errors: always wrap with context — fmt.Errorf("repo.Create: %w", err)
- DB queries: named params only — @param_name, never string concat
- HTTP errors: always JSON — {"error": "message"} with correct status code
- Logging: use log/slog with structured key-value pairs
- Response envelope: { data: ..., meta: {...}, error: null }
## Database
- Soft deletes only — deleted_at column, filter WHERE deleted_at IS NULL
- Every table: created_at, updated_at with DEFAULT GETUTCDATE()
- Polymorphic refs: parent_type + parent_id columns
- All SQL in repository layer — no raw SQL in handlers or service
## Git
- Branches: feature/*, fix/*, chore/*
- Commit messages: conventional commits (feat:, fix:, chore:, docs:)
```
### Go Dependencies
```go
// go.mod
require (
github.com/go-chi/chi/v5 v5.1.0
github.com/jmoiron/sqlx v1.3.5
github.com/microsoft/go-mssqldb v1.7.1
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
golang.org/x/crypto latest // bcrypt
github.com/disintegration/imaging latest // thumbnail generation
)
```
### Frontend CDN Deps (loaded in `index.html`)
```html
<!-- Fonts -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<!-- Icons — Lucide (tree-shakeable, no API key) -->
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
<!-- Maps — Leaflet (no API key) -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9/dist/leaflet.css">
<script src="https://unpkg.com/leaflet@1.9/dist/leaflet.js"></script>
<!-- Charts — Chart.js -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
```
---
*Start with Phase 1 in order. Each checkbox is one commit. Don't skip CLAUDE.md — it must exist before any code is written.*
---
# Work Order System — Technical Build Plan
**Stack:** Go (backend API) · Pure JavaScript + Web Components (frontend) · SQL Server (MSSQL) · Docker
---
## Table of Contents
1. [Architecture Overview](#1-architecture-overview)
2. [Project Structure](#2-project-structure)
3. [Database Schema](#3-database-schema)
4. [Go Backend](#4-go-backend)
5. [REST API Design](#5-rest-api-design)
6. [Frontend — Web Components](#6-frontend--web-components)
7. [File & Photo Handling](#7-file--photo-handling)
8. [Maps & Location](#8-maps--location)
9. [Authentication & Authorization](#9-authentication--authorization)
10. [Docker Deployment](#10-docker-deployment)
11. [Phased Build Plan](#11-phased-build-plan)
12. [Development Conventions](#12-development-conventions)
---
## 1. Architecture Overview
```
┌─────────────────────────────────────────────────────────┐
│ Browser Client │
│ ┌────────────────────────────────────────────────────┐ │
│ │ Pure JS + Web Components SPA │ │
│ │ <wo-list> <wo-form> <wo-map> <wo-checklist> │ │
│ └───────────────────┬────────────────────────────────┘ │
└──────────────────────│──────────────────────────────────┘
│ REST/JSON over HTTPS
┌──────────────────────▼──────────────────────────────────┐
│ Go API Server │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Router │ │ Handlers │ │ Service │ │
│ │ (chi) │ │ │ │ Layer │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Auth │ │ Upload │ │ Spatial │ │
│ │ (JWT) │ │ Handler │ │ Utils │ │
│ └──────────┘ └──────────┘ └──────────┘ │
└──────────────────────┬──────────────────────────────────┘
│ sqlx / database/sql
┌──────────────────────▼──────────────────────────────────┐
│ SQL Server (MSSQL) │
│ work_orders · wo_resources · wo_steps · wo_attachments │
│ wo_accounting · wo_parents · users · resource_registry │
└─────────────────────────────────────────────────────────┘
```
**Key decisions:**
- **No framework on the frontend** — Custom Elements v1, Shadow DOM, ES Modules. Works in all modern browsers with zero build step.
- **chi router** for Go — lightweight, idiomatic, stdlib-compatible middleware.
- **sqlx** for DB access — thin wrapper over `database/sql`, named params, struct scanning.
- **SQL Server** — matches your existing infrastructure; geography type for spatial data.
- **Multipart uploads** stored to local volume (or swap for Azure Blob / S3 later).
- **JWT** stateless auth — fits well with a SPA + API split.
---
## 2. Project Structure
```
workorder/
├── cmd/
│ └── server/
│ └── main.go # Entry point, wires everything together
├── internal/
│ ├── api/
│ │ ├── router.go # chi router setup, middleware chain
│ │ ├── middleware.go # JWT, CORS, logging, recovery
│ │ └── handlers/
│ │ ├── workorder.go # WO CRUD handlers
│ │ ├── resource.go # People / vehicle / equipment assignment
│ │ ├── step.go # Checklist steps
│ │ ├── attachment.go # Photo & file upload
│ │ ├── accounting.go # Accounting code endpoints
│ │ ├── parent.go # Parent record linking (polymorphic)
│ │ └── auth.go # Login / token refresh
│ ├── service/
│ │ ├── workorder.go # Business logic layer
│ │ ├── resource.go
│ │ ├── notification.go # Email/push when WO assigned
│ │ └── spatial.go # Geocoding, distance calc
│ ├── repository/
│ │ ├── workorder.go # SQL queries — work orders
│ │ ├── resource.go # SQL queries — resources
│ │ ├── step.go
│ │ ├── attachment.go
│ │ └── db.go # sqlx connection pool setup
│ ├── model/
│ │ ├── workorder.go # Structs: WorkOrder, WorkOrderSummary
│ │ ├── resource.go # Person, Vehicle, Equipment, Material
│ │ ├── step.go # Step, StepCompletion
│ │ ├── attachment.go # Attachment
│ │ ├── accounting.go # AccountingCode
│ │ └── auth.go # User, Claims
│ └── config/
│ └── config.go # Env-based config (DB DSN, JWT secret, upload path)
├── web/ # Frontend — served as static files by Go
│ ├── index.html # Shell — loads the app component
│ ├── app.mjs # <app-root> component, client-side router
│ ├── components/
│ │ ├── wo-list.mjs # Work order list / search
│ │ ├── wo-form.mjs # Create / edit work order
│ │ ├── wo-detail.mjs # Read-only detail view
│ │ ├── wo-checklist.mjs # Step checklist with check-off
│ │ ├── wo-resource-panel.mjs # Assign people, vehicles, equipment
│ │ ├── wo-photo-panel.mjs # Photo capture and gallery
│ │ ├── wo-map.mjs # Embedded map + directions
│ │ ├── wo-accounting.mjs # Accounting code fields
│ │ ├── wo-status-badge.mjs # Status pill (reusable)
│ │ └── wo-timeline.mjs # Activity / audit log
│ ├── lib/
│ │ ├── api.mjs # Fetch wrapper, auth header injection
│ │ ├── router.mjs # Tiny hash/history router
│ │ ├── store.mjs # Lightweight reactive state (no Redux)
│ │ └── utils.mjs # Formatters, validators
│ └── styles/
│ ├── global.css # CSS custom properties (design tokens)
│ └── reset.css
├── uploads/ # Stored attachments (bind-mounted in Docker)
├── migrations/
│ ├── 001_initial_schema.sql
│ ├── 002_resource_registry.sql
│ └── 003_spatial_fields.sql
├── docker-compose.yml
├── Dockerfile
└── .env.example
```
---
## 3. Database Schema
### Core Tables
```sql
-- ── Work Orders ───────────────────────────────────────────────────────────────
CREATE TABLE work_orders (
id INT IDENTITY PRIMARY KEY,
wo_number AS ('WO-' + RIGHT('000000' + CAST(id AS VARCHAR), 6)) PERSISTED,
title NVARCHAR(200) NOT NULL,
description NVARCHAR(MAX),
instructions NVARCHAR(MAX), -- Rich text / markdown
status NVARCHAR(30) NOT NULL DEFAULT 'draft',
-- draft | assigned | scheduled | in_progress | pending_review | closed
priority NVARCHAR(10) NOT NULL DEFAULT 'normal',
-- low | normal | high | urgent
scheduled_start DATETIME2,
scheduled_end DATETIME2,
actual_start DATETIME2,
actual_end DATETIME2,
-- Location
site_name NVARCHAR(200),
address NVARCHAR(400),
lat DECIMAL(10,7),
lng DECIMAL(10,7),
access_notes NVARCHAR(MAX), -- Gate codes, access roads
-- Polymorphic parent
parent_type NVARCHAR(50), -- 'project' | 'ticket' | 'service_order' | NULL
parent_id INT,
-- Audit
created_by INT NOT NULL,
created_at DATETIME2 NOT NULL DEFAULT GETUTCDATE(),
updated_at DATETIME2 NOT NULL DEFAULT GETUTCDATE(),
closed_at DATETIME2,
closed_by INT,
CONSTRAINT chk_wo_status CHECK (status IN ('draft','assigned','scheduled','in_progress','pending_review','closed')),
CONSTRAINT chk_wo_priority CHECK (priority IN ('low','normal','high','urgent'))
);
-- ── Resource Registry (master lists) ─────────────────────────────────────────
CREATE TABLE resource_people (
id INT IDENTITY PRIMARY KEY,
name NVARCHAR(100) NOT NULL,
role NVARCHAR(100),
email NVARCHAR(200),
phone NVARCHAR(30),
active BIT NOT NULL DEFAULT 1
);
CREATE TABLE resource_vehicles (
id INT IDENTITY PRIMARY KEY,
unit_number NVARCHAR(50) NOT NULL,
description NVARCHAR(200),
vehicle_type NVARCHAR(100),
active BIT NOT NULL DEFAULT 1
);
CREATE TABLE resource_equipment (
id INT IDENTITY PRIMARY KEY,
name NVARCHAR(200) NOT NULL,
asset_tag NVARCHAR(100),
category NVARCHAR(100),
active BIT NOT NULL DEFAULT 1
);
CREATE TABLE resource_materials (
id INT IDENTITY PRIMARY KEY,
name NVARCHAR(200) NOT NULL,
unit NVARCHAR(30), -- 'ft', 'each', 'box'
part_number NVARCHAR(100),
active BIT NOT NULL DEFAULT 1
);
-- ── Work Order Resource Assignments ───────────────────────────────────────────
CREATE TABLE wo_resources (
id INT IDENTITY PRIMARY KEY,
wo_id INT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE,
resource_type NVARCHAR(20) NOT NULL, -- 'person' | 'vehicle' | 'equipment' | 'material'
resource_id INT NOT NULL,
quantity DECIMAL(10,2), -- For materials
notes NVARCHAR(500),
assigned_at DATETIME2 NOT NULL DEFAULT GETUTCDATE(),
CONSTRAINT chk_resource_type CHECK (resource_type IN ('person','vehicle','equipment','material'))
);
-- ── Checklist Steps ───────────────────────────────────────────────────────────
CREATE TABLE wo_steps (
id INT IDENTITY PRIMARY KEY,
wo_id INT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE,
step_order INT NOT NULL,
title NVARCHAR(200) NOT NULL,
description NVARCHAR(MAX),
required BIT NOT NULL DEFAULT 1,
completed BIT NOT NULL DEFAULT 0,
completed_by INT,
completed_at DATETIME2,
notes NVARCHAR(MAX) -- Field notes added at completion
);
-- ── Attachments (photos, docs) ────────────────────────────────────────────────
CREATE TABLE wo_attachments (
id INT IDENTITY PRIMARY KEY,
wo_id INT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE,
step_id INT REFERENCES wo_steps(id), -- Optional: attach to a specific step
file_name NVARCHAR(500) NOT NULL,
file_path NVARCHAR(1000) NOT NULL, -- Relative path under /uploads
file_type NVARCHAR(100), -- MIME type
file_size BIGINT,
caption NVARCHAR(500),
phase NVARCHAR(20), -- 'before' | 'during' | 'after'
lat DECIMAL(10,7), -- Geo-tag from EXIF or device
lng DECIMAL(10,7),
uploaded_by INT NOT NULL,
uploaded_at DATETIME2 NOT NULL DEFAULT GETUTCDATE()
);
-- ── Accounting Codes ──────────────────────────────────────────────────────────
CREATE TABLE wo_accounting (
id INT IDENTITY PRIMARY KEY,
wo_id INT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE,
code_type NVARCHAR(50) NOT NULL, -- 'gl_account' | 'cost_center' | 'wbs' | 'billing_ref'
code_value NVARCHAR(200) NOT NULL,
description NVARCHAR(500),
CONSTRAINT uq_wo_accounting UNIQUE (wo_id, code_type)
);
-- ── Audit Log ─────────────────────────────────────────────────────────────────
CREATE TABLE wo_audit_log (
id INT IDENTITY PRIMARY KEY,
wo_id INT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE,
action NVARCHAR(100) NOT NULL,
old_value NVARCHAR(MAX),
new_value NVARCHAR(MAX),
performed_by INT NOT NULL,
performed_at DATETIME2 NOT NULL DEFAULT GETUTCDATE()
);
-- ── Indexes ───────────────────────────────────────────────────────────────────
CREATE INDEX ix_wo_status ON work_orders (status);
CREATE INDEX ix_wo_parent ON work_orders (parent_type, parent_id);
CREATE INDEX ix_wo_scheduled ON work_orders (scheduled_start);
CREATE INDEX ix_wo_resources ON wo_resources (wo_id, resource_type);
CREATE INDEX ix_wo_attachments ON wo_attachments (wo_id);
```
---
## 4. Go Backend
### `cmd/server/main.go`
```go
package main
import (
"log"
"net/http"
"github.com/yourorg/workorder/internal/api"
"github.com/yourorg/workorder/internal/config"
"github.com/yourorg/workorder/internal/repository"
)
func main() {
cfg := config.Load() // reads .env / environment variables
db, err := repository.Connect(cfg.DBDSN)
if err != nil {
log.Fatalf("db connect: %v", err)
}
defer db.Close()
r := api.NewRouter(cfg, db)
log.Printf("listening on %s", cfg.Addr)
if err := http.ListenAndServe(cfg.Addr, r); err != nil {
log.Fatal(err)
}
}
```
### `internal/config/config.go`
```go
package config
import "os"
type Config struct {
Addr string
DBDSN string
JWTSecret string
UploadPath string
BaseURL string
}
func Load() *Config {
return &Config{
Addr: env("ADDR", ":8080"),
DBDSN: env("DB_DSN", ""),
JWTSecret: env("JWT_SECRET", "change-me"),
UploadPath: env("UPLOAD_PATH", "./uploads"),
BaseURL: env("BASE_URL", "http://localhost:8080"),
}
}
func env(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
```
### `internal/api/router.go`
```go
package api
import (
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/yourorg/workorder/internal/api/handlers"
mw "github.com/yourorg/workorder/internal/api/middleware"
"github.com/yourorg/workorder/internal/config"
"github.com/jmoiron/sqlx"
)
func NewRouter(cfg *config.Config, db *sqlx.DB) http.Handler {
r := chi.NewRouter()
// Global middleware
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.RealIP)
r.Use(mw.CORS)
// Serve frontend static files
r.Handle("/*", http.FileServer(http.Dir("./web")))
// Auth routes (no JWT required)
r.Post("/api/auth/login", handlers.NewAuthHandler(cfg, db).Login)
r.Post("/api/auth/refresh", handlers.NewAuthHandler(cfg, db).Refresh)
// Protected API routes
r.Group(func(r chi.Router) {
r.Use(mw.JWT(cfg.JWTSecret))
wo := handlers.NewWorkOrderHandler(db)
r.Get("/api/work-orders", wo.List)
r.Post("/api/work-orders", wo.Create)
r.Get("/api/work-orders/{id}", wo.Get)
r.Put("/api/work-orders/{id}", wo.Update)
r.Delete("/api/work-orders/{id}", wo.Delete)
r.Put("/api/work-orders/{id}/status", wo.UpdateStatus)
res := handlers.NewResourceHandler(db)
r.Get("/api/work-orders/{id}/resources", res.List)
r.Post("/api/work-orders/{id}/resources", res.Assign)
r.Delete("/api/work-orders/{id}/resources/{rid}", res.Remove)
step := handlers.NewStepHandler(db)
r.Get("/api/work-orders/{id}/steps", step.List)
r.Post("/api/work-orders/{id}/steps", step.Create)
r.Put("/api/work-orders/{id}/steps/{sid}", step.Update)
r.Post("/api/work-orders/{id}/steps/{sid}/complete", step.Complete)
r.Delete("/api/work-orders/{id}/steps/{sid}", step.Delete)
att := handlers.NewAttachmentHandler(cfg, db)
r.Get("/api/work-orders/{id}/attachments", att.List)
r.Post("/api/work-orders/{id}/attachments", att.Upload)
r.Delete("/api/work-orders/{id}/attachments/{aid}", att.Delete)
r.Handle("/uploads/*", http.StripPrefix("/uploads/", http.FileServer(http.Dir(cfg.UploadPath))))
acc := handlers.NewAccountingHandler(db)
r.Get("/api/work-orders/{id}/accounting", acc.Get)
r.Put("/api/work-orders/{id}/accounting", acc.Upsert)
// Resource registries (master lists for assignment pickers)
r.Get("/api/registry/people", handlers.RegistryPeople(db))
r.Get("/api/registry/vehicles", handlers.RegistryVehicles(db))
r.Get("/api/registry/equipment", handlers.RegistryEquipment(db))
r.Get("/api/registry/materials", handlers.RegistryMaterials(db))
})
return r
}
```
### `internal/model/workorder.go`
```go
package model
import "time"
type WorkOrder struct {
ID int `db:"id" json:"id"`
WONumber string `db:"wo_number" json:"wo_number"`
Title string `db:"title" json:"title"`
Description string `db:"description" json:"description"`
Instructions string `db:"instructions" json:"instructions"`
Status string `db:"status" json:"status"`
Priority string `db:"priority" json:"priority"`
ScheduledStart *time.Time `db:"scheduled_start" json:"scheduled_start"`
ScheduledEnd *time.Time `db:"scheduled_end" json:"scheduled_end"`
ActualStart *time.Time `db:"actual_start" json:"actual_start"`
ActualEnd *time.Time `db:"actual_end" json:"actual_end"`
SiteName string `db:"site_name" json:"site_name"`
Address string `db:"address" json:"address"`
Lat *float64 `db:"lat" json:"lat"`
Lng *float64 `db:"lng" json:"lng"`
AccessNotes string `db:"access_notes" json:"access_notes"`
ParentType *string `db:"parent_type" json:"parent_type"`
ParentID *int `db:"parent_id" json:"parent_id"`
CreatedBy int `db:"created_by" json:"created_by"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// Enriched view returned by GET /work-orders/{id}
type WorkOrderDetail struct {
WorkOrder
Resources []AssignedResource `json:"resources"`
Steps []Step `json:"steps"`
Attachments []Attachment `json:"attachments"`
Accounting []AccountingCode `json:"accounting"`
}
type WorkOrderListItem struct {
ID int `db:"id" json:"id"`
WONumber string `db:"wo_number" json:"wo_number"`
Title string `db:"title" json:"title"`
Status string `db:"status" json:"status"`
Priority string `db:"priority" json:"priority"`
SiteName string `db:"site_name" json:"site_name"`
ScheduledStart *time.Time `db:"scheduled_start" json:"scheduled_start"`
StepCount int `db:"step_count" json:"step_count"`
StepsDone int `db:"steps_done" json:"steps_done"`
PhotoCount int `db:"photo_count" json:"photo_count"`
}
```
### `internal/repository/workorder.go`
```go
package repository
import (
"context"
"github.com/jmoiron/sqlx"
"github.com/yourorg/workorder/internal/model"
)
type WorkOrderRepo struct{ db *sqlx.DB }
func NewWorkOrderRepo(db *sqlx.DB) *WorkOrderRepo {
return &WorkOrderRepo{db: db}
}
func (r *WorkOrderRepo) List(ctx context.Context, status, search string) ([]model.WorkOrderListItem, error) {
query := `
SELECT
wo.id, wo.wo_number, wo.title, wo.status, wo.priority,
wo.site_name, wo.scheduled_start,
COUNT(s.id) AS step_count,
SUM(CAST(s.completed AS INT)) AS steps_done,
COUNT(a.id) AS photo_count
FROM work_orders wo
LEFT JOIN wo_steps s ON s.wo_id = wo.id
LEFT JOIN wo_attachments a ON a.wo_id = wo.id
WHERE (@status = '' OR wo.status = @status)
AND (@search = '' OR wo.title LIKE '%' + @search + '%'
OR wo.wo_number LIKE '%' + @search + '%'
OR wo.site_name LIKE '%' + @search + '%')
GROUP BY wo.id, wo.wo_number, wo.title, wo.status,
wo.priority, wo.site_name, wo.scheduled_start
ORDER BY wo.scheduled_start DESC, wo.id DESC`
rows := []model.WorkOrderListItem{}
err := r.db.SelectContext(ctx, &rows, query,
sqlx.Named("status", status),
sqlx.Named("search", search),
)
return rows, err
}
func (r *WorkOrderRepo) GetByID(ctx context.Context, id int) (*model.WorkOrder, error) {
var wo model.WorkOrder
err := r.db.GetContext(ctx, &wo,
`SELECT * FROM work_orders WHERE id = @p1`, id)
if err != nil {
return nil, err
}
return &wo, nil
}
func (r *WorkOrderRepo) Create(ctx context.Context, wo *model.WorkOrder) (int, error) {
var id int
err := r.db.QueryRowContext(ctx, `
INSERT INTO work_orders
(title, description, instructions, status, priority,
scheduled_start, scheduled_end, site_name, address, lat, lng,
access_notes, parent_type, parent_id, created_by, updated_at)
OUTPUT INSERTED.id
VALUES
(@title, @description, @instructions, @status, @priority,
@scheduled_start, @scheduled_end, @site_name, @address, @lat, @lng,
@access_notes, @parent_type, @parent_id, @created_by, GETUTCDATE())`,
sqlx.Named("title", wo.Title),
// ... remaining named params
).Scan(&id)
return id, err
}
func (r *WorkOrderRepo) UpdateStatus(ctx context.Context, id int, status string, userID int) error {
_, err := r.db.ExecContext(ctx, `
UPDATE work_orders
SET status = @p1, updated_at = GETUTCDATE(),
actual_start = CASE WHEN @p1 = 'in_progress' AND actual_start IS NULL
THEN GETUTCDATE() ELSE actual_start END,
actual_end = CASE WHEN @p1 = 'closed' THEN GETUTCDATE() ELSE actual_end END,
closed_at = CASE WHEN @p1 = 'closed' THEN GETUTCDATE() ELSE closed_at END,
closed_by = CASE WHEN @p1 = 'closed' THEN @p2 ELSE closed_by END
WHERE id = @p3`, status, userID, id)
return err
}
```
---
## 5. REST API Design
| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/auth/login` | Login, returns JWT |
| GET | `/api/work-orders?status=&search=&page=` | Paginated list |
| POST | `/api/work-orders` | Create work order |
| GET | `/api/work-orders/{id}` | Full detail (WO + resources + steps + photos + accounting) |
| PUT | `/api/work-orders/{id}` | Update work order fields |
| PUT | `/api/work-orders/{id}/status` | Status transition with audit |
| GET | `/api/work-orders/{id}/resources` | List assigned resources |
| POST | `/api/work-orders/{id}/resources` | Assign a resource |
| DELETE | `/api/work-orders/{id}/resources/{rid}` | Remove assignment |
| GET | `/api/work-orders/{id}/steps` | List checklist steps |
| POST | `/api/work-orders/{id}/steps` | Add a step |
| PUT | `/api/work-orders/{id}/steps/{sid}` | Edit step |
| POST | `/api/work-orders/{id}/steps/{sid}/complete` | Check off step |
| POST | `/api/work-orders/{id}/attachments` | Upload photo/file (multipart) |
| GET | `/api/work-orders/{id}/attachments` | List attachments |
| PUT | `/api/work-orders/{id}/accounting` | Upsert accounting codes |
| GET | `/api/registry/people` | Lookup: people for assignment picker |
| GET | `/api/registry/vehicles` | Lookup: vehicles |
| GET | `/api/registry/equipment` | Lookup: equipment |
| GET | `/api/registry/materials` | Lookup: materials |
### Standard Response Envelope
```json
{
"data": { ... },
"meta": { "page": 1, "per_page": 25, "total": 142 },
"error": null
}
```
### Status Transition Rules (enforced server-side)
```
draft → assigned → scheduled → in_progress → pending_review → closed
↘ (re-open) ↗
```
---
## 6. Frontend — Web Components
### Design Principles
- **No build step** — plain `<script type="module">` imports. Works directly in browser.
- **Shadow DOM** per component — style isolation, no CSS leakage.
- **Custom events** for cross-component communication — no shared global state pollution.
- **`api.mjs`** is the single HTTP layer — all fetch calls go through it.
### `web/lib/api.mjs`
```javascript
const BASE = '/api';
let _token = localStorage.getItem('wo_token') || '';
export function setToken(t) {
_token = t;
localStorage.setItem('wo_token', t);
}
async function request(method, path, body, isFormData = false) {
const headers = { Authorization: `Bearer ${_token}` };
if (!isFormData) headers['Content-Type'] = 'application/json';
const res = await fetch(BASE + path, {
method,
headers,
body: body
? (isFormData ? body : JSON.stringify(body))
: undefined,
});
if (res.status === 401) {
// Token expired — redirect to login
window.dispatchEvent(new CustomEvent('auth:expired'));
return;
}
const json = await res.mjson();
if (!res.ok) throw new Error(json.error || 'Request failed');
return json.data;
}
export const api = {
get: (path) => request('GET', path),
post: (path, body) => request('POST', path, body),
put: (path, body) => request('PUT', path, body),
delete: (path) => request('DELETE', path),
upload: (path, form) => request('POST', path, form, true),
};
```
### `web/lib/router.mjs`
```javascript
// Hash-based router — no server config needed
class Router {
#routes = [];
on(pattern, handler) {
this.#routes.push({ pattern: new URLPattern({ pathname: pattern }), handler });
return this;
}
start() {
const dispatch = () => {
const path = location.hash.slice(1) || '/';
for (const { pattern, handler } of this.#routes) {
const m = pattern.exec({ pathname: path });
if (m) { handler(m.pathname.groups); return; }
}
};
window.addEventListener('hashchange', dispatch);
dispatch();
}
}
export const router = new Router();
```
### `web/components/wo-list.mjs`
```javascript
import { api } from '../lib/api.mjs';
class WoList extends HTMLElement {
#data = [];
#status = '';
connectedCallback() {
this.attachShadow({ mode: 'open' });
this.#render();
this.#load();
}
async #load() {
const status = this.getAttribute('filter-status') || '';
this.#data = await api.get(`/work-orders?status=${status}`);
this.#render();
}
#render() {
this.shadowRoot.innerHTML = `
<style>
:host { display: block; }
.grid { display: grid; gap: 0.75rem; }
.card {
background: var(--surface, #fff);
border: 1px solid var(--border, #e2e8f0);
border-radius: 8px;
padding: 1rem 1.25rem;
cursor: pointer;
display: grid;
grid-template-columns: 1fr auto;
gap: 0.25rem 1rem;
transition: box-shadow .15s;
}
.card:hover { box-shadow: 0 4px 12px rgba(0,0,0,.08); }
.wo-number { font-size: .75rem; color: var(--muted); }
.title { font-weight: 600; color: var(--text); }
.progress { font-size: .8rem; color: var(--muted); }
</style>
<div class="grid">
${this.#data.map(wo => `
<div class="card" data-id="${wo.id}">
<div>
<div class="wo-number">${wo.wo_number}</div>
<div class="title">${wo.title}</div>
<div class="progress">
${wo.site_name} · ${wo.steps_done}/${wo.step_count} steps
</div>
</div>
<wo-status-badge status="${wo.status}"></wo-status-badge>
</div>
`).join('')}
</div>`;
this.shadowRoot.querySelectorAll('.card').forEach(card => {
card.addEventListener('click', () => {
this.dispatchEvent(new CustomEvent('wo:select',
{ detail: { id: card.dataset.id }, bubbles: true, composed: true }));
});
});
}
}
customElements.define('wo-list', WoList);
```
### `web/components/wo-resource-panel.mjs`
```javascript
import { api } from '../lib/api.mjs';
class WoResourcePanel extends HTMLElement {
#woId = null;
#resources = [];
#registry = { people: [], vehicles: [], equipment: [], materials: [] };
static get observedAttributes() { return ['wo-id']; }
attributeChangedCallback(name, _, val) {
if (name === 'wo-id') { this.#woId = val; this.#load(); }
}
connectedCallback() {
this.attachShadow({ mode: 'open' });
if (this.#woId) this.#load();
}
async #load() {
const [resources, people, vehicles, equipment, materials] = await Promise.all([
api.get(`/work-orders/${this.#woId}/resources`),
api.get('/registry/people'),
api.get('/registry/vehicles'),
api.get('/registry/equipment'),
api.get('/registry/materials'),
]);
this.#resources = resources;
this.#registry = { people, vehicles, equipment, materials };
this.#render();
}
async #assign(type, resourceId) {
await api.post(`/work-orders/${this.#woId}/resources`, {
resource_type: type, resource_id: Number(resourceId)
});
await this.#load();
}
async #remove(assignmentId) {
await api.delete(`/work-orders/${this.#woId}/resources/${assignmentId}`);
await this.#load();
}
#sectionHTML(label, type, list) {
const assigned = this.#resources.filter(r => r.resource_type === type);
const available = list.filter(item =>
!assigned.some(a => a.resource_id === item.id));
return `
<section>
<h3>${label}</h3>
<ul>
${assigned.map(a => `
<li>${a.name}
<button class="remove-btn" data-id="${a.id}">×</button>
</li>`).join('')}
</ul>
<select class="add-select" data-type="${type}">
<option value="">Add ${label.slice(0,-1)}...</option>
${available.map(item =>
`<option value="${item.id}">${item.name ?? item.unit_number}</option>`
).join('')}
</select>
</section>`;
}
#render() {
const { people, vehicles, equipment, materials } = this.#registry;
this.shadowRoot.innerHTML = `
<style>
:host { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
section { background: var(--surface); border-radius: 8px; padding: 1rem; }
h3 { margin: 0 0 .5rem; font-size: .9rem; text-transform: uppercase;
letter-spacing: .05em; color: var(--muted); }
ul { list-style: none; padding: 0; margin: 0 0 .5rem; }
li { display: flex; justify-content: space-between; padding: .25rem 0;
border-bottom: 1px solid var(--border); font-size: .9rem; }
select { width: 100%; padding: .4rem; border-radius: 6px;
border: 1px solid var(--border); }
.remove-btn { background: none; border: none; cursor: pointer;
color: var(--danger, #c0392b); font-size: 1.1rem; }
</style>
${this.#sectionHTML('People', 'person', people)}
${this.#sectionHTML('Vehicles', 'vehicle', vehicles)}
${this.#sectionHTML('Equipment', 'equipment', equipment)}
${this.#sectionHTML('Materials', 'material', materials)}`;
this.shadowRoot.querySelectorAll('.add-select').forEach(sel => {
sel.addEventListener('change', e => {
if (e.target.value) this.#assign(e.target.dataset.type, e.target.value);
});
});
this.shadowRoot.querySelectorAll('.remove-btn').forEach(btn => {
btn.addEventListener('click', () => this.#remove(btn.dataset.id));
});
}
}
customElements.define('wo-resource-panel', WoResourcePanel);
```
### `web/components/wo-checklist.mjs`
```javascript
import { api } from '../lib/api.mjs';
class WoChecklist extends HTMLElement {
#woId = null;
#steps = [];
static get observedAttributes() { return ['wo-id']; }
attributeChangedCallback(_, __, val) { this.#woId = val; this.#load(); }
connectedCallback() { this.attachShadow({ mode: 'open' }); }
async #load() {
this.#steps = await api.get(`/work-orders/${this.#woId}/steps`);
this.#render();
}
async #complete(stepId) {
await api.post(`/work-orders/${this.#woId}/steps/${stepId}/complete`, {});
await this.#load();
this.dispatchEvent(new CustomEvent('steps:updated', { bubbles: true, composed: true }));
}
#render() {
const done = this.#steps.filter(s => s.completed).length;
const total = this.#steps.length;
const pct = total ? Math.round(done / total * 100) : 0;
this.shadowRoot.innerHTML = `
<style>
.progress-bar { height: 6px; background: var(--border); border-radius: 3px; margin-bottom: 1rem; }
.progress-fill { height: 100%; border-radius: 3px;
background: var(--accent, #0a7ea4); width: ${pct}%; transition: width .3s; }
.step { display: flex; gap: .75rem; align-items: flex-start;
padding: .75rem 0; border-bottom: 1px solid var(--border); }
.step.done .step-title { text-decoration: line-through; color: var(--muted); }
input[type=checkbox] { width: 1.2rem; height: 1.2rem; accent-color: var(--accent); margin-top: .15rem; }
.step-title { font-weight: 500; }
.step-desc { font-size: .85rem; color: var(--muted); margin-top: .2rem; }
.summary { font-size: .85rem; color: var(--muted); margin-bottom: .5rem; }
</style>
<div class="progress-bar"><div class="progress-fill"></div></div>
<div class="summary">${done} of ${total} steps complete</div>
${this.#steps.map(s => `
<div class="step ${s.completed ? 'done' : ''}">
<input type="checkbox" data-id="${s.id}" ${s.completed ? 'checked disabled' : ''}>
<div>
<div class="step-title">${s.step_order}. ${s.title}</div>
${s.description ? `<div class="step-desc">${s.description}</div>` : ''}
</div>
</div>`).join('')}`;
this.shadowRoot.querySelectorAll('input[type=checkbox]:not([disabled])').forEach(cb => {
cb.addEventListener('change', () => this.#complete(cb.dataset.id));
});
}
}
customElements.define('wo-checklist', WoChecklist);
```
### `web/styles/global.css` — Design Tokens
```css
:root {
--navy: #0d2137;
--accent: #0a7ea4;
--accent-lt: #14b8d4;
--surface: #ffffff;
--bg: #f0f6fa;
--border: #e2ebf0;
--text: #1a2e3b;
--muted: #64748b;
--danger: #c0392b;
--success: #1d9d6c;
--warning: #e07b39;
--radius: 8px;
--shadow: 0 2px 8px rgba(0,0,0,.08);
font-family: Calibri, 'Segoe UI', system-ui, sans-serif;
font-size: 16px;
color: var(--text);
background: var(--bg);
}
```
---
## 7. File & Photo Handling
### Go Upload Handler
```go
func (h *AttachmentHandler) Upload(w http.ResponseWriter, r *http.Request) {
woID := chi.URLParam(r, "id")
r.ParseMultipartForm(32 << 20) // 32 MB max
file, header, err := r.FormFile("file")
if err != nil { respondError(w, 400, "missing file"); return }
defer file.Close()
// Build storage path: uploads/{wo_id}/{uuid}{ext}
ext := filepath.Ext(header.Filename)
fname := uuid.New().String() + ext
relPath := filepath.Join(woID, fname)
absPath := filepath.Join(h.cfg.UploadPath, relPath)
os.MkdirAll(filepath.Dir(absPath), 0755)
dst, _ := os.Create(absPath)
defer dst.Close()
io.Copy(dst, file)
// Extract EXIF GPS if it's a JPEG
lat, lng := extractGPS(absPath)
// Save record
att := &model.Attachment{
WOID: mustInt(woID),
FileName: header.Filename,
FilePath: relPath,
FileType: header.Header.Get("Content-Type"),
FileSize: header.Size,
Phase: r.FormValue("phase"),
Caption: r.FormValue("caption"),
Lat: lat,
Lng: lng,
UploadedBy: userIDFromCtx(r.Context()),
}
id, _ := h.repo.Create(r.Context(), att)
respond(w, 201, map[string]any{"id": id, "url": "/uploads/" + relPath})
}
```
### Frontend Photo Capture (`wo-photo-panel.mjs` key logic)
```javascript
// Use camera on mobile devices
async #capturePhoto() {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.capture = 'environment'; // rear camera
input.onchange = async (e) => {
const file = e.target.files[0];
const form = new FormData();
form.append('file', file);
form.append('phase', this.#phase); // 'before' | 'during' | 'after'
// Attach GPS from browser if available
if (navigator.geolocation) {
await new Promise(res => navigator.geolocation.getCurrentPosition(pos => {
form.append('lat', pos.coords.latitude);
form.append('lng', pos.coords.longitude);
res();
}, res));
}
await api.upload(`/work-orders/${this.#woId}/attachments`, form);
await this.#load();
};
input.click();
}
```
---
## 8. Maps & Location
- **Leaflet.mjs** (open source, no API key required) loaded via CDN for the embedded map.
- **Google Maps Directions API** (or OpenRouteService if you want fully open source) for turn-by-turn routing.
- Coordinates stored as `lat`/`lng` decimals in SQL Server. No geography type required unless you add spatial radius queries later.
### `web/components/wo-map.mjs` skeleton
```javascript
// Leaflet loaded via <script> in index.html
class WoMap extends HTMLElement {
connectedCallback() {
this.style.display = 'block';
this.style.height = '300px';
const map = L.map(this).setView([0, 0], 13);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png').addTo(map);
const lat = parseFloat(this.getAttribute('lat'));
const lng = parseFloat(this.getAttribute('lng'));
if (lat && lng) {
map.setView([lat, lng], 15);
L.marker([lat, lng]).addTo(map)
.bindPopup(this.getAttribute('site-name') || 'Work Site').openPopup();
}
}
}
customElements.define('wo-map', WoMap);
```
### Directions button
```javascript
// Opens Google Maps / Apple Maps turn-by-turn in native app on mobile
function openDirections(lat, lng, label) {
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
const url = isIOS
? `maps://maps.apple.com/?daddr=${lat},${lng}&dirflg=d`
: `https://maps.google.com/maps?daddr=${lat},${lng}`;
window.open(url, '_blank');
}
```
---
## 9. Authentication & Authorization
### JWT Middleware (Go)
```go
func JWT(secret string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth := r.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer ") {
http.Error(w, `{"error":"unauthorized"}`, 401); return
}
token, err := jwt.ParseWithClaims(strings.TrimPrefix(auth, "Bearer "),
&Claims{}, func(t *jwt.Token) (any, error) {
return []byte(secret), nil
})
if err != nil || !token.Valid {
http.Error(w, `{"error":"invalid token"}`, 401); return
}
claims := token.Claims.(*Claims)
ctx := context.WithValue(r.Context(), ctxKeyUser, claims)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
```
### Roles to implement
| Role | Can Do |
|------|--------|
| `admin` | Full CRUD, close any WO, manage registries |
| `dispatcher` | Create & assign WOs, edit all fields |
| `field_tech` | View assigned WOs, complete steps, upload photos |
| `viewer` | Read-only access |
---
## 10. Docker Deployment
### `Dockerfile`
```dockerfile
# ── Build stage ───────────────────────────────────────────
FROM golang:1.23-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o workorder ./cmd/server
# ── Runtime stage ─────────────────────────────────────────
FROM alpine:3.20
WORKDIR /app
RUN apk add --no-cache tzdata ca-certificates
COPY --from=builder /app/workorder .
COPY web/ ./web/
EXPOSE 8080
CMD ["./workorder"]
```
### `docker-compose.yml`
```yaml
services:
api:
build: .
ports:
- "8080:8080"
environment:
ADDR: ":8080"
DB_DSN: "sqlserver://sa:${SA_PASSWORD}@mssql:1433?database=workorders"
JWT_SECRET: "${JWT_SECRET}"
UPLOAD_PATH: "/uploads"
volumes:
- uploads:/uploads
depends_on:
- mssql
restart: unless-stopped
mssql:
image: mcr.microsoft.com/mssql/server:2022-latest
environment:
SA_PASSWORD: "${SA_PASSWORD}"
ACCEPT_EULA: "Y"
MSSQL_PID: "Developer"
volumes:
- mssql_data:/var/opt/mssql
ports:
- "1433:1433" # Remove in production
volumes:
uploads:
mssql_data:
```
If you're connecting to your **existing SQL Server** instance, just point `DB_DSN` at it and drop the `mssql` service.
---
## 11. Phased Build Plan
### Phase 1 — Foundation (Weeks 1–4)
**Goal:** Working CRUD with project linking and status flow.
- [ ] Repo setup: Go module, chi, sqlx, godotenv
- [ ] DB migration: `work_orders` table + indexes
- [ ] Auth: login endpoint, JWT middleware, user table
- [ ] Work Order CRUD endpoints (list, get, create, update, status)
- [ ] Frontend shell: `index.html`, router, `api.mjs`, global CSS
- [ ] `<wo-list>` — list with search + filter by status
- [ ] `<wo-form>` — create / edit form with all base fields
- [ ] `<wo-status-badge>` — reusable status pill
- [ ] Docker Compose working locally
- [ ] Basic role checking (admin vs viewer)
### Phase 2 — Field Features (Weeks 5–8)
**Goal:** Everything a field crew needs on their phone.
- [ ] DB: `wo_steps`, `wo_resources`, resource registry tables
- [ ] Step endpoints + `<wo-checklist>` with check-off
- [ ] Resource registry endpoints + `<wo-resource-panel>` picker
- [ ] DB: `wo_attachments` table
- [ ] Photo upload endpoint with GPS extraction
- [ ] `<wo-photo-panel>` — capture, gallery, before/during/after phases
- [ ] `<wo-map>` — Leaflet map + site pin
- [ ] Directions button (native maps deeplink)
- [ ] Mobile-responsive layout (CSS Grid, touch-friendly targets)
### Phase 3 — Integrations (Weeks 9–12)
**Goal:** Connects to the rest of the system.
- [ ] DB: `wo_accounting` table
- [ ] `<wo-accounting>` — GL, cost center, WBS, billing ref fields
- [ ] Parent linking: polymorphic `parent_type`/`parent_id`
- [ ] Link WOs to existing Projects (query existing project IDs)
- [ ] Link to Trouble Tickets / Service Orders (when those APIs exist)
- [ ] Audit log table + `<wo-timeline>` component
- [ ] Email notification on assignment (Go `net/smtp` or SendGrid)
- [ ] Reporting endpoint: WOs by status, by cost center, by date range
### Phase 4 — Optimization (Week 13+)
- [ ] Dashboard `<wo-dashboard>` — KPI cards + charts (Chart.mjs via CDN)
- [ ] Crew scheduling calendar view
- [ ] Offline support (Service Worker + IndexedDB cache for field use)
- [ ] CSV / Excel export for accounting reconciliation
- [ ] Full-text search across WO title, site, instructions
- [ ] Webhook / API for third-party integrations
---
## 12. Development Conventions
### Go
```
Packages: lowercase, single word (handlers, repository, model)
Errors: always wrapped (fmt.Errorf("repo.Create: %w", err))
HTTP errors: JSON envelope {"error": "message"} with correct status code
DB queries: named params only @param_name — never string concat
Logging: log/slog structured key-value pairs
Tests: _test.go files table-driven, httptest.NewRecorder for handlers
```
### JavaScript
```
Components: kebab-case custom elements <wo-form>, <wo-map>
Private: # prefix for class fields #data, #load(), #render()
Events: colon namespaced 'wo:select', 'steps:updated', 'auth:expired'
API calls: always in the component no inline fetch(), always via api.mjs
Render: full innerHTML redraw simple, no virtual DOM needed at this scale
State: component-local lift to app-root only when truly shared
```
### Git Branch Strategy
```
main — production-ready, tagged releases
develop — integration branch
feature/* — individual features (feature/wo-checklist)
fix/* — bug fixes
```
---
## Go Dependencies
```go
// go.mod
require (
github.com/go-chi/chi/v5 v5.1.0
github.com/jmoiron/sqlx v1.3.5
github.com/microsoft/go-mssqldb v1.7.1
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
)
```
## Frontend Dependencies (CDN, no npm)
```html
<!-- in index.html -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9/dist/leaflet.css">
<script src="https://unpkg.com/leaflet@1.9/dist/leaflet.mjs"></script>
<!-- Chart.mjs added in Phase 4 only -->
<script src="https://cdn.mjsdelivr.net/npm/chart.mjs@4/dist/chart.umd.min.mjs"></script>
```
Zero build tooling. Zero npm for the frontend. Open in browser and go.
## Work Order Profiles
### Overview
A **Work Order Profile** is a reusable template that defines the default structure and behavior for a category of work orders. When a profile is applied to a work order (at creation or post-creation), its steps, instructions, priority, and duration are loaded in — then customized as needed.
> **Key idea:** Profile = preset + flexibility. The profile defines the standard; each WO can deviate from it.
---
### Profile Step Types
Profile steps have a **type** that controls how they are displayed and completed in the field. Types are a fixed code-level constant — not a database table. Each type has optional **type_config** (stored as JSON) that gets set when the step is added to a profile.
#### Built-in Step Types
| Type | Value | Field behavior |
|------|-------|----------------|
| Work Step | `work_step` | Standard checkbox — mark done, optional note |
| Photo | `photo` | Camera capture required; config defines phase and prompt |
| Inspection | `inspection` | Pass / Fail / N/A buttons; config defines criteria text |
| Note | `note` | Free-text entry field; config defines the prompt label |
#### type_config JSON by Type
```json
// work_step — no config needed
{}
// photo
{
"phase": "before", // "before" | "during" | "after"
"caption_prompt": "Photo of existing equipment before any work begins"
}
// inspection
{
"criteria": "All cable slack coiled and secured per spec"
}
// note
{
"prompt": "Record any site hazards or access issues observed"
}
```
#### Example profile: Fiber Splice Job
```
1. [photo] Before — site overview { phase: "before", caption_prompt: "Wide shot of work area" }
2. [work_step] Power down splice enclosure
3. [work_step] Prep and strip fiber ends
4. [inspection] Check splice loss < 0.1 dB { criteria: "OTDR reading below 0.1 dB loss" }
5. [photo] Splice tray loaded and sealed { phase: "during", caption_prompt: "Tray with labeled splices" }
6. [work_step] Seal enclosure and torque to spec
7. [note] Record any deviations or notes { prompt: "Deviations from standard procedure?" }
8. [photo] After — completed enclosure { phase: "after", caption_prompt: "Sealed enclosure, final install" }
```
---
### Database
```sql
-- Profiles table
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = 'wo_profiles')
BEGIN
CREATE TABLE wo_profiles (
id INT IDENTITY PRIMARY KEY,
name NVARCHAR(200) NOT NULL,
description NVARCHAR(MAX),
category NVARCHAR(100),
default_priority NVARCHAR(10) NOT NULL DEFAULT 'normal',
default_duration_hours INT,
default_instructions NVARCHAR(MAX),
active BIT NOT NULL DEFAULT 1,
created_at DATETIME2 NOT NULL DEFAULT GETUTCDATE(),
updated_at DATETIME2 NOT NULL DEFAULT GETUTCDATE()
);
END
-- Profile steps table (with step_type and type_config)
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = 'wo_profile_steps')
BEGIN
CREATE TABLE wo_profile_steps (
id INT IDENTITY PRIMARY KEY,
profile_id INT NOT NULL REFERENCES wo_profiles(id) ON DELETE CASCADE,
step_order INT NOT NULL,
title NVARCHAR(200) NOT NULL,
description NVARCHAR(MAX),
required BIT NOT NULL DEFAULT 1,
step_type NVARCHAR(30) NOT NULL DEFAULT 'work_step',
-- 'work_step' | 'photo' | 'inspection' | 'note'
type_config NVARCHAR(MAX) -- JSON; shape depends on step_type
);
CREATE INDEX ix_profile_steps ON wo_profile_steps (profile_id, step_order);
END
-- WO steps also carry step_type and type_config (copied from profile on apply)
-- Migration: add columns if they do not exist
IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID('wo_steps') AND name = 'step_type')
ALTER TABLE wo_steps ADD step_type NVARCHAR(30) NOT NULL DEFAULT 'work_step';
IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID('wo_steps') AND name = 'type_config')
ALTER TABLE wo_steps ADD type_config NVARCHAR(MAX) NULL;
```
### API Endpoints
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/profiles` | List active profiles (search, category filter) |
| POST | `/api/profiles` | Create profile |
| GET | `/api/profiles/{id}` | Get profile with its steps |
| PUT | `/api/profiles/{id}` | Update profile fields |
| DELETE | `/api/profiles/{id}` | Soft delete (set active=0) |
| GET | `/api/profiles/{id}/steps` | List profile steps |
| POST | `/api/profiles/{id}/steps` | Add step to profile |
| PUT | `/api/profiles/{id}/steps/{sid}` | Update profile step |
| DELETE | `/api/profiles/{id}/steps/{sid}` | Remove step from profile |
| POST | `/api/work-orders/{id}/apply-profile/{profileId}` | Apply profile to WO |
#### apply-profile behavior
Request body: `{ "mode": "append" | "replace" }`
- **append** — inserts profile steps after existing steps (step_order continues from current max)
- **replace** — deletes all existing steps, then inserts profile steps starting at order 1
- Both modes copy `step_type` and `type_config` from the profile step into `wo_steps`
- Both modes update `instructions` if currently blank and `priority` if WO is still `draft`
### Go Models
```go
type Profile struct {
ID int `db:"id" json:"id"`
Name string `db:"name" json:"name"`
Description string `db:"description" json:"description"`
Category string `db:"category" json:"category"`
DefaultPriority string `db:"default_priority" json:"default_priority"`
DefaultDurationHours *int `db:"default_duration_hours" json:"default_duration_hours"`
DefaultInstructions string `db:"default_instructions" json:"default_instructions"`
Active bool `db:"active" json:"active"`
StepCount int `db:"step_count" json:"step_count"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
Steps []ProfileStep `db:"-" json:"steps,omitempty"`
}
type ProfileStep struct {
ID int `db:"id" json:"id"`
ProfileID int `db:"profile_id" json:"profile_id"`
StepOrder int `db:"step_order" json:"step_order"`
Title string `db:"title" json:"title"`
Description string `db:"description" json:"description"`
Required bool `db:"required" json:"required"`
StepType string `db:"step_type" json:"step_type"` // "work_step"|"photo"|"inspection"|"note"
TypeConfig string `db:"type_config" json:"type_config"` // JSON string
}
// Step also gains these two fields (in model/models.go)
// StepType string `db:"step_type" json:"step_type"`
// TypeConfig string `db:"type_config" json:"type_config"`
```
### Frontend Components
**`web/components/registry/profile-list.mjs`**
- List page at `/registry/profiles`
- Cards: name, category badge, step count, active status
- Search + category filter, edit/deactivate actions, "New Profile" button
**`web/components/registry/profile-form.mjs`**
- Modal dialog for create/edit
- Fields: name (required), description, category, default priority, default duration hours, default instructions
- Inline step editor: ordered step list with add / edit / reorder / delete
- When adding a step: type picker first (work_step / photo / inspection / note icon buttons)
- After type selected: type-specific config fields appear inline:
- `photo` → phase select (before/during/after) + caption prompt input
- `inspection` → criteria text input
- `note` → prompt label input
- `work_step` → no extra fields
- Active toggle in edit mode
**`web/components/work-orders/wo-checklist.mjs`**
- Each step renders differently based on `step_type`:
- `work_step` — large checkbox + title + optional note textarea
- `photo` — camera icon button labeled with `type_config.caption_prompt`; tapping opens photo panel pre-set to the configured phase; completed when a photo is attached to this step
- `inspection` — Pass / Fail / N/A button group; completed when any selection is made; result stored in completion notes
- `note` — text input labeled with `type_config.prompt`; completed when non-empty text is saved
- Progress bar counts all step types equally
**WO Form integration (`wo-form.mjs`)**
- "Load Profile" ghost button at top of form (shown only on new WOs or draft status)
- Searchable profile picker dialog — on confirm pre-fills priority and instructions (if blank)
- Steps applied server-side via apply-profile after the WO saves; checklist tab then refreshes
**WO Detail integration (`wo-detail.mjs`)**
- "Apply Profile" button in header actions alongside Edit / Change Status
- Two-step dialog: (1) pick profile, (2) if WO has existing steps — prompt append vs replace
- Calls `POST /api/work-orders/{id}/apply-profile/{profileId}` then refreshes Checklist tab
### Sidebar Nav
Under Resources section:
```
Profiles /registry/profiles (layout-template icon)
```
### apply-profile Handler (pseudocode)
```go
func (h *ProfileHandler) Apply(w http.ResponseWriter, r *http.Request) {
woID, profileID := intParam(r, "id"), intParam(r, "profileId")
var body struct { Mode string `json:"mode"` }
json.NewDecoder(r.Body).Decode(&body)
profile, steps := // load profile + steps
if body.Mode == "replace" {
db.Exec(`DELETE FROM wo_steps WHERE wo_id = @p1`, woID)
}
var maxOrder int
db.Get(&maxOrder, `SELECT ISNULL(MAX(step_order), 0) FROM wo_steps WHERE wo_id = @p1`, woID)
for _, s := range steps {
db.Exec(`INSERT INTO wo_steps (wo_id, step_order, title, description, required, step_type, type_config)
VALUES (@p1, @p2, @p3, @p4, @p5, @p6, @p7)`,
woID, maxOrder+s.StepOrder, s.Title, s.Description, s.Required, s.StepType, s.TypeConfig)
}
db.Exec(`
UPDATE work_orders SET
instructions = CASE WHEN (instructions IS NULL OR instructions = '') THEN @p1 ELSE instructions END,
priority = CASE WHEN status = 'draft' THEN @p2 ELSE priority END,
updated_at = GETUTCDATE()
WHERE id = @p3`,
profile.DefaultInstructions, profile.DefaultPriority, woID)
}
```