Files
workorders/CLAUDE.md
T

1295 lines
47 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.