Files
workorders/CLAUDE.md
T

2313 lines
94 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 14)
**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 58)
**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 912)
**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 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.*