47 KiB
47 KiB
Work Order System — Technical Build Plan
Stack: Go (backend API) · Pure JavaScript + Web Components (frontend) · SQL Server (MSSQL) · Docker
Table of Contents
- Architecture Overview
- Project Structure
- Database Schema
- Go Backend
- REST API Design
- Frontend — Web Components
- File & Photo Handling
- Maps & Location
- Authentication & Authorization
- Docker Deployment
- Phased Build Plan
- 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.mjsis 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/lngdecimals 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 1–4)
Goal: Working CRUD with project linking and status flow.
- Repo setup: Go module, chi, sqlx, godotenv
- DB migration:
work_orderstable + 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 5–8)
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_attachmentstable - 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 9–12)
Goal: Connects to the rest of the system.
- DB:
wo_accountingtable <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/smtpor 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.