# 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 `` in `index.html` — no build step needed:
```html
```
```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
```
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 ↠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 +
│ │ │ └── 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 ↠— status + priority pills
│ │ ├── ui-button.mjs ↠— primary/ghost/danger variants
│ │ ├── ui-card.mjs ↠— surface container
│ │ ├── ui-dialog.mjs ↠— modal with backdrop
│ │ ├── ui-drawer.mjs ↠— slide-in panel (mobile forms)
│ │ ├── ui-toast.mjs ↠— success/error notifications
│ │ ├── ui-spinner.mjs ↠— loading state
│ │ ├── ui-empty.mjs ↠— empty state illustration + CTA
│ │ ├── ui-confirm.mjs ↠— "Are you sure?" dialog
│ │ ├── ui-search.mjs ↠— debounced search input
│ │ ├── ui-tabs.mjs ↠— tab bar + panels
│ │ ├── ui-avatar.mjs ↠— initials or photo avatar
│ │ └── ui-tooltip.mjs ↠— 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
- `
```
---
*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 │ │
│ │ │ │
│ └───────────────────┬────────────────────────────────┘ │
└──────────────────────│──────────────────────────────────┘
│ 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 # 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 `
```
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)
}
```