1295 lines
47 KiB
Markdown
1295 lines
47 KiB
Markdown
# Work Order System — Technical Build Plan
|
||
**Stack:** Go (backend API) · Pure JavaScript + Web Components (frontend) · SQL Server (MSSQL) · Docker
|
||
|
||
---
|
||
|
||
## Table of Contents
|
||
|
||
1. [Architecture Overview](#1-architecture-overview)
|
||
2. [Project Structure](#2-project-structure)
|
||
3. [Database Schema](#3-database-schema)
|
||
4. [Go Backend](#4-go-backend)
|
||
5. [REST API Design](#5-rest-api-design)
|
||
6. [Frontend — Web Components](#6-frontend--web-components)
|
||
7. [File & Photo Handling](#7-file--photo-handling)
|
||
8. [Maps & Location](#8-maps--location)
|
||
9. [Authentication & Authorization](#9-authentication--authorization)
|
||
10. [Docker Deployment](#10-docker-deployment)
|
||
11. [Phased Build Plan](#11-phased-build-plan)
|
||
12. [Development Conventions](#12-development-conventions)
|
||
|
||
---
|
||
|
||
## 1. Architecture Overview
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────┐
|
||
│ Browser Client │
|
||
│ ┌────────────────────────────────────────────────────┐ │
|
||
│ │ Pure JS + Web Components SPA │ │
|
||
│ │ <wo-list> <wo-form> <wo-map> <wo-checklist> │ │
|
||
│ └───────────────────┬────────────────────────────────┘ │
|
||
└──────────────────────│──────────────────────────────────┘
|
||
│ REST/JSON over HTTPS
|
||
┌──────────────────────▼──────────────────────────────────┐
|
||
│ Go API Server │
|
||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||
│ │ Router │ │ Handlers │ │ Service │ │
|
||
│ │ (chi) │ │ │ │ Layer │ │
|
||
│ └──────────┘ └──────────┘ └──────────┘ │
|
||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||
│ │ Auth │ │ Upload │ │ Spatial │ │
|
||
│ │ (JWT) │ │ Handler │ │ Utils │ │
|
||
│ └──────────┘ └──────────┘ └──────────┘ │
|
||
└──────────────────────┬──────────────────────────────────┘
|
||
│ sqlx / database/sql
|
||
┌──────────────────────▼──────────────────────────────────┐
|
||
│ SQL Server (MSSQL) │
|
||
│ work_orders · wo_resources · wo_steps · wo_attachments │
|
||
│ wo_accounting · wo_parents · users · resource_registry │
|
||
└─────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
**Key decisions:**
|
||
- **No framework on the frontend** — Custom Elements v1, Shadow DOM, ES Modules. Works in all modern browsers with zero build step.
|
||
- **chi router** for Go — lightweight, idiomatic, stdlib-compatible middleware.
|
||
- **sqlx** for DB access — thin wrapper over `database/sql`, named params, struct scanning.
|
||
- **SQL Server** — matches your existing infrastructure; geography type for spatial data.
|
||
- **Multipart uploads** stored to local volume (or swap for Azure Blob / S3 later).
|
||
- **JWT** stateless auth — fits well with a SPA + API split.
|
||
|
||
---
|
||
|
||
## 2. Project Structure
|
||
|
||
```
|
||
workorder/
|
||
├── cmd/
|
||
│ └── server/
|
||
│ └── main.go # Entry point, wires everything together
|
||
├── internal/
|
||
│ ├── api/
|
||
│ │ ├── router.go # chi router setup, middleware chain
|
||
│ │ ├── middleware.go # JWT, CORS, logging, recovery
|
||
│ │ └── handlers/
|
||
│ │ ├── workorder.go # WO CRUD handlers
|
||
│ │ ├── resource.go # People / vehicle / equipment assignment
|
||
│ │ ├── step.go # Checklist steps
|
||
│ │ ├── attachment.go # Photo & file upload
|
||
│ │ ├── accounting.go # Accounting code endpoints
|
||
│ │ ├── parent.go # Parent record linking (polymorphic)
|
||
│ │ └── auth.go # Login / token refresh
|
||
│ ├── service/
|
||
│ │ ├── workorder.go # Business logic layer
|
||
│ │ ├── resource.go
|
||
│ │ ├── notification.go # Email/push when WO assigned
|
||
│ │ └── spatial.go # Geocoding, distance calc
|
||
│ ├── repository/
|
||
│ │ ├── workorder.go # SQL queries — work orders
|
||
│ │ ├── resource.go # SQL queries — resources
|
||
│ │ ├── step.go
|
||
│ │ ├── attachment.go
|
||
│ │ └── db.go # sqlx connection pool setup
|
||
│ ├── model/
|
||
│ │ ├── workorder.go # Structs: WorkOrder, WorkOrderSummary
|
||
│ │ ├── resource.go # Person, Vehicle, Equipment, Material
|
||
│ │ ├── step.go # Step, StepCompletion
|
||
│ │ ├── attachment.go # Attachment
|
||
│ │ ├── accounting.go # AccountingCode
|
||
│ │ └── auth.go # User, Claims
|
||
│ └── config/
|
||
│ └── config.go # Env-based config (DB DSN, JWT secret, upload path)
|
||
├── web/ # Frontend — served as static files by Go
|
||
│ ├── index.html # Shell — loads the app component
|
||
│ ├── app.mjs # <app-root> component, client-side router
|
||
│ ├── components/
|
||
│ │ ├── wo-list.mjs # Work order list / search
|
||
│ │ ├── wo-form.mjs # Create / edit work order
|
||
│ │ ├── wo-detail.mjs # Read-only detail view
|
||
│ │ ├── wo-checklist.mjs # Step checklist with check-off
|
||
│ │ ├── wo-resource-panel.mjs # Assign people, vehicles, equipment
|
||
│ │ ├── wo-photo-panel.mjs # Photo capture and gallery
|
||
│ │ ├── wo-map.mjs # Embedded map + directions
|
||
│ │ ├── wo-accounting.mjs # Accounting code fields
|
||
│ │ ├── wo-status-badge.mjs # Status pill (reusable)
|
||
│ │ └── wo-timeline.mjs # Activity / audit log
|
||
│ ├── lib/
|
||
│ │ ├── api.mjs # Fetch wrapper, auth header injection
|
||
│ │ ├── router.mjs # Tiny hash/history router
|
||
│ │ ├── store.mjs # Lightweight reactive state (no Redux)
|
||
│ │ └── utils.mjs # Formatters, validators
|
||
│ └── styles/
|
||
│ ├── global.css # CSS custom properties (design tokens)
|
||
│ └── reset.css
|
||
├── uploads/ # Stored attachments (bind-mounted in Docker)
|
||
├── migrations/
|
||
│ ├── 001_initial_schema.sql
|
||
│ ├── 002_resource_registry.sql
|
||
│ └── 003_spatial_fields.sql
|
||
├── docker-compose.yml
|
||
├── Dockerfile
|
||
└── .env.example
|
||
```
|
||
|
||
---
|
||
|
||
## 3. Database Schema
|
||
|
||
### Core Tables
|
||
|
||
```sql
|
||
-- ── Work Orders ───────────────────────────────────────────────────────────────
|
||
CREATE TABLE work_orders (
|
||
id INT IDENTITY PRIMARY KEY,
|
||
wo_number AS ('WO-' + RIGHT('000000' + CAST(id AS VARCHAR), 6)) PERSISTED,
|
||
title NVARCHAR(200) NOT NULL,
|
||
description NVARCHAR(MAX),
|
||
instructions NVARCHAR(MAX), -- Rich text / markdown
|
||
status NVARCHAR(30) NOT NULL DEFAULT 'draft',
|
||
-- draft | assigned | scheduled | in_progress | pending_review | closed
|
||
priority NVARCHAR(10) NOT NULL DEFAULT 'normal',
|
||
-- low | normal | high | urgent
|
||
scheduled_start DATETIME2,
|
||
scheduled_end DATETIME2,
|
||
actual_start DATETIME2,
|
||
actual_end DATETIME2,
|
||
-- Location
|
||
site_name NVARCHAR(200),
|
||
address NVARCHAR(400),
|
||
lat DECIMAL(10,7),
|
||
lng DECIMAL(10,7),
|
||
access_notes NVARCHAR(MAX), -- Gate codes, access roads
|
||
-- Polymorphic parent
|
||
parent_type NVARCHAR(50), -- 'project' | 'ticket' | 'service_order' | NULL
|
||
parent_id INT,
|
||
-- Audit
|
||
created_by INT NOT NULL,
|
||
created_at DATETIME2 NOT NULL DEFAULT GETUTCDATE(),
|
||
updated_at DATETIME2 NOT NULL DEFAULT GETUTCDATE(),
|
||
closed_at DATETIME2,
|
||
closed_by INT,
|
||
CONSTRAINT chk_wo_status CHECK (status IN ('draft','assigned','scheduled','in_progress','pending_review','closed')),
|
||
CONSTRAINT chk_wo_priority CHECK (priority IN ('low','normal','high','urgent'))
|
||
);
|
||
|
||
-- ── Resource Registry (master lists) ─────────────────────────────────────────
|
||
CREATE TABLE resource_people (
|
||
id INT IDENTITY PRIMARY KEY,
|
||
name NVARCHAR(100) NOT NULL,
|
||
role NVARCHAR(100),
|
||
email NVARCHAR(200),
|
||
phone NVARCHAR(30),
|
||
active BIT NOT NULL DEFAULT 1
|
||
);
|
||
|
||
CREATE TABLE resource_vehicles (
|
||
id INT IDENTITY PRIMARY KEY,
|
||
unit_number NVARCHAR(50) NOT NULL,
|
||
description NVARCHAR(200),
|
||
vehicle_type NVARCHAR(100),
|
||
active BIT NOT NULL DEFAULT 1
|
||
);
|
||
|
||
CREATE TABLE resource_equipment (
|
||
id INT IDENTITY PRIMARY KEY,
|
||
name NVARCHAR(200) NOT NULL,
|
||
asset_tag NVARCHAR(100),
|
||
category NVARCHAR(100),
|
||
active BIT NOT NULL DEFAULT 1
|
||
);
|
||
|
||
CREATE TABLE resource_materials (
|
||
id INT IDENTITY PRIMARY KEY,
|
||
name NVARCHAR(200) NOT NULL,
|
||
unit NVARCHAR(30), -- 'ft', 'each', 'box'
|
||
part_number NVARCHAR(100),
|
||
active BIT NOT NULL DEFAULT 1
|
||
);
|
||
|
||
-- ── Work Order Resource Assignments ───────────────────────────────────────────
|
||
CREATE TABLE wo_resources (
|
||
id INT IDENTITY PRIMARY KEY,
|
||
wo_id INT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE,
|
||
resource_type NVARCHAR(20) NOT NULL, -- 'person' | 'vehicle' | 'equipment' | 'material'
|
||
resource_id INT NOT NULL,
|
||
quantity DECIMAL(10,2), -- For materials
|
||
notes NVARCHAR(500),
|
||
assigned_at DATETIME2 NOT NULL DEFAULT GETUTCDATE(),
|
||
CONSTRAINT chk_resource_type CHECK (resource_type IN ('person','vehicle','equipment','material'))
|
||
);
|
||
|
||
-- ── Checklist Steps ───────────────────────────────────────────────────────────
|
||
CREATE TABLE wo_steps (
|
||
id INT IDENTITY PRIMARY KEY,
|
||
wo_id INT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE,
|
||
step_order INT NOT NULL,
|
||
title NVARCHAR(200) NOT NULL,
|
||
description NVARCHAR(MAX),
|
||
required BIT NOT NULL DEFAULT 1,
|
||
completed BIT NOT NULL DEFAULT 0,
|
||
completed_by INT,
|
||
completed_at DATETIME2,
|
||
notes NVARCHAR(MAX) -- Field notes added at completion
|
||
);
|
||
|
||
-- ── Attachments (photos, docs) ────────────────────────────────────────────────
|
||
CREATE TABLE wo_attachments (
|
||
id INT IDENTITY PRIMARY KEY,
|
||
wo_id INT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE,
|
||
step_id INT REFERENCES wo_steps(id), -- Optional: attach to a specific step
|
||
file_name NVARCHAR(500) NOT NULL,
|
||
file_path NVARCHAR(1000) NOT NULL, -- Relative path under /uploads
|
||
file_type NVARCHAR(100), -- MIME type
|
||
file_size BIGINT,
|
||
caption NVARCHAR(500),
|
||
phase NVARCHAR(20), -- 'before' | 'during' | 'after'
|
||
lat DECIMAL(10,7), -- Geo-tag from EXIF or device
|
||
lng DECIMAL(10,7),
|
||
uploaded_by INT NOT NULL,
|
||
uploaded_at DATETIME2 NOT NULL DEFAULT GETUTCDATE()
|
||
);
|
||
|
||
-- ── Accounting Codes ──────────────────────────────────────────────────────────
|
||
CREATE TABLE wo_accounting (
|
||
id INT IDENTITY PRIMARY KEY,
|
||
wo_id INT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE,
|
||
code_type NVARCHAR(50) NOT NULL, -- 'gl_account' | 'cost_center' | 'wbs' | 'billing_ref'
|
||
code_value NVARCHAR(200) NOT NULL,
|
||
description NVARCHAR(500),
|
||
CONSTRAINT uq_wo_accounting UNIQUE (wo_id, code_type)
|
||
);
|
||
|
||
-- ── Audit Log ─────────────────────────────────────────────────────────────────
|
||
CREATE TABLE wo_audit_log (
|
||
id INT IDENTITY PRIMARY KEY,
|
||
wo_id INT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE,
|
||
action NVARCHAR(100) NOT NULL,
|
||
old_value NVARCHAR(MAX),
|
||
new_value NVARCHAR(MAX),
|
||
performed_by INT NOT NULL,
|
||
performed_at DATETIME2 NOT NULL DEFAULT GETUTCDATE()
|
||
);
|
||
|
||
-- ── Indexes ───────────────────────────────────────────────────────────────────
|
||
CREATE INDEX ix_wo_status ON work_orders (status);
|
||
CREATE INDEX ix_wo_parent ON work_orders (parent_type, parent_id);
|
||
CREATE INDEX ix_wo_scheduled ON work_orders (scheduled_start);
|
||
CREATE INDEX ix_wo_resources ON wo_resources (wo_id, resource_type);
|
||
CREATE INDEX ix_wo_attachments ON wo_attachments (wo_id);
|
||
```
|
||
|
||
---
|
||
|
||
## 4. Go Backend
|
||
|
||
### `cmd/server/main.go`
|
||
|
||
```go
|
||
package main
|
||
|
||
import (
|
||
"log"
|
||
"net/http"
|
||
|
||
"github.com/yourorg/workorder/internal/api"
|
||
"github.com/yourorg/workorder/internal/config"
|
||
"github.com/yourorg/workorder/internal/repository"
|
||
)
|
||
|
||
func main() {
|
||
cfg := config.Load() // reads .env / environment variables
|
||
|
||
db, err := repository.Connect(cfg.DBDSN)
|
||
if err != nil {
|
||
log.Fatalf("db connect: %v", err)
|
||
}
|
||
defer db.Close()
|
||
|
||
r := api.NewRouter(cfg, db)
|
||
|
||
log.Printf("listening on %s", cfg.Addr)
|
||
if err := http.ListenAndServe(cfg.Addr, r); err != nil {
|
||
log.Fatal(err)
|
||
}
|
||
}
|
||
```
|
||
|
||
### `internal/config/config.go`
|
||
|
||
```go
|
||
package config
|
||
|
||
import "os"
|
||
|
||
type Config struct {
|
||
Addr string
|
||
DBDSN string
|
||
JWTSecret string
|
||
UploadPath string
|
||
BaseURL string
|
||
}
|
||
|
||
func Load() *Config {
|
||
return &Config{
|
||
Addr: env("ADDR", ":8080"),
|
||
DBDSN: env("DB_DSN", ""),
|
||
JWTSecret: env("JWT_SECRET", "change-me"),
|
||
UploadPath: env("UPLOAD_PATH", "./uploads"),
|
||
BaseURL: env("BASE_URL", "http://localhost:8080"),
|
||
}
|
||
}
|
||
|
||
func env(key, fallback string) string {
|
||
if v := os.Getenv(key); v != "" {
|
||
return v
|
||
}
|
||
return fallback
|
||
}
|
||
```
|
||
|
||
### `internal/api/router.go`
|
||
|
||
```go
|
||
package api
|
||
|
||
import (
|
||
"net/http"
|
||
|
||
"github.com/go-chi/chi/v5"
|
||
"github.com/go-chi/chi/v5/middleware"
|
||
"github.com/yourorg/workorder/internal/api/handlers"
|
||
mw "github.com/yourorg/workorder/internal/api/middleware"
|
||
"github.com/yourorg/workorder/internal/config"
|
||
"github.com/jmoiron/sqlx"
|
||
)
|
||
|
||
func NewRouter(cfg *config.Config, db *sqlx.DB) http.Handler {
|
||
r := chi.NewRouter()
|
||
|
||
// Global middleware
|
||
r.Use(middleware.Logger)
|
||
r.Use(middleware.Recoverer)
|
||
r.Use(middleware.RealIP)
|
||
r.Use(mw.CORS)
|
||
|
||
// Serve frontend static files
|
||
r.Handle("/*", http.FileServer(http.Dir("./web")))
|
||
|
||
// Auth routes (no JWT required)
|
||
r.Post("/api/auth/login", handlers.NewAuthHandler(cfg, db).Login)
|
||
r.Post("/api/auth/refresh", handlers.NewAuthHandler(cfg, db).Refresh)
|
||
|
||
// Protected API routes
|
||
r.Group(func(r chi.Router) {
|
||
r.Use(mw.JWT(cfg.JWTSecret))
|
||
|
||
wo := handlers.NewWorkOrderHandler(db)
|
||
r.Get("/api/work-orders", wo.List)
|
||
r.Post("/api/work-orders", wo.Create)
|
||
r.Get("/api/work-orders/{id}", wo.Get)
|
||
r.Put("/api/work-orders/{id}", wo.Update)
|
||
r.Delete("/api/work-orders/{id}", wo.Delete)
|
||
r.Put("/api/work-orders/{id}/status", wo.UpdateStatus)
|
||
|
||
res := handlers.NewResourceHandler(db)
|
||
r.Get("/api/work-orders/{id}/resources", res.List)
|
||
r.Post("/api/work-orders/{id}/resources", res.Assign)
|
||
r.Delete("/api/work-orders/{id}/resources/{rid}", res.Remove)
|
||
|
||
step := handlers.NewStepHandler(db)
|
||
r.Get("/api/work-orders/{id}/steps", step.List)
|
||
r.Post("/api/work-orders/{id}/steps", step.Create)
|
||
r.Put("/api/work-orders/{id}/steps/{sid}", step.Update)
|
||
r.Post("/api/work-orders/{id}/steps/{sid}/complete", step.Complete)
|
||
r.Delete("/api/work-orders/{id}/steps/{sid}", step.Delete)
|
||
|
||
att := handlers.NewAttachmentHandler(cfg, db)
|
||
r.Get("/api/work-orders/{id}/attachments", att.List)
|
||
r.Post("/api/work-orders/{id}/attachments", att.Upload)
|
||
r.Delete("/api/work-orders/{id}/attachments/{aid}", att.Delete)
|
||
r.Handle("/uploads/*", http.StripPrefix("/uploads/", http.FileServer(http.Dir(cfg.UploadPath))))
|
||
|
||
acc := handlers.NewAccountingHandler(db)
|
||
r.Get("/api/work-orders/{id}/accounting", acc.Get)
|
||
r.Put("/api/work-orders/{id}/accounting", acc.Upsert)
|
||
|
||
// Resource registries (master lists for assignment pickers)
|
||
r.Get("/api/registry/people", handlers.RegistryPeople(db))
|
||
r.Get("/api/registry/vehicles", handlers.RegistryVehicles(db))
|
||
r.Get("/api/registry/equipment", handlers.RegistryEquipment(db))
|
||
r.Get("/api/registry/materials", handlers.RegistryMaterials(db))
|
||
})
|
||
|
||
return r
|
||
}
|
||
```
|
||
|
||
### `internal/model/workorder.go`
|
||
|
||
```go
|
||
package model
|
||
|
||
import "time"
|
||
|
||
type WorkOrder struct {
|
||
ID int `db:"id" json:"id"`
|
||
WONumber string `db:"wo_number" json:"wo_number"`
|
||
Title string `db:"title" json:"title"`
|
||
Description string `db:"description" json:"description"`
|
||
Instructions string `db:"instructions" json:"instructions"`
|
||
Status string `db:"status" json:"status"`
|
||
Priority string `db:"priority" json:"priority"`
|
||
ScheduledStart *time.Time `db:"scheduled_start" json:"scheduled_start"`
|
||
ScheduledEnd *time.Time `db:"scheduled_end" json:"scheduled_end"`
|
||
ActualStart *time.Time `db:"actual_start" json:"actual_start"`
|
||
ActualEnd *time.Time `db:"actual_end" json:"actual_end"`
|
||
SiteName string `db:"site_name" json:"site_name"`
|
||
Address string `db:"address" json:"address"`
|
||
Lat *float64 `db:"lat" json:"lat"`
|
||
Lng *float64 `db:"lng" json:"lng"`
|
||
AccessNotes string `db:"access_notes" json:"access_notes"`
|
||
ParentType *string `db:"parent_type" json:"parent_type"`
|
||
ParentID *int `db:"parent_id" json:"parent_id"`
|
||
CreatedBy int `db:"created_by" json:"created_by"`
|
||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||
}
|
||
|
||
// Enriched view returned by GET /work-orders/{id}
|
||
type WorkOrderDetail struct {
|
||
WorkOrder
|
||
Resources []AssignedResource `json:"resources"`
|
||
Steps []Step `json:"steps"`
|
||
Attachments []Attachment `json:"attachments"`
|
||
Accounting []AccountingCode `json:"accounting"`
|
||
}
|
||
|
||
type WorkOrderListItem struct {
|
||
ID int `db:"id" json:"id"`
|
||
WONumber string `db:"wo_number" json:"wo_number"`
|
||
Title string `db:"title" json:"title"`
|
||
Status string `db:"status" json:"status"`
|
||
Priority string `db:"priority" json:"priority"`
|
||
SiteName string `db:"site_name" json:"site_name"`
|
||
ScheduledStart *time.Time `db:"scheduled_start" json:"scheduled_start"`
|
||
StepCount int `db:"step_count" json:"step_count"`
|
||
StepsDone int `db:"steps_done" json:"steps_done"`
|
||
PhotoCount int `db:"photo_count" json:"photo_count"`
|
||
}
|
||
```
|
||
|
||
### `internal/repository/workorder.go`
|
||
|
||
```go
|
||
package repository
|
||
|
||
import (
|
||
"context"
|
||
"github.com/jmoiron/sqlx"
|
||
"github.com/yourorg/workorder/internal/model"
|
||
)
|
||
|
||
type WorkOrderRepo struct{ db *sqlx.DB }
|
||
|
||
func NewWorkOrderRepo(db *sqlx.DB) *WorkOrderRepo {
|
||
return &WorkOrderRepo{db: db}
|
||
}
|
||
|
||
func (r *WorkOrderRepo) List(ctx context.Context, status, search string) ([]model.WorkOrderListItem, error) {
|
||
query := `
|
||
SELECT
|
||
wo.id, wo.wo_number, wo.title, wo.status, wo.priority,
|
||
wo.site_name, wo.scheduled_start,
|
||
COUNT(s.id) AS step_count,
|
||
SUM(CAST(s.completed AS INT)) AS steps_done,
|
||
COUNT(a.id) AS photo_count
|
||
FROM work_orders wo
|
||
LEFT JOIN wo_steps s ON s.wo_id = wo.id
|
||
LEFT JOIN wo_attachments a ON a.wo_id = wo.id
|
||
WHERE (@status = '' OR wo.status = @status)
|
||
AND (@search = '' OR wo.title LIKE '%' + @search + '%'
|
||
OR wo.wo_number LIKE '%' + @search + '%'
|
||
OR wo.site_name LIKE '%' + @search + '%')
|
||
GROUP BY wo.id, wo.wo_number, wo.title, wo.status,
|
||
wo.priority, wo.site_name, wo.scheduled_start
|
||
ORDER BY wo.scheduled_start DESC, wo.id DESC`
|
||
|
||
rows := []model.WorkOrderListItem{}
|
||
err := r.db.SelectContext(ctx, &rows, query,
|
||
sqlx.Named("status", status),
|
||
sqlx.Named("search", search),
|
||
)
|
||
return rows, err
|
||
}
|
||
|
||
func (r *WorkOrderRepo) GetByID(ctx context.Context, id int) (*model.WorkOrder, error) {
|
||
var wo model.WorkOrder
|
||
err := r.db.GetContext(ctx, &wo,
|
||
`SELECT * FROM work_orders WHERE id = @p1`, id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return &wo, nil
|
||
}
|
||
|
||
func (r *WorkOrderRepo) Create(ctx context.Context, wo *model.WorkOrder) (int, error) {
|
||
var id int
|
||
err := r.db.QueryRowContext(ctx, `
|
||
INSERT INTO work_orders
|
||
(title, description, instructions, status, priority,
|
||
scheduled_start, scheduled_end, site_name, address, lat, lng,
|
||
access_notes, parent_type, parent_id, created_by, updated_at)
|
||
OUTPUT INSERTED.id
|
||
VALUES
|
||
(@title, @description, @instructions, @status, @priority,
|
||
@scheduled_start, @scheduled_end, @site_name, @address, @lat, @lng,
|
||
@access_notes, @parent_type, @parent_id, @created_by, GETUTCDATE())`,
|
||
sqlx.Named("title", wo.Title),
|
||
// ... remaining named params
|
||
).Scan(&id)
|
||
return id, err
|
||
}
|
||
|
||
func (r *WorkOrderRepo) UpdateStatus(ctx context.Context, id int, status string, userID int) error {
|
||
_, err := r.db.ExecContext(ctx, `
|
||
UPDATE work_orders
|
||
SET status = @p1, updated_at = GETUTCDATE(),
|
||
actual_start = CASE WHEN @p1 = 'in_progress' AND actual_start IS NULL
|
||
THEN GETUTCDATE() ELSE actual_start END,
|
||
actual_end = CASE WHEN @p1 = 'closed' THEN GETUTCDATE() ELSE actual_end END,
|
||
closed_at = CASE WHEN @p1 = 'closed' THEN GETUTCDATE() ELSE closed_at END,
|
||
closed_by = CASE WHEN @p1 = 'closed' THEN @p2 ELSE closed_by END
|
||
WHERE id = @p3`, status, userID, id)
|
||
return err
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 5. REST API Design
|
||
|
||
| Method | Path | Description |
|
||
|--------|------|-------------|
|
||
| POST | `/api/auth/login` | Login, returns JWT |
|
||
| GET | `/api/work-orders?status=&search=&page=` | Paginated list |
|
||
| POST | `/api/work-orders` | Create work order |
|
||
| GET | `/api/work-orders/{id}` | Full detail (WO + resources + steps + photos + accounting) |
|
||
| PUT | `/api/work-orders/{id}` | Update work order fields |
|
||
| PUT | `/api/work-orders/{id}/status` | Status transition with audit |
|
||
| GET | `/api/work-orders/{id}/resources` | List assigned resources |
|
||
| POST | `/api/work-orders/{id}/resources` | Assign a resource |
|
||
| DELETE | `/api/work-orders/{id}/resources/{rid}` | Remove assignment |
|
||
| GET | `/api/work-orders/{id}/steps` | List checklist steps |
|
||
| POST | `/api/work-orders/{id}/steps` | Add a step |
|
||
| PUT | `/api/work-orders/{id}/steps/{sid}` | Edit step |
|
||
| POST | `/api/work-orders/{id}/steps/{sid}/complete` | Check off step |
|
||
| POST | `/api/work-orders/{id}/attachments` | Upload photo/file (multipart) |
|
||
| GET | `/api/work-orders/{id}/attachments` | List attachments |
|
||
| PUT | `/api/work-orders/{id}/accounting` | Upsert accounting codes |
|
||
| GET | `/api/registry/people` | Lookup: people for assignment picker |
|
||
| GET | `/api/registry/vehicles` | Lookup: vehicles |
|
||
| GET | `/api/registry/equipment` | Lookup: equipment |
|
||
| GET | `/api/registry/materials` | Lookup: materials |
|
||
|
||
### Standard Response Envelope
|
||
|
||
```json
|
||
{
|
||
"data": { ... },
|
||
"meta": { "page": 1, "per_page": 25, "total": 142 },
|
||
"error": null
|
||
}
|
||
```
|
||
|
||
### Status Transition Rules (enforced server-side)
|
||
|
||
```
|
||
draft → assigned → scheduled → in_progress → pending_review → closed
|
||
↘ (re-open) ↗
|
||
```
|
||
|
||
---
|
||
|
||
## 6. Frontend — Web Components
|
||
|
||
### Design Principles
|
||
|
||
- **No build step** — plain `<script type="module">` imports. Works directly in browser.
|
||
- **Shadow DOM** per component — style isolation, no CSS leakage.
|
||
- **Custom events** for cross-component communication — no shared global state pollution.
|
||
- **`api.mjs`** is the single HTTP layer — all fetch calls go through it.
|
||
|
||
### `web/lib/api.mjs`
|
||
|
||
```javascript
|
||
const BASE = '/api';
|
||
|
||
let _token = localStorage.getItem('wo_token') || '';
|
||
|
||
export function setToken(t) {
|
||
_token = t;
|
||
localStorage.setItem('wo_token', t);
|
||
}
|
||
|
||
async function request(method, path, body, isFormData = false) {
|
||
const headers = { Authorization: `Bearer ${_token}` };
|
||
if (!isFormData) headers['Content-Type'] = 'application/json';
|
||
|
||
const res = await fetch(BASE + path, {
|
||
method,
|
||
headers,
|
||
body: body
|
||
? (isFormData ? body : JSON.stringify(body))
|
||
: undefined,
|
||
});
|
||
|
||
if (res.status === 401) {
|
||
// Token expired — redirect to login
|
||
window.dispatchEvent(new CustomEvent('auth:expired'));
|
||
return;
|
||
}
|
||
|
||
const json = await res.mjson();
|
||
if (!res.ok) throw new Error(json.error || 'Request failed');
|
||
return json.data;
|
||
}
|
||
|
||
export const api = {
|
||
get: (path) => request('GET', path),
|
||
post: (path, body) => request('POST', path, body),
|
||
put: (path, body) => request('PUT', path, body),
|
||
delete: (path) => request('DELETE', path),
|
||
upload: (path, form) => request('POST', path, form, true),
|
||
};
|
||
```
|
||
|
||
### `web/lib/router.mjs`
|
||
|
||
```javascript
|
||
// Hash-based router — no server config needed
|
||
class Router {
|
||
#routes = [];
|
||
|
||
on(pattern, handler) {
|
||
this.#routes.push({ pattern: new URLPattern({ pathname: pattern }), handler });
|
||
return this;
|
||
}
|
||
|
||
start() {
|
||
const dispatch = () => {
|
||
const path = location.hash.slice(1) || '/';
|
||
for (const { pattern, handler } of this.#routes) {
|
||
const m = pattern.exec({ pathname: path });
|
||
if (m) { handler(m.pathname.groups); return; }
|
||
}
|
||
};
|
||
window.addEventListener('hashchange', dispatch);
|
||
dispatch();
|
||
}
|
||
}
|
||
|
||
export const router = new Router();
|
||
```
|
||
|
||
### `web/components/wo-list.mjs`
|
||
|
||
```javascript
|
||
import { api } from '../lib/api.mjs';
|
||
|
||
class WoList extends HTMLElement {
|
||
#data = [];
|
||
#status = '';
|
||
|
||
connectedCallback() {
|
||
this.attachShadow({ mode: 'open' });
|
||
this.#render();
|
||
this.#load();
|
||
}
|
||
|
||
async #load() {
|
||
const status = this.getAttribute('filter-status') || '';
|
||
this.#data = await api.get(`/work-orders?status=${status}`);
|
||
this.#render();
|
||
}
|
||
|
||
#render() {
|
||
this.shadowRoot.innerHTML = `
|
||
<style>
|
||
:host { display: block; }
|
||
.grid { display: grid; gap: 0.75rem; }
|
||
.card {
|
||
background: var(--surface, #fff);
|
||
border: 1px solid var(--border, #e2e8f0);
|
||
border-radius: 8px;
|
||
padding: 1rem 1.25rem;
|
||
cursor: pointer;
|
||
display: grid;
|
||
grid-template-columns: 1fr auto;
|
||
gap: 0.25rem 1rem;
|
||
transition: box-shadow .15s;
|
||
}
|
||
.card:hover { box-shadow: 0 4px 12px rgba(0,0,0,.08); }
|
||
.wo-number { font-size: .75rem; color: var(--muted); }
|
||
.title { font-weight: 600; color: var(--text); }
|
||
.progress { font-size: .8rem; color: var(--muted); }
|
||
</style>
|
||
<div class="grid">
|
||
${this.#data.map(wo => `
|
||
<div class="card" data-id="${wo.id}">
|
||
<div>
|
||
<div class="wo-number">${wo.wo_number}</div>
|
||
<div class="title">${wo.title}</div>
|
||
<div class="progress">
|
||
${wo.site_name} · ${wo.steps_done}/${wo.step_count} steps
|
||
</div>
|
||
</div>
|
||
<wo-status-badge status="${wo.status}"></wo-status-badge>
|
||
</div>
|
||
`).join('')}
|
||
</div>`;
|
||
|
||
this.shadowRoot.querySelectorAll('.card').forEach(card => {
|
||
card.addEventListener('click', () => {
|
||
this.dispatchEvent(new CustomEvent('wo:select',
|
||
{ detail: { id: card.dataset.id }, bubbles: true, composed: true }));
|
||
});
|
||
});
|
||
}
|
||
}
|
||
|
||
customElements.define('wo-list', WoList);
|
||
```
|
||
|
||
### `web/components/wo-resource-panel.mjs`
|
||
|
||
```javascript
|
||
import { api } from '../lib/api.mjs';
|
||
|
||
class WoResourcePanel extends HTMLElement {
|
||
#woId = null;
|
||
#resources = [];
|
||
#registry = { people: [], vehicles: [], equipment: [], materials: [] };
|
||
|
||
static get observedAttributes() { return ['wo-id']; }
|
||
|
||
attributeChangedCallback(name, _, val) {
|
||
if (name === 'wo-id') { this.#woId = val; this.#load(); }
|
||
}
|
||
|
||
connectedCallback() {
|
||
this.attachShadow({ mode: 'open' });
|
||
if (this.#woId) this.#load();
|
||
}
|
||
|
||
async #load() {
|
||
const [resources, people, vehicles, equipment, materials] = await Promise.all([
|
||
api.get(`/work-orders/${this.#woId}/resources`),
|
||
api.get('/registry/people'),
|
||
api.get('/registry/vehicles'),
|
||
api.get('/registry/equipment'),
|
||
api.get('/registry/materials'),
|
||
]);
|
||
this.#resources = resources;
|
||
this.#registry = { people, vehicles, equipment, materials };
|
||
this.#render();
|
||
}
|
||
|
||
async #assign(type, resourceId) {
|
||
await api.post(`/work-orders/${this.#woId}/resources`, {
|
||
resource_type: type, resource_id: Number(resourceId)
|
||
});
|
||
await this.#load();
|
||
}
|
||
|
||
async #remove(assignmentId) {
|
||
await api.delete(`/work-orders/${this.#woId}/resources/${assignmentId}`);
|
||
await this.#load();
|
||
}
|
||
|
||
#sectionHTML(label, type, list) {
|
||
const assigned = this.#resources.filter(r => r.resource_type === type);
|
||
const available = list.filter(item =>
|
||
!assigned.some(a => a.resource_id === item.id));
|
||
|
||
return `
|
||
<section>
|
||
<h3>${label}</h3>
|
||
<ul>
|
||
${assigned.map(a => `
|
||
<li>${a.name}
|
||
<button class="remove-btn" data-id="${a.id}">×</button>
|
||
</li>`).join('')}
|
||
</ul>
|
||
<select class="add-select" data-type="${type}">
|
||
<option value="">Add ${label.slice(0,-1)}...</option>
|
||
${available.map(item =>
|
||
`<option value="${item.id}">${item.name ?? item.unit_number}</option>`
|
||
).join('')}
|
||
</select>
|
||
</section>`;
|
||
}
|
||
|
||
#render() {
|
||
const { people, vehicles, equipment, materials } = this.#registry;
|
||
this.shadowRoot.innerHTML = `
|
||
<style>
|
||
:host { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
|
||
section { background: var(--surface); border-radius: 8px; padding: 1rem; }
|
||
h3 { margin: 0 0 .5rem; font-size: .9rem; text-transform: uppercase;
|
||
letter-spacing: .05em; color: var(--muted); }
|
||
ul { list-style: none; padding: 0; margin: 0 0 .5rem; }
|
||
li { display: flex; justify-content: space-between; padding: .25rem 0;
|
||
border-bottom: 1px solid var(--border); font-size: .9rem; }
|
||
select { width: 100%; padding: .4rem; border-radius: 6px;
|
||
border: 1px solid var(--border); }
|
||
.remove-btn { background: none; border: none; cursor: pointer;
|
||
color: var(--danger, #c0392b); font-size: 1.1rem; }
|
||
</style>
|
||
${this.#sectionHTML('People', 'person', people)}
|
||
${this.#sectionHTML('Vehicles', 'vehicle', vehicles)}
|
||
${this.#sectionHTML('Equipment', 'equipment', equipment)}
|
||
${this.#sectionHTML('Materials', 'material', materials)}`;
|
||
|
||
this.shadowRoot.querySelectorAll('.add-select').forEach(sel => {
|
||
sel.addEventListener('change', e => {
|
||
if (e.target.value) this.#assign(e.target.dataset.type, e.target.value);
|
||
});
|
||
});
|
||
this.shadowRoot.querySelectorAll('.remove-btn').forEach(btn => {
|
||
btn.addEventListener('click', () => this.#remove(btn.dataset.id));
|
||
});
|
||
}
|
||
}
|
||
|
||
customElements.define('wo-resource-panel', WoResourcePanel);
|
||
```
|
||
|
||
### `web/components/wo-checklist.mjs`
|
||
|
||
```javascript
|
||
import { api } from '../lib/api.mjs';
|
||
|
||
class WoChecklist extends HTMLElement {
|
||
#woId = null;
|
||
#steps = [];
|
||
|
||
static get observedAttributes() { return ['wo-id']; }
|
||
attributeChangedCallback(_, __, val) { this.#woId = val; this.#load(); }
|
||
connectedCallback() { this.attachShadow({ mode: 'open' }); }
|
||
|
||
async #load() {
|
||
this.#steps = await api.get(`/work-orders/${this.#woId}/steps`);
|
||
this.#render();
|
||
}
|
||
|
||
async #complete(stepId) {
|
||
await api.post(`/work-orders/${this.#woId}/steps/${stepId}/complete`, {});
|
||
await this.#load();
|
||
this.dispatchEvent(new CustomEvent('steps:updated', { bubbles: true, composed: true }));
|
||
}
|
||
|
||
#render() {
|
||
const done = this.#steps.filter(s => s.completed).length;
|
||
const total = this.#steps.length;
|
||
const pct = total ? Math.round(done / total * 100) : 0;
|
||
|
||
this.shadowRoot.innerHTML = `
|
||
<style>
|
||
.progress-bar { height: 6px; background: var(--border); border-radius: 3px; margin-bottom: 1rem; }
|
||
.progress-fill { height: 100%; border-radius: 3px;
|
||
background: var(--accent, #0a7ea4); width: ${pct}%; transition: width .3s; }
|
||
.step { display: flex; gap: .75rem; align-items: flex-start;
|
||
padding: .75rem 0; border-bottom: 1px solid var(--border); }
|
||
.step.done .step-title { text-decoration: line-through; color: var(--muted); }
|
||
input[type=checkbox] { width: 1.2rem; height: 1.2rem; accent-color: var(--accent); margin-top: .15rem; }
|
||
.step-title { font-weight: 500; }
|
||
.step-desc { font-size: .85rem; color: var(--muted); margin-top: .2rem; }
|
||
.summary { font-size: .85rem; color: var(--muted); margin-bottom: .5rem; }
|
||
</style>
|
||
<div class="progress-bar"><div class="progress-fill"></div></div>
|
||
<div class="summary">${done} of ${total} steps complete</div>
|
||
${this.#steps.map(s => `
|
||
<div class="step ${s.completed ? 'done' : ''}">
|
||
<input type="checkbox" data-id="${s.id}" ${s.completed ? 'checked disabled' : ''}>
|
||
<div>
|
||
<div class="step-title">${s.step_order}. ${s.title}</div>
|
||
${s.description ? `<div class="step-desc">${s.description}</div>` : ''}
|
||
</div>
|
||
</div>`).join('')}`;
|
||
|
||
this.shadowRoot.querySelectorAll('input[type=checkbox]:not([disabled])').forEach(cb => {
|
||
cb.addEventListener('change', () => this.#complete(cb.dataset.id));
|
||
});
|
||
}
|
||
}
|
||
|
||
customElements.define('wo-checklist', WoChecklist);
|
||
```
|
||
|
||
### `web/styles/global.css` — Design Tokens
|
||
|
||
```css
|
||
:root {
|
||
--navy: #0d2137;
|
||
--accent: #0a7ea4;
|
||
--accent-lt: #14b8d4;
|
||
--surface: #ffffff;
|
||
--bg: #f0f6fa;
|
||
--border: #e2ebf0;
|
||
--text: #1a2e3b;
|
||
--muted: #64748b;
|
||
--danger: #c0392b;
|
||
--success: #1d9d6c;
|
||
--warning: #e07b39;
|
||
|
||
--radius: 8px;
|
||
--shadow: 0 2px 8px rgba(0,0,0,.08);
|
||
|
||
font-family: Calibri, 'Segoe UI', system-ui, sans-serif;
|
||
font-size: 16px;
|
||
color: var(--text);
|
||
background: var(--bg);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 7. File & Photo Handling
|
||
|
||
### Go Upload Handler
|
||
|
||
```go
|
||
func (h *AttachmentHandler) Upload(w http.ResponseWriter, r *http.Request) {
|
||
woID := chi.URLParam(r, "id")
|
||
r.ParseMultipartForm(32 << 20) // 32 MB max
|
||
|
||
file, header, err := r.FormFile("file")
|
||
if err != nil { respondError(w, 400, "missing file"); return }
|
||
defer file.Close()
|
||
|
||
// Build storage path: uploads/{wo_id}/{uuid}{ext}
|
||
ext := filepath.Ext(header.Filename)
|
||
fname := uuid.New().String() + ext
|
||
relPath := filepath.Join(woID, fname)
|
||
absPath := filepath.Join(h.cfg.UploadPath, relPath)
|
||
|
||
os.MkdirAll(filepath.Dir(absPath), 0755)
|
||
dst, _ := os.Create(absPath)
|
||
defer dst.Close()
|
||
io.Copy(dst, file)
|
||
|
||
// Extract EXIF GPS if it's a JPEG
|
||
lat, lng := extractGPS(absPath)
|
||
|
||
// Save record
|
||
att := &model.Attachment{
|
||
WOID: mustInt(woID),
|
||
FileName: header.Filename,
|
||
FilePath: relPath,
|
||
FileType: header.Header.Get("Content-Type"),
|
||
FileSize: header.Size,
|
||
Phase: r.FormValue("phase"),
|
||
Caption: r.FormValue("caption"),
|
||
Lat: lat,
|
||
Lng: lng,
|
||
UploadedBy: userIDFromCtx(r.Context()),
|
||
}
|
||
id, _ := h.repo.Create(r.Context(), att)
|
||
respond(w, 201, map[string]any{"id": id, "url": "/uploads/" + relPath})
|
||
}
|
||
```
|
||
|
||
### Frontend Photo Capture (`wo-photo-panel.mjs` key logic)
|
||
|
||
```javascript
|
||
// Use camera on mobile devices
|
||
async #capturePhoto() {
|
||
const input = document.createElement('input');
|
||
input.type = 'file';
|
||
input.accept = 'image/*';
|
||
input.capture = 'environment'; // rear camera
|
||
input.onchange = async (e) => {
|
||
const file = e.target.files[0];
|
||
const form = new FormData();
|
||
form.append('file', file);
|
||
form.append('phase', this.#phase); // 'before' | 'during' | 'after'
|
||
|
||
// Attach GPS from browser if available
|
||
if (navigator.geolocation) {
|
||
await new Promise(res => navigator.geolocation.getCurrentPosition(pos => {
|
||
form.append('lat', pos.coords.latitude);
|
||
form.append('lng', pos.coords.longitude);
|
||
res();
|
||
}, res));
|
||
}
|
||
|
||
await api.upload(`/work-orders/${this.#woId}/attachments`, form);
|
||
await this.#load();
|
||
};
|
||
input.click();
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 8. Maps & Location
|
||
|
||
- **Leaflet.mjs** (open source, no API key required) loaded via CDN for the embedded map.
|
||
- **Google Maps Directions API** (or OpenRouteService if you want fully open source) for turn-by-turn routing.
|
||
- Coordinates stored as `lat`/`lng` decimals in SQL Server. No geography type required unless you add spatial radius queries later.
|
||
|
||
### `web/components/wo-map.mjs` skeleton
|
||
|
||
```javascript
|
||
// Leaflet loaded via <script> in index.html
|
||
class WoMap extends HTMLElement {
|
||
connectedCallback() {
|
||
this.style.display = 'block';
|
||
this.style.height = '300px';
|
||
const map = L.map(this).setView([0, 0], 13);
|
||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png').addTo(map);
|
||
|
||
const lat = parseFloat(this.getAttribute('lat'));
|
||
const lng = parseFloat(this.getAttribute('lng'));
|
||
if (lat && lng) {
|
||
map.setView([lat, lng], 15);
|
||
L.marker([lat, lng]).addTo(map)
|
||
.bindPopup(this.getAttribute('site-name') || 'Work Site').openPopup();
|
||
}
|
||
}
|
||
}
|
||
customElements.define('wo-map', WoMap);
|
||
```
|
||
|
||
### Directions button
|
||
|
||
```javascript
|
||
// Opens Google Maps / Apple Maps turn-by-turn in native app on mobile
|
||
function openDirections(lat, lng, label) {
|
||
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
|
||
const url = isIOS
|
||
? `maps://maps.apple.com/?daddr=${lat},${lng}&dirflg=d`
|
||
: `https://maps.google.com/maps?daddr=${lat},${lng}`;
|
||
window.open(url, '_blank');
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 9. Authentication & Authorization
|
||
|
||
### JWT Middleware (Go)
|
||
|
||
```go
|
||
func JWT(secret string) func(http.Handler) http.Handler {
|
||
return func(next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
auth := r.Header.Get("Authorization")
|
||
if !strings.HasPrefix(auth, "Bearer ") {
|
||
http.Error(w, `{"error":"unauthorized"}`, 401); return
|
||
}
|
||
token, err := jwt.ParseWithClaims(strings.TrimPrefix(auth, "Bearer "),
|
||
&Claims{}, func(t *jwt.Token) (any, error) {
|
||
return []byte(secret), nil
|
||
})
|
||
if err != nil || !token.Valid {
|
||
http.Error(w, `{"error":"invalid token"}`, 401); return
|
||
}
|
||
claims := token.Claims.(*Claims)
|
||
ctx := context.WithValue(r.Context(), ctxKeyUser, claims)
|
||
next.ServeHTTP(w, r.WithContext(ctx))
|
||
})
|
||
}
|
||
}
|
||
```
|
||
|
||
### Roles to implement
|
||
|
||
| Role | Can Do |
|
||
|------|--------|
|
||
| `admin` | Full CRUD, close any WO, manage registries |
|
||
| `dispatcher` | Create & assign WOs, edit all fields |
|
||
| `field_tech` | View assigned WOs, complete steps, upload photos |
|
||
| `viewer` | Read-only access |
|
||
|
||
---
|
||
|
||
## 10. Docker Deployment
|
||
|
||
### `Dockerfile`
|
||
|
||
```dockerfile
|
||
# ── Build stage ───────────────────────────────────────────
|
||
FROM golang:1.23-alpine AS builder
|
||
WORKDIR /app
|
||
COPY go.mod go.sum ./
|
||
RUN go mod download
|
||
COPY . .
|
||
RUN CGO_ENABLED=0 go build -o workorder ./cmd/server
|
||
|
||
# ── Runtime stage ─────────────────────────────────────────
|
||
FROM alpine:3.20
|
||
WORKDIR /app
|
||
RUN apk add --no-cache tzdata ca-certificates
|
||
COPY --from=builder /app/workorder .
|
||
COPY web/ ./web/
|
||
|
||
EXPOSE 8080
|
||
CMD ["./workorder"]
|
||
```
|
||
|
||
### `docker-compose.yml`
|
||
|
||
```yaml
|
||
services:
|
||
api:
|
||
build: .
|
||
ports:
|
||
- "8080:8080"
|
||
environment:
|
||
ADDR: ":8080"
|
||
DB_DSN: "sqlserver://sa:${SA_PASSWORD}@mssql:1433?database=workorders"
|
||
JWT_SECRET: "${JWT_SECRET}"
|
||
UPLOAD_PATH: "/uploads"
|
||
volumes:
|
||
- uploads:/uploads
|
||
depends_on:
|
||
- mssql
|
||
restart: unless-stopped
|
||
|
||
mssql:
|
||
image: mcr.microsoft.com/mssql/server:2022-latest
|
||
environment:
|
||
SA_PASSWORD: "${SA_PASSWORD}"
|
||
ACCEPT_EULA: "Y"
|
||
MSSQL_PID: "Developer"
|
||
volumes:
|
||
- mssql_data:/var/opt/mssql
|
||
ports:
|
||
- "1433:1433" # Remove in production
|
||
|
||
volumes:
|
||
uploads:
|
||
mssql_data:
|
||
```
|
||
|
||
If you're connecting to your **existing SQL Server** instance, just point `DB_DSN` at it and drop the `mssql` service.
|
||
|
||
---
|
||
|
||
## 11. Phased Build Plan
|
||
|
||
### Phase 1 — Foundation (Weeks 1–4)
|
||
|
||
**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 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_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 9–12)
|
||
|
||
**Goal:** Connects to the rest of the system.
|
||
|
||
- [ ] DB: `wo_accounting` table
|
||
- [ ] `<wo-accounting>` — GL, cost center, WBS, billing ref fields
|
||
- [ ] Parent linking: polymorphic `parent_type`/`parent_id`
|
||
- [ ] Link WOs to existing Projects (query existing project IDs)
|
||
- [ ] Link to Trouble Tickets / Service Orders (when those APIs exist)
|
||
- [ ] Audit log table + `<wo-timeline>` component
|
||
- [ ] Email notification on assignment (Go `net/smtp` or SendGrid)
|
||
- [ ] Reporting endpoint: WOs by status, by cost center, by date range
|
||
|
||
### Phase 4 — Optimization (Week 13+)
|
||
|
||
- [ ] Dashboard `<wo-dashboard>` — KPI cards + charts (Chart.mjs via CDN)
|
||
- [ ] Crew scheduling calendar view
|
||
- [ ] Offline support (Service Worker + IndexedDB cache for field use)
|
||
- [ ] CSV / Excel export for accounting reconciliation
|
||
- [ ] Full-text search across WO title, site, instructions
|
||
- [ ] Webhook / API for third-party integrations
|
||
|
||
---
|
||
|
||
## 12. Development Conventions
|
||
|
||
### Go
|
||
|
||
```
|
||
Packages: lowercase, single word (handlers, repository, model)
|
||
Errors: always wrapped (fmt.Errorf("repo.Create: %w", err))
|
||
HTTP errors: JSON envelope {"error": "message"} with correct status code
|
||
DB queries: named params only @param_name — never string concat
|
||
Logging: log/slog structured key-value pairs
|
||
Tests: _test.go files table-driven, httptest.NewRecorder for handlers
|
||
```
|
||
|
||
### JavaScript
|
||
|
||
```
|
||
Components: kebab-case custom elements <wo-form>, <wo-map>
|
||
Private: # prefix for class fields #data, #load(), #render()
|
||
Events: colon namespaced 'wo:select', 'steps:updated', 'auth:expired'
|
||
API calls: always in the component no inline fetch(), always via api.mjs
|
||
Render: full innerHTML redraw simple, no virtual DOM needed at this scale
|
||
State: component-local lift to app-root only when truly shared
|
||
```
|
||
|
||
### Git Branch Strategy
|
||
|
||
```
|
||
main — production-ready, tagged releases
|
||
develop — integration branch
|
||
feature/* — individual features (feature/wo-checklist)
|
||
fix/* — bug fixes
|
||
```
|
||
|
||
---
|
||
|
||
## Go Dependencies
|
||
|
||
```go
|
||
// go.mod
|
||
require (
|
||
github.com/go-chi/chi/v5 v5.1.0
|
||
github.com/jmoiron/sqlx v1.3.5
|
||
github.com/microsoft/go-mssqldb v1.7.1
|
||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||
github.com/google/uuid v1.6.0
|
||
github.com/joho/godotenv v1.5.1
|
||
)
|
||
```
|
||
|
||
## Frontend Dependencies (CDN, no npm)
|
||
|
||
```html
|
||
<!-- in index.html -->
|
||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9/dist/leaflet.css">
|
||
<script src="https://unpkg.com/leaflet@1.9/dist/leaflet.mjs"></script>
|
||
<!-- Chart.mjs added in Phase 4 only -->
|
||
<script src="https://cdn.mjsdelivr.net/npm/chart.mjs@4/dist/chart.umd.min.mjs"></script>
|
||
```
|
||
|
||
Zero build tooling. Zero npm for the frontend. Open in browser and go.
|