diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..30cf57e --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/go.imports.xml b/.idea/go.imports.xml new file mode 100644 index 0000000..644cdf0 --- /dev/null +++ b/.idea/go.imports.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..50ae804 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/workorders.iml b/.idea/workorders.iml new file mode 100644 index 0000000..5e764c4 --- /dev/null +++ b/.idea/workorders.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9777ebc --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,1294 @@ +# 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. diff --git a/WorkOrderSystemProposal.pptx b/WorkOrderSystemProposal.pptx new file mode 100644 index 0000000..eec13fb Binary files /dev/null and b/WorkOrderSystemProposal.pptx differ