# Work Order System — Build TODO > Hand this file to Claude Code. All frontend files use `.mjs` extensions. > Stack: Go · Pure JS + Web Components · SQL Server · Docker --- ## Table of Contents - [Design System](#design-system) - [Project Structure](#project-structure) - [Phase 1 — Foundation](#phase-1--foundation) - [Phase 2 — Field Features](#phase-2--field-features) - [Phase 3 — Integrations & Admin](#phase-3--integrations--admin) - [Phase 4 — Polish & Optimization](#phase-4--polish--optimization) - [Component Inventory](#component-inventory) - [API Endpoints](#api-endpoints) - [Database Schema](#database-schema) - [Conventions](#conventions) --- ## Design System ### Color Palette ```css :root { /* Brand */ --navy: #0D2137; /* Primary dark — sidebar, headers */ --teal: #0A7EA4; /* Primary accent — buttons, links, active states */ --teal-lt: #14B8D4; /* Light accent — hover states, highlights */ --teal-dk: #075E7A; /* Dark accent — pressed states */ /* Surfaces */ --bg: #F0F6FA; /* Page background */ --surface: #FFFFFF; /* Cards, panels */ --surface-2: #E8F0F5; /* Inset areas, table stripes */ --sidebar-bg: #0D2137; /* Sidebar background */ --sidebar-hover: #153248; --sidebar-active: #0A7EA4; /* Text */ --text: #1A2E3B; /* Primary text */ --text-muted: #64748B; /* Secondary / helper text */ --text-inv: #FFFFFF; /* Text on dark backgrounds */ /* Semantic */ --success: #1D9D6C; --warning: #E07B39; --danger: #C0392B; --info: #0A7EA4; /* Borders & Shadows */ --border: #D1DDE6; --border-lt: #E8F0F5; --shadow-sm: 0 1px 3px rgba(0,0,0,.08); --shadow-md: 0 4px 12px rgba(0,0,0,.10); --shadow-lg: 0 8px 24px rgba(0,0,0,.12); /* Status Pills */ --status-draft: #94A3B8; --status-assigned: #0A7EA4; --status-scheduled: #8B5CF6; --status-in_progress: #E07B39; --status-pending_review: #D97706; --status-closed: #1D9D6C; /* Priority */ --priority-low: #64748B; --priority-normal: #0A7EA4; --priority-high: #E07B39; --priority-urgent: #C0392B; /* Spacing */ --radius-sm: 4px; --radius: 8px; --radius-lg: 12px; --radius-xl: 16px; /* Sidebar width */ --sidebar-w: 260px; --sidebar-collapsed: 64px; } ``` ### Typography Load via `` in `index.html` — no build step needed: ```html ``` ```css :root { --font-body: 'Inter', 'Segoe UI', system-ui, sans-serif; --font-mono: 'JetBrains Mono', 'Cascadia Code', monospace; --text-xs: 0.70rem; /* 11px — labels, caps */ --text-sm: 0.813rem; /* 13px — helper, meta */ --text-base: 0.938rem; /* 15px — body */ --text-md: 1.063rem; /* 17px — subheadings */ --text-lg: 1.25rem; /* 20px — section headers */ --text-xl: 1.5rem; /* 24px — page titles */ --text-2xl: 2rem; /* 32px — dashboard hero numbers */ --weight-normal: 400; --weight-medium: 500; --weight-semibold: 600; --weight-bold: 700; } ``` ### Component Design Patterns - Cards: white background, `var(--border)` border, `var(--radius)` corners, `var(--shadow-sm)` - Buttons: filled primary = `--teal`, ghost = transparent + `--teal` border, danger = `--danger` - Inputs: `--border` border, focus ring `--teal` 2px outline-offset, `--radius-sm` corners - Tables: header row `--surface-2`, alternating rows, sticky header on scroll - Status pills: colored dot + label, pill shape, light background tint of the status color - Icon + label pattern everywhere — use Lucide icons loaded via CDN (no npm) ### Icons ```html ``` Call `lucide.createIcons()` after each render. Use named icons: `clipboard-list`, `users`, `truck`, `wrench`, `map-pin`, `camera`, `file-invoice`, `layout-dashboard`, `settings`, `bell`, `chevron-left`, `plus`, `search`, `filter` --- ## Project Structure ``` workorder/ ├── CLAUDE.md ← !! READ FIRST — conventions for Claude Code ├── cmd/server/main.go ├── internal/ │ ├── api/ │ │ ├── router.go │ │ ├── middleware/ │ │ │ ├── auth.go │ │ │ ├── cors.go │ │ │ └── logger.go │ │ └── handlers/ │ │ ├── auth.go │ │ ├── workorder.go │ │ ├── step.go │ │ ├── resource.go │ │ ├── attachment.go │ │ ├── accounting.go │ │ ├── user.go │ │ ├── registry.go ← people/vehicle/equipment/material master lists │ │ ├── dashboard.go │ │ └── report.go │ ├── service/ │ │ ├── workorder.go │ │ ├── notification.go │ │ ├── spatial.go │ │ └── export.go ← CSV/Excel export │ ├── repository/ │ │ ├── db.go │ │ ├── workorder.go │ │ ├── step.go │ │ ├── resource.go │ │ ├── attachment.go │ │ ├── accounting.go │ │ ├── user.go │ │ └── registry.go │ ├── model/ │ │ ├── workorder.go │ │ ├── step.go │ │ ├── resource.go │ │ ├── attachment.go │ │ ├── accounting.go │ │ ├── user.go │ │ └── dashboard.go │ └── config/config.go │ ├── web/ ← All frontend — served as static files by Go │ ├── index.html ← App shell, loads fonts, Lucide, Leaflet │ ├── app.mjs ← custom element + client router │ │ │ ├── components/ │ │ ├── layout/ │ │ │ ├── app-sidebar.mjs ← Left nav — collapsible, mobile drawer │ │ │ ├── app-topbar.mjs ← Top bar — breadcrumb, notifications bell, user avatar │ │ │ ├── app-root.mjs ← Shell that wires sidebar + topbar +
│ │ │ └── app-mobile-nav.mjs ← Bottom tab bar for mobile (≤768px) │ │ │ │ │ ├── work-orders/ │ │ │ ├── wo-list.mjs ← Searchable, filterable WO list │ │ │ ├── wo-kanban.mjs ← Kanban board view (by status column) │ │ │ ├── wo-form.mjs ← Create / edit full form │ │ │ ├── wo-detail.mjs ← Read-only detail — tabs for each section │ │ │ ├── wo-checklist.mjs ← Step checklist with check-off + progress bar │ │ │ ├── wo-resource-panel.mjs← Assign people, vehicles, equipment, materials │ │ │ ├── wo-photo-panel.mjs ← Photo gallery, capture, before/during/after │ │ │ ├── wo-map.mjs ← Leaflet map + directions button │ │ │ ├── wo-accounting.mjs ← GL, cost center, WBS, billing ref fields │ │ │ ├── wo-timeline.mjs ← Audit/activity log feed │ │ │ └── wo-print.mjs ← Print-friendly layout for field packets │ │ │ │ │ ├── dashboard/ │ │ │ ├── dash-root.mjs ← Dashboard page shell │ │ │ ├── dash-kpi-card.mjs ← Reusable stat card (number + trend + icon) │ │ │ ├── dash-status-chart.mjs← Doughnut chart — WOs by status │ │ │ ├── dash-priority-bar.mjs← Bar chart — WOs by priority │ │ │ └── dash-recent-feed.mjs ← Recent activity feed │ │ │ │ │ ├── registry/ │ │ │ ├── people-list.mjs ← Manage crew / technician records │ │ │ ├── people-form.mjs │ │ │ ├── vehicle-list.mjs ← Manage fleet / vehicles │ │ │ ├── vehicle-form.mjs │ │ │ ├── equipment-list.mjs ← Manage tools & equipment │ │ │ ├── equipment-form.mjs │ │ │ ├── material-list.mjs ← Manage materials / inventory │ │ │ └── material-form.mjs │ │ │ │ │ ├── users/ │ │ │ ├── user-list.mjs ← User management (admin only) │ │ │ ├── user-form.mjs ← Create / edit user, assign role │ │ │ └── user-profile.mjs ← Current user profile + password change │ │ │ │ │ ├── reports/ │ │ │ ├── report-root.mjs ← Reports landing page │ │ │ ├── report-by-status.mjs │ │ │ ├── report-by-cost.mjs │ │ │ └── report-export.mjs ← CSV / Excel download triggers │ │ │ │ │ └── shared/ │ │ ├── ui-badge.mjs ← — status + priority pills │ │ ├── ui-button.mjs ← — primary/ghost/danger variants │ │ ├── ui-card.mjs ← — surface container │ │ ├── ui-dialog.mjs ← — modal with backdrop │ │ ├── ui-drawer.mjs ← — slide-in panel (mobile forms) │ │ ├── ui-toast.mjs ← — success/error notifications │ │ ├── ui-spinner.mjs ← — loading state │ │ ├── ui-empty.mjs ← — empty state illustration + CTA │ │ ├── ui-confirm.mjs ← — "Are you sure?" dialog │ │ ├── ui-search.mjs ← — debounced search input │ │ ├── ui-tabs.mjs ← — tab bar + panels │ │ ├── ui-avatar.mjs ← — initials or photo avatar │ │ └── ui-tooltip.mjs ← — hover tooltip │ │ │ ├── lib/ │ │ ├── api.mjs ← Fetch wrapper, auth header, error handling │ │ ├── router.mjs ← Hash/history client router │ │ ├── store.mjs ← Reactive state (lightweight signal pattern) │ │ ├── auth.mjs ← JWT storage, decode, role checks │ │ ├── format.mjs ← Date, currency, phone formatters │ │ ├── validate.mjs ← Form field validators │ │ └── utils.mjs ← Misc helpers, debounce, deepMerge │ │ │ └── styles/ │ ├── global.css ← CSS custom properties (design tokens above) │ ├── reset.css ← Modern CSS reset │ ├── typography.css ← Base font rules │ ├── forms.css ← Shared input/select/textarea styles │ ├── tables.css ← Shared table styles │ └── print.css ← Print overrides for wo-print.mjs │ ├── migrations/ │ ├── 001_initial.sql │ ├── 002_resources.sql │ ├── 003_attachments.sql │ ├── 004_accounting.sql │ ├── 005_users_roles.sql │ └── 006_audit_log.sql │ ├── uploads/ ← Bind-mounted in Docker ├── Dockerfile ├── docker-compose.yml ├── .env.example └── go.mod ``` --- ## Phase 1 — Foundation **Goal:** Running app with shell, sidebar, auth, and WO CRUD. ### 1.1 CLAUDE.md (create first) - [ ] Document `.mjs` extension convention for all frontend JS modules - [ ] Document Go conventions: package names, error wrapping, named DB params - [ ] Document CSS convention: always use design token vars, never hardcoded hex - [ ] Document component convention: Shadow DOM, `#private` fields, custom events - [ ] List all CDN dependencies and their globals (`L` for Leaflet, `lucide`, `Chart`) ### 1.2 Go Project Bootstrap - [ ] `go mod init github.com/yourorg/workorder` - [ ] Add dependencies: `chi/v5`, `sqlx`, `go-mssqldb`, `golang-jwt/jwt/v5`, `google/uuid`, `joho/godotenv` - [ ] `internal/config/config.go` — load from env: `ADDR`, `DB_DSN`, `JWT_SECRET`, `UPLOAD_PATH`, `BASE_URL` - [ ] `internal/repository/db.go` — `sqlx.Connect`, pool settings (max open 25, max idle 5, lifetime 5min) - [ ] `cmd/server/main.go` — wire config → db → router → `http.ListenAndServe` ### 1.3 Database — Migration 001 ```sql -- work_orders table (full schema as planned) -- Computed wo_number column: 'WO-' + zero-padded id -- status CHECK constraint -- priority CHECK constraint -- Indexes on status, parent, scheduled_start ``` ### 1.4 Auth - [ ] `migrations/005_users_roles.sql` ```sql CREATE TABLE users ( id INT IDENTITY PRIMARY KEY, username NVARCHAR(100) NOT NULL UNIQUE, email NVARCHAR(200) NOT NULL UNIQUE, display_name NVARCHAR(200), password_hash NVARCHAR(200) NOT NULL, -- bcrypt role NVARCHAR(30) NOT NULL DEFAULT 'viewer', -- admin | dispatcher | field_tech | viewer avatar_url NVARCHAR(500), active BIT NOT NULL DEFAULT 1, last_login DATETIME2, created_at DATETIME2 NOT NULL DEFAULT GETUTCDATE() ); -- Seed one admin user ``` - [ ] `POST /api/auth/login` — bcrypt compare, return `{ token, user }` with 8hr JWT - [ ] `POST /api/auth/refresh` — accepts valid token, returns new token - [ ] `GET /api/auth/me` — current user from token claims - [ ] JWT middleware: attach user to `context.Context`, return 401 JSON on failure - [ ] Role helper: `RequireRole(roles ...string)` middleware ### 1.5 Work Order Backend (CRUD) - [ ] `model/workorder.go` — `WorkOrder`, `WorkOrderListItem`, `WorkOrderDetail` structs with `db:` and `json:` tags - [ ] `repository/workorder.go` - `List(ctx, filters)` — status, search, priority, parentType, page/limit - `GetByID(ctx, id)` — single WO - `Create(ctx, wo)` — insert + return generated ID - `Update(ctx, wo)` — partial update, always set `updated_at = GETUTCDATE()` - `UpdateStatus(ctx, id, status, userID)` — transition + audit log insert - `Delete(ctx, id)` — soft delete (add `deleted_at` column) - `GetDetail(ctx, id)` — joins WO + steps + resources + attachments + accounting in one call - [ ] Handlers: `List`, `Get`, `Create`, `Update`, `Delete`, `UpdateStatus` - [ ] Paginated response: `{ data: [...], meta: { page, per_page, total } }` ### 1.6 Frontend Shell - [ ] `web/index.html` - Load Google Fonts (Inter + JetBrains Mono) - Load Lucide CDN - Load Leaflet CSS + JS CDN - Load Chart.js CDN - ` ``` --- *Start with Phase 1 in order. Each checkbox is one commit. Don't skip CLAUDE.md — it must exist before any code is written.* --- # Work Order System — Technical Build Plan **Stack:** Go (backend API) · Pure JavaScript + Web Components (frontend) · SQL Server (MSSQL) · Docker --- ## Table of Contents 1. [Architecture Overview](#1-architecture-overview) 2. [Project Structure](#2-project-structure) 3. [Database Schema](#3-database-schema) 4. [Go Backend](#4-go-backend) 5. [REST API Design](#5-rest-api-design) 6. [Frontend — Web Components](#6-frontend--web-components) 7. [File & Photo Handling](#7-file--photo-handling) 8. [Maps & Location](#8-maps--location) 9. [Authentication & Authorization](#9-authentication--authorization) 10. [Docker Deployment](#10-docker-deployment) 11. [Phased Build Plan](#11-phased-build-plan) 12. [Development Conventions](#12-development-conventions) --- ## 1. Architecture Overview ``` ┌─────────────────────────────────────────────────────────┐ │ Browser Client │ │ ┌────────────────────────────────────────────────────┐ │ │ │ Pure JS + Web Components SPA │ │ │ │ │ │ │ └───────────────────┬────────────────────────────────┘ │ └──────────────────────│──────────────────────────────────┘ │ REST/JSON over HTTPS ┌──────────────────────▼──────────────────────────────────┐ │ Go API Server │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │ Router │ │ Handlers │ │ Service │ │ │ │ (chi) │ │ │ │ Layer │ │ │ └──────────┘ └──────────┘ └──────────┘ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │ Auth │ │ Upload │ │ Spatial │ │ │ │ (JWT) │ │ Handler │ │ Utils │ │ │ └──────────┘ └──────────┘ └──────────┘ │ └──────────────────────┬──────────────────────────────────┘ │ sqlx / database/sql ┌──────────────────────▼──────────────────────────────────┐ │ SQL Server (MSSQL) │ │ work_orders · wo_resources · wo_steps · wo_attachments │ │ wo_accounting · wo_parents · users · resource_registry │ └─────────────────────────────────────────────────────────┘ ``` **Key decisions:** - **No framework on the frontend** — Custom Elements v1, Shadow DOM, ES Modules. Works in all modern browsers with zero build step. - **chi router** for Go — lightweight, idiomatic, stdlib-compatible middleware. - **sqlx** for DB access — thin wrapper over `database/sql`, named params, struct scanning. - **SQL Server** — matches your existing infrastructure; geography type for spatial data. - **Multipart uploads** stored to local volume (or swap for Azure Blob / S3 later). - **JWT** stateless auth — fits well with a SPA + API split. --- ## 2. Project Structure ``` workorder/ ├── cmd/ │ └── server/ │ └── main.go # Entry point, wires everything together ├── internal/ │ ├── api/ │ │ ├── router.go # chi router setup, middleware chain │ │ ├── middleware.go # JWT, CORS, logging, recovery │ │ └── handlers/ │ │ ├── workorder.go # WO CRUD handlers │ │ ├── resource.go # People / vehicle / equipment assignment │ │ ├── step.go # Checklist steps │ │ ├── attachment.go # Photo & file upload │ │ ├── accounting.go # Accounting code endpoints │ │ ├── parent.go # Parent record linking (polymorphic) │ │ └── auth.go # Login / token refresh │ ├── service/ │ │ ├── workorder.go # Business logic layer │ │ ├── resource.go │ │ ├── notification.go # Email/push when WO assigned │ │ └── spatial.go # Geocoding, distance calc │ ├── repository/ │ │ ├── workorder.go # SQL queries — work orders │ │ ├── resource.go # SQL queries — resources │ │ ├── step.go │ │ ├── attachment.go │ │ └── db.go # sqlx connection pool setup │ ├── model/ │ │ ├── workorder.go # Structs: WorkOrder, WorkOrderSummary │ │ ├── resource.go # Person, Vehicle, Equipment, Material │ │ ├── step.go # Step, StepCompletion │ │ ├── attachment.go # Attachment │ │ ├── accounting.go # AccountingCode │ │ └── auth.go # User, Claims │ └── config/ │ └── config.go # Env-based config (DB DSN, JWT secret, upload path) ├── web/ # Frontend — served as static files by Go │ ├── index.html # Shell — loads the app component │ ├── app.mjs # component, client-side router │ ├── components/ │ │ ├── wo-list.mjs # Work order list / search │ │ ├── wo-form.mjs # Create / edit work order │ │ ├── wo-detail.mjs # Read-only detail view │ │ ├── wo-checklist.mjs # Step checklist with check-off │ │ ├── wo-resource-panel.mjs # Assign people, vehicles, equipment │ │ ├── wo-photo-panel.mjs # Photo capture and gallery │ │ ├── wo-map.mjs # Embedded map + directions │ │ ├── wo-accounting.mjs # Accounting code fields │ │ ├── wo-status-badge.mjs # Status pill (reusable) │ │ └── wo-timeline.mjs # Activity / audit log │ ├── lib/ │ │ ├── api.mjs # Fetch wrapper, auth header injection │ │ ├── router.mjs # Tiny hash/history router │ │ ├── store.mjs # Lightweight reactive state (no Redux) │ │ └── utils.mjs # Formatters, validators │ └── styles/ │ ├── global.css # CSS custom properties (design tokens) │ └── reset.css ├── uploads/ # Stored attachments (bind-mounted in Docker) ├── migrations/ │ ├── 001_initial_schema.sql │ ├── 002_resource_registry.sql │ └── 003_spatial_fields.sql ├── docker-compose.yml ├── Dockerfile └── .env.example ``` --- ## 3. Database Schema ### Core Tables ```sql -- ── Work Orders ─────────────────────────────────────────────────────────────── CREATE TABLE work_orders ( id INT IDENTITY PRIMARY KEY, wo_number AS ('WO-' + RIGHT('000000' + CAST(id AS VARCHAR), 6)) PERSISTED, title NVARCHAR(200) NOT NULL, description NVARCHAR(MAX), instructions NVARCHAR(MAX), -- Rich text / markdown status NVARCHAR(30) NOT NULL DEFAULT 'draft', -- draft | assigned | scheduled | in_progress | pending_review | closed priority NVARCHAR(10) NOT NULL DEFAULT 'normal', -- low | normal | high | urgent scheduled_start DATETIME2, scheduled_end DATETIME2, actual_start DATETIME2, actual_end DATETIME2, -- Location site_name NVARCHAR(200), address NVARCHAR(400), lat DECIMAL(10,7), lng DECIMAL(10,7), access_notes NVARCHAR(MAX), -- Gate codes, access roads -- Polymorphic parent parent_type NVARCHAR(50), -- 'project' | 'ticket' | 'service_order' | NULL parent_id INT, -- Audit created_by INT NOT NULL, created_at DATETIME2 NOT NULL DEFAULT GETUTCDATE(), updated_at DATETIME2 NOT NULL DEFAULT GETUTCDATE(), closed_at DATETIME2, closed_by INT, CONSTRAINT chk_wo_status CHECK (status IN ('draft','assigned','scheduled','in_progress','pending_review','closed')), CONSTRAINT chk_wo_priority CHECK (priority IN ('low','normal','high','urgent')) ); -- ── Resource Registry (master lists) ───────────────────────────────────────── CREATE TABLE resource_people ( id INT IDENTITY PRIMARY KEY, name NVARCHAR(100) NOT NULL, role NVARCHAR(100), email NVARCHAR(200), phone NVARCHAR(30), active BIT NOT NULL DEFAULT 1 ); CREATE TABLE resource_vehicles ( id INT IDENTITY PRIMARY KEY, unit_number NVARCHAR(50) NOT NULL, description NVARCHAR(200), vehicle_type NVARCHAR(100), active BIT NOT NULL DEFAULT 1 ); CREATE TABLE resource_equipment ( id INT IDENTITY PRIMARY KEY, name NVARCHAR(200) NOT NULL, asset_tag NVARCHAR(100), category NVARCHAR(100), active BIT NOT NULL DEFAULT 1 ); CREATE TABLE resource_materials ( id INT IDENTITY PRIMARY KEY, name NVARCHAR(200) NOT NULL, unit NVARCHAR(30), -- 'ft', 'each', 'box' part_number NVARCHAR(100), active BIT NOT NULL DEFAULT 1 ); -- ── Work Order Resource Assignments ─────────────────────────────────────────── CREATE TABLE wo_resources ( id INT IDENTITY PRIMARY KEY, wo_id INT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE, resource_type NVARCHAR(20) NOT NULL, -- 'person' | 'vehicle' | 'equipment' | 'material' resource_id INT NOT NULL, quantity DECIMAL(10,2), -- For materials notes NVARCHAR(500), assigned_at DATETIME2 NOT NULL DEFAULT GETUTCDATE(), CONSTRAINT chk_resource_type CHECK (resource_type IN ('person','vehicle','equipment','material')) ); -- ── Checklist Steps ─────────────────────────────────────────────────────────── CREATE TABLE wo_steps ( id INT IDENTITY PRIMARY KEY, wo_id INT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE, step_order INT NOT NULL, title NVARCHAR(200) NOT NULL, description NVARCHAR(MAX), required BIT NOT NULL DEFAULT 1, completed BIT NOT NULL DEFAULT 0, completed_by INT, completed_at DATETIME2, notes NVARCHAR(MAX) -- Field notes added at completion ); -- ── Attachments (photos, docs) ──────────────────────────────────────────────── CREATE TABLE wo_attachments ( id INT IDENTITY PRIMARY KEY, wo_id INT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE, step_id INT REFERENCES wo_steps(id), -- Optional: attach to a specific step file_name NVARCHAR(500) NOT NULL, file_path NVARCHAR(1000) NOT NULL, -- Relative path under /uploads file_type NVARCHAR(100), -- MIME type file_size BIGINT, caption NVARCHAR(500), phase NVARCHAR(20), -- 'before' | 'during' | 'after' lat DECIMAL(10,7), -- Geo-tag from EXIF or device lng DECIMAL(10,7), uploaded_by INT NOT NULL, uploaded_at DATETIME2 NOT NULL DEFAULT GETUTCDATE() ); -- ── Accounting Codes ────────────────────────────────────────────────────────── CREATE TABLE wo_accounting ( id INT IDENTITY PRIMARY KEY, wo_id INT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE, code_type NVARCHAR(50) NOT NULL, -- 'gl_account' | 'cost_center' | 'wbs' | 'billing_ref' code_value NVARCHAR(200) NOT NULL, description NVARCHAR(500), CONSTRAINT uq_wo_accounting UNIQUE (wo_id, code_type) ); -- ── Audit Log ───────────────────────────────────────────────────────────────── CREATE TABLE wo_audit_log ( id INT IDENTITY PRIMARY KEY, wo_id INT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE, action NVARCHAR(100) NOT NULL, old_value NVARCHAR(MAX), new_value NVARCHAR(MAX), performed_by INT NOT NULL, performed_at DATETIME2 NOT NULL DEFAULT GETUTCDATE() ); -- ── Indexes ─────────────────────────────────────────────────────────────────── CREATE INDEX ix_wo_status ON work_orders (status); CREATE INDEX ix_wo_parent ON work_orders (parent_type, parent_id); CREATE INDEX ix_wo_scheduled ON work_orders (scheduled_start); CREATE INDEX ix_wo_resources ON wo_resources (wo_id, resource_type); CREATE INDEX ix_wo_attachments ON wo_attachments (wo_id); ``` --- ## 4. Go Backend ### `cmd/server/main.go` ```go package main import ( "log" "net/http" "github.com/yourorg/workorder/internal/api" "github.com/yourorg/workorder/internal/config" "github.com/yourorg/workorder/internal/repository" ) func main() { cfg := config.Load() // reads .env / environment variables db, err := repository.Connect(cfg.DBDSN) if err != nil { log.Fatalf("db connect: %v", err) } defer db.Close() r := api.NewRouter(cfg, db) log.Printf("listening on %s", cfg.Addr) if err := http.ListenAndServe(cfg.Addr, r); err != nil { log.Fatal(err) } } ``` ### `internal/config/config.go` ```go package config import "os" type Config struct { Addr string DBDSN string JWTSecret string UploadPath string BaseURL string } func Load() *Config { return &Config{ Addr: env("ADDR", ":8080"), DBDSN: env("DB_DSN", ""), JWTSecret: env("JWT_SECRET", "change-me"), UploadPath: env("UPLOAD_PATH", "./uploads"), BaseURL: env("BASE_URL", "http://localhost:8080"), } } func env(key, fallback string) string { if v := os.Getenv(key); v != "" { return v } return fallback } ``` ### `internal/api/router.go` ```go package api import ( "net/http" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/yourorg/workorder/internal/api/handlers" mw "github.com/yourorg/workorder/internal/api/middleware" "github.com/yourorg/workorder/internal/config" "github.com/jmoiron/sqlx" ) func NewRouter(cfg *config.Config, db *sqlx.DB) http.Handler { r := chi.NewRouter() // Global middleware r.Use(middleware.Logger) r.Use(middleware.Recoverer) r.Use(middleware.RealIP) r.Use(mw.CORS) // Serve frontend static files r.Handle("/*", http.FileServer(http.Dir("./web"))) // Auth routes (no JWT required) r.Post("/api/auth/login", handlers.NewAuthHandler(cfg, db).Login) r.Post("/api/auth/refresh", handlers.NewAuthHandler(cfg, db).Refresh) // Protected API routes r.Group(func(r chi.Router) { r.Use(mw.JWT(cfg.JWTSecret)) wo := handlers.NewWorkOrderHandler(db) r.Get("/api/work-orders", wo.List) r.Post("/api/work-orders", wo.Create) r.Get("/api/work-orders/{id}", wo.Get) r.Put("/api/work-orders/{id}", wo.Update) r.Delete("/api/work-orders/{id}", wo.Delete) r.Put("/api/work-orders/{id}/status", wo.UpdateStatus) res := handlers.NewResourceHandler(db) r.Get("/api/work-orders/{id}/resources", res.List) r.Post("/api/work-orders/{id}/resources", res.Assign) r.Delete("/api/work-orders/{id}/resources/{rid}", res.Remove) step := handlers.NewStepHandler(db) r.Get("/api/work-orders/{id}/steps", step.List) r.Post("/api/work-orders/{id}/steps", step.Create) r.Put("/api/work-orders/{id}/steps/{sid}", step.Update) r.Post("/api/work-orders/{id}/steps/{sid}/complete", step.Complete) r.Delete("/api/work-orders/{id}/steps/{sid}", step.Delete) att := handlers.NewAttachmentHandler(cfg, db) r.Get("/api/work-orders/{id}/attachments", att.List) r.Post("/api/work-orders/{id}/attachments", att.Upload) r.Delete("/api/work-orders/{id}/attachments/{aid}", att.Delete) r.Handle("/uploads/*", http.StripPrefix("/uploads/", http.FileServer(http.Dir(cfg.UploadPath)))) acc := handlers.NewAccountingHandler(db) r.Get("/api/work-orders/{id}/accounting", acc.Get) r.Put("/api/work-orders/{id}/accounting", acc.Upsert) // Resource registries (master lists for assignment pickers) r.Get("/api/registry/people", handlers.RegistryPeople(db)) r.Get("/api/registry/vehicles", handlers.RegistryVehicles(db)) r.Get("/api/registry/equipment", handlers.RegistryEquipment(db)) r.Get("/api/registry/materials", handlers.RegistryMaterials(db)) }) return r } ``` ### `internal/model/workorder.go` ```go package model import "time" type WorkOrder struct { ID int `db:"id" json:"id"` WONumber string `db:"wo_number" json:"wo_number"` Title string `db:"title" json:"title"` Description string `db:"description" json:"description"` Instructions string `db:"instructions" json:"instructions"` Status string `db:"status" json:"status"` Priority string `db:"priority" json:"priority"` ScheduledStart *time.Time `db:"scheduled_start" json:"scheduled_start"` ScheduledEnd *time.Time `db:"scheduled_end" json:"scheduled_end"` ActualStart *time.Time `db:"actual_start" json:"actual_start"` ActualEnd *time.Time `db:"actual_end" json:"actual_end"` SiteName string `db:"site_name" json:"site_name"` Address string `db:"address" json:"address"` Lat *float64 `db:"lat" json:"lat"` Lng *float64 `db:"lng" json:"lng"` AccessNotes string `db:"access_notes" json:"access_notes"` ParentType *string `db:"parent_type" json:"parent_type"` ParentID *int `db:"parent_id" json:"parent_id"` CreatedBy int `db:"created_by" json:"created_by"` CreatedAt time.Time `db:"created_at" json:"created_at"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"` } // Enriched view returned by GET /work-orders/{id} type WorkOrderDetail struct { WorkOrder Resources []AssignedResource `json:"resources"` Steps []Step `json:"steps"` Attachments []Attachment `json:"attachments"` Accounting []AccountingCode `json:"accounting"` } type WorkOrderListItem struct { ID int `db:"id" json:"id"` WONumber string `db:"wo_number" json:"wo_number"` Title string `db:"title" json:"title"` Status string `db:"status" json:"status"` Priority string `db:"priority" json:"priority"` SiteName string `db:"site_name" json:"site_name"` ScheduledStart *time.Time `db:"scheduled_start" json:"scheduled_start"` StepCount int `db:"step_count" json:"step_count"` StepsDone int `db:"steps_done" json:"steps_done"` PhotoCount int `db:"photo_count" json:"photo_count"` } ``` ### `internal/repository/workorder.go` ```go package repository import ( "context" "github.com/jmoiron/sqlx" "github.com/yourorg/workorder/internal/model" ) type WorkOrderRepo struct{ db *sqlx.DB } func NewWorkOrderRepo(db *sqlx.DB) *WorkOrderRepo { return &WorkOrderRepo{db: db} } func (r *WorkOrderRepo) List(ctx context.Context, status, search string) ([]model.WorkOrderListItem, error) { query := ` SELECT wo.id, wo.wo_number, wo.title, wo.status, wo.priority, wo.site_name, wo.scheduled_start, COUNT(s.id) AS step_count, SUM(CAST(s.completed AS INT)) AS steps_done, COUNT(a.id) AS photo_count FROM work_orders wo LEFT JOIN wo_steps s ON s.wo_id = wo.id LEFT JOIN wo_attachments a ON a.wo_id = wo.id WHERE (@status = '' OR wo.status = @status) AND (@search = '' OR wo.title LIKE '%' + @search + '%' OR wo.wo_number LIKE '%' + @search + '%' OR wo.site_name LIKE '%' + @search + '%') GROUP BY wo.id, wo.wo_number, wo.title, wo.status, wo.priority, wo.site_name, wo.scheduled_start ORDER BY wo.scheduled_start DESC, wo.id DESC` rows := []model.WorkOrderListItem{} err := r.db.SelectContext(ctx, &rows, query, sqlx.Named("status", status), sqlx.Named("search", search), ) return rows, err } func (r *WorkOrderRepo) GetByID(ctx context.Context, id int) (*model.WorkOrder, error) { var wo model.WorkOrder err := r.db.GetContext(ctx, &wo, `SELECT * FROM work_orders WHERE id = @p1`, id) if err != nil { return nil, err } return &wo, nil } func (r *WorkOrderRepo) Create(ctx context.Context, wo *model.WorkOrder) (int, error) { var id int err := r.db.QueryRowContext(ctx, ` INSERT INTO work_orders (title, description, instructions, status, priority, scheduled_start, scheduled_end, site_name, address, lat, lng, access_notes, parent_type, parent_id, created_by, updated_at) OUTPUT INSERTED.id VALUES (@title, @description, @instructions, @status, @priority, @scheduled_start, @scheduled_end, @site_name, @address, @lat, @lng, @access_notes, @parent_type, @parent_id, @created_by, GETUTCDATE())`, sqlx.Named("title", wo.Title), // ... remaining named params ).Scan(&id) return id, err } func (r *WorkOrderRepo) UpdateStatus(ctx context.Context, id int, status string, userID int) error { _, err := r.db.ExecContext(ctx, ` UPDATE work_orders SET status = @p1, updated_at = GETUTCDATE(), actual_start = CASE WHEN @p1 = 'in_progress' AND actual_start IS NULL THEN GETUTCDATE() ELSE actual_start END, actual_end = CASE WHEN @p1 = 'closed' THEN GETUTCDATE() ELSE actual_end END, closed_at = CASE WHEN @p1 = 'closed' THEN GETUTCDATE() ELSE closed_at END, closed_by = CASE WHEN @p1 = 'closed' THEN @p2 ELSE closed_by END WHERE id = @p3`, status, userID, id) return err } ``` --- ## 5. REST API Design | Method | Path | Description | |--------|------|-------------| | POST | `/api/auth/login` | Login, returns JWT | | GET | `/api/work-orders?status=&search=&page=` | Paginated list | | POST | `/api/work-orders` | Create work order | | GET | `/api/work-orders/{id}` | Full detail (WO + resources + steps + photos + accounting) | | PUT | `/api/work-orders/{id}` | Update work order fields | | PUT | `/api/work-orders/{id}/status` | Status transition with audit | | GET | `/api/work-orders/{id}/resources` | List assigned resources | | POST | `/api/work-orders/{id}/resources` | Assign a resource | | DELETE | `/api/work-orders/{id}/resources/{rid}` | Remove assignment | | GET | `/api/work-orders/{id}/steps` | List checklist steps | | POST | `/api/work-orders/{id}/steps` | Add a step | | PUT | `/api/work-orders/{id}/steps/{sid}` | Edit step | | POST | `/api/work-orders/{id}/steps/{sid}/complete` | Check off step | | POST | `/api/work-orders/{id}/attachments` | Upload photo/file (multipart) | | GET | `/api/work-orders/{id}/attachments` | List attachments | | PUT | `/api/work-orders/{id}/accounting` | Upsert accounting codes | | GET | `/api/registry/people` | Lookup: people for assignment picker | | GET | `/api/registry/vehicles` | Lookup: vehicles | | GET | `/api/registry/equipment` | Lookup: equipment | | GET | `/api/registry/materials` | Lookup: materials | ### Standard Response Envelope ```json { "data": { ... }, "meta": { "page": 1, "per_page": 25, "total": 142 }, "error": null } ``` ### Status Transition Rules (enforced server-side) ``` draft → assigned → scheduled → in_progress → pending_review → closed ↘ (re-open) ↗ ``` --- ## 6. Frontend — Web Components ### Design Principles - **No build step** — plain ` ``` Zero build tooling. Zero npm for the frontend. Open in browser and go. ## Work Order Profiles ### Overview A **Work Order Profile** is a reusable template that defines the default structure and behavior for a category of work orders. When a profile is applied to a work order (at creation or post-creation), its steps, instructions, priority, and duration are loaded in — then customized as needed. > **Key idea:** Profile = preset + flexibility. The profile defines the standard; each WO can deviate from it. --- ### Profile Step Types Profile steps have a **type** that controls how they are displayed and completed in the field. Types are a fixed code-level constant — not a database table. Each type has optional **type_config** (stored as JSON) that gets set when the step is added to a profile. #### Built-in Step Types | Type | Value | Field behavior | |------|-------|----------------| | Work Step | `work_step` | Standard checkbox — mark done, optional note | | Photo | `photo` | Camera capture required; config defines phase and prompt | | Inspection | `inspection` | Pass / Fail / N/A buttons; config defines criteria text | | Note | `note` | Free-text entry field; config defines the prompt label | #### type_config JSON by Type ```json // work_step — no config needed {} // photo { "phase": "before", // "before" | "during" | "after" "caption_prompt": "Photo of existing equipment before any work begins" } // inspection { "criteria": "All cable slack coiled and secured per spec" } // note { "prompt": "Record any site hazards or access issues observed" } ``` #### Example profile: Fiber Splice Job ``` 1. [photo] Before — site overview { phase: "before", caption_prompt: "Wide shot of work area" } 2. [work_step] Power down splice enclosure 3. [work_step] Prep and strip fiber ends 4. [inspection] Check splice loss < 0.1 dB { criteria: "OTDR reading below 0.1 dB loss" } 5. [photo] Splice tray loaded and sealed { phase: "during", caption_prompt: "Tray with labeled splices" } 6. [work_step] Seal enclosure and torque to spec 7. [note] Record any deviations or notes { prompt: "Deviations from standard procedure?" } 8. [photo] After — completed enclosure { phase: "after", caption_prompt: "Sealed enclosure, final install" } ``` --- ### Database ```sql -- Profiles table IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = 'wo_profiles') BEGIN CREATE TABLE wo_profiles ( id INT IDENTITY PRIMARY KEY, name NVARCHAR(200) NOT NULL, description NVARCHAR(MAX), category NVARCHAR(100), default_priority NVARCHAR(10) NOT NULL DEFAULT 'normal', default_duration_hours INT, default_instructions NVARCHAR(MAX), active BIT NOT NULL DEFAULT 1, created_at DATETIME2 NOT NULL DEFAULT GETUTCDATE(), updated_at DATETIME2 NOT NULL DEFAULT GETUTCDATE() ); END -- Profile steps table (with step_type and type_config) IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = 'wo_profile_steps') BEGIN CREATE TABLE wo_profile_steps ( id INT IDENTITY PRIMARY KEY, profile_id INT NOT NULL REFERENCES wo_profiles(id) ON DELETE CASCADE, step_order INT NOT NULL, title NVARCHAR(200) NOT NULL, description NVARCHAR(MAX), required BIT NOT NULL DEFAULT 1, step_type NVARCHAR(30) NOT NULL DEFAULT 'work_step', -- 'work_step' | 'photo' | 'inspection' | 'note' type_config NVARCHAR(MAX) -- JSON; shape depends on step_type ); CREATE INDEX ix_profile_steps ON wo_profile_steps (profile_id, step_order); END -- WO steps also carry step_type and type_config (copied from profile on apply) -- Migration: add columns if they do not exist IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID('wo_steps') AND name = 'step_type') ALTER TABLE wo_steps ADD step_type NVARCHAR(30) NOT NULL DEFAULT 'work_step'; IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID('wo_steps') AND name = 'type_config') ALTER TABLE wo_steps ADD type_config NVARCHAR(MAX) NULL; ``` ### API Endpoints | Method | Path | Description | |--------|------|-------------| | GET | `/api/profiles` | List active profiles (search, category filter) | | POST | `/api/profiles` | Create profile | | GET | `/api/profiles/{id}` | Get profile with its steps | | PUT | `/api/profiles/{id}` | Update profile fields | | DELETE | `/api/profiles/{id}` | Soft delete (set active=0) | | GET | `/api/profiles/{id}/steps` | List profile steps | | POST | `/api/profiles/{id}/steps` | Add step to profile | | PUT | `/api/profiles/{id}/steps/{sid}` | Update profile step | | DELETE | `/api/profiles/{id}/steps/{sid}` | Remove step from profile | | POST | `/api/work-orders/{id}/apply-profile/{profileId}` | Apply profile to WO | #### apply-profile behavior Request body: `{ "mode": "append" | "replace" }` - **append** — inserts profile steps after existing steps (step_order continues from current max) - **replace** — deletes all existing steps, then inserts profile steps starting at order 1 - Both modes copy `step_type` and `type_config` from the profile step into `wo_steps` - Both modes update `instructions` if currently blank and `priority` if WO is still `draft` ### Go Models ```go type Profile struct { ID int `db:"id" json:"id"` Name string `db:"name" json:"name"` Description string `db:"description" json:"description"` Category string `db:"category" json:"category"` DefaultPriority string `db:"default_priority" json:"default_priority"` DefaultDurationHours *int `db:"default_duration_hours" json:"default_duration_hours"` DefaultInstructions string `db:"default_instructions" json:"default_instructions"` Active bool `db:"active" json:"active"` StepCount int `db:"step_count" json:"step_count"` CreatedAt time.Time `db:"created_at" json:"created_at"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"` Steps []ProfileStep `db:"-" json:"steps,omitempty"` } type ProfileStep struct { ID int `db:"id" json:"id"` ProfileID int `db:"profile_id" json:"profile_id"` StepOrder int `db:"step_order" json:"step_order"` Title string `db:"title" json:"title"` Description string `db:"description" json:"description"` Required bool `db:"required" json:"required"` StepType string `db:"step_type" json:"step_type"` // "work_step"|"photo"|"inspection"|"note" TypeConfig string `db:"type_config" json:"type_config"` // JSON string } // Step also gains these two fields (in model/models.go) // StepType string `db:"step_type" json:"step_type"` // TypeConfig string `db:"type_config" json:"type_config"` ``` ### Frontend Components **`web/components/registry/profile-list.mjs`** - List page at `/registry/profiles` - Cards: name, category badge, step count, active status - Search + category filter, edit/deactivate actions, "New Profile" button **`web/components/registry/profile-form.mjs`** - Modal dialog for create/edit - Fields: name (required), description, category, default priority, default duration hours, default instructions - Inline step editor: ordered step list with add / edit / reorder / delete - When adding a step: type picker first (work_step / photo / inspection / note icon buttons) - After type selected: type-specific config fields appear inline: - `photo` → phase select (before/during/after) + caption prompt input - `inspection` → criteria text input - `note` → prompt label input - `work_step` → no extra fields - Active toggle in edit mode **`web/components/work-orders/wo-checklist.mjs`** - Each step renders differently based on `step_type`: - `work_step` — large checkbox + title + optional note textarea - `photo` — camera icon button labeled with `type_config.caption_prompt`; tapping opens photo panel pre-set to the configured phase; completed when a photo is attached to this step - `inspection` — Pass / Fail / N/A button group; completed when any selection is made; result stored in completion notes - `note` — text input labeled with `type_config.prompt`; completed when non-empty text is saved - Progress bar counts all step types equally **WO Form integration (`wo-form.mjs`)** - "Load Profile" ghost button at top of form (shown only on new WOs or draft status) - Searchable profile picker dialog — on confirm pre-fills priority and instructions (if blank) - Steps applied server-side via apply-profile after the WO saves; checklist tab then refreshes **WO Detail integration (`wo-detail.mjs`)** - "Apply Profile" button in header actions alongside Edit / Change Status - Two-step dialog: (1) pick profile, (2) if WO has existing steps — prompt append vs replace - Calls `POST /api/work-orders/{id}/apply-profile/{profileId}` then refreshes Checklist tab ### Sidebar Nav Under Resources section: ``` Profiles /registry/profiles (layout-template icon) ``` ### apply-profile Handler (pseudocode) ```go func (h *ProfileHandler) Apply(w http.ResponseWriter, r *http.Request) { woID, profileID := intParam(r, "id"), intParam(r, "profileId") var body struct { Mode string `json:"mode"` } json.NewDecoder(r.Body).Decode(&body) profile, steps := // load profile + steps if body.Mode == "replace" { db.Exec(`DELETE FROM wo_steps WHERE wo_id = @p1`, woID) } var maxOrder int db.Get(&maxOrder, `SELECT ISNULL(MAX(step_order), 0) FROM wo_steps WHERE wo_id = @p1`, woID) for _, s := range steps { db.Exec(`INSERT INTO wo_steps (wo_id, step_order, title, description, required, step_type, type_config) VALUES (@p1, @p2, @p3, @p4, @p5, @p6, @p7)`, woID, maxOrder+s.StepOrder, s.Title, s.Description, s.Required, s.StepType, s.TypeConfig) } db.Exec(` UPDATE work_orders SET instructions = CASE WHEN (instructions IS NULL OR instructions = '') THEN @p1 ELSE instructions END, priority = CASE WHEN status = 'draft' THEN @p2 ELSE priority END, updated_at = GETUTCDATE() WHERE id = @p3`, profile.DefaultInstructions, profile.DefaultPriority, woID) } ```