Files
workorders/CLAUDE.md
T

47 KiB
Raw Blame History

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
  2. Project Structure
  3. Database Schema
  4. Go Backend
  5. REST API Design
  6. Frontend — Web Components
  7. File & Photo Handling
  8. Maps & Location
  9. Authentication & Authorization
  10. Docker Deployment
  11. Phased Build Plan
  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

-- ── 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

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

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

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

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

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

{
  "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

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

// 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

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

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

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

: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

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)

// 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

// 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

// 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)

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

# ── 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

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.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)

<!-- 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.