Files
workorders/CLAUDE.md
T

141 KiB
Raw Blame History

Work Order System — Build TODO

Hand this file to Claude Code. All frontend files use .mjs extensions. Stack: Go · Pure JS + Web Components · SQL Server · Docker


Table of Contents


Design System

Color Palette

:root {
  /* Brand */
  --navy:       #0D2137;   /* Primary dark — sidebar, headers */
  --teal:       #0A7EA4;   /* Primary accent — buttons, links, active states */
  --teal-lt:    #14B8D4;   /* Light accent — hover states, highlights */
  --teal-dk:    #075E7A;   /* Dark accent — pressed states */

  /* Surfaces */
  --bg:         #F0F6FA;   /* Page background */
  --surface:    #FFFFFF;   /* Cards, panels */
  --surface-2:  #E8F0F5;   /* Inset areas, table stripes */
  --sidebar-bg: #0D2137;   /* Sidebar background */
  --sidebar-hover: #153248;
  --sidebar-active: #0A7EA4;

  /* Text */
  --text:       #1A2E3B;   /* Primary text */
  --text-muted: #64748B;   /* Secondary / helper text */
  --text-inv:   #FFFFFF;   /* Text on dark backgrounds */

  /* Semantic */
  --success:    #1D9D6C;
  --warning:    #E07B39;
  --danger:     #C0392B;
  --info:       #0A7EA4;

  /* Borders & Shadows */
  --border:     #D1DDE6;
  --border-lt:  #E8F0F5;
  --shadow-sm:  0 1px 3px rgba(0,0,0,.08);
  --shadow-md:  0 4px 12px rgba(0,0,0,.10);
  --shadow-lg:  0 8px 24px rgba(0,0,0,.12);

  /* Status Pills */
  --status-draft:          #94A3B8;
  --status-assigned:       #0A7EA4;
  --status-scheduled:      #8B5CF6;
  --status-in_progress:    #E07B39;
  --status-pending_review: #D97706;
  --status-closed:         #1D9D6C;

  /* Priority */
  --priority-low:    #64748B;
  --priority-normal: #0A7EA4;
  --priority-high:   #E07B39;
  --priority-urgent: #C0392B;

  /* Spacing */
  --radius-sm:  4px;
  --radius:     8px;
  --radius-lg:  12px;
  --radius-xl:  16px;

  /* Sidebar width */
  --sidebar-w:  260px;
  --sidebar-collapsed: 64px;
}

Typography

Load via <link> in index.html — no build step needed:

<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
:root {
  --font-body: 'Inter', 'Segoe UI', system-ui, sans-serif;
  --font-mono: 'JetBrains Mono', 'Cascadia Code', monospace;

  --text-xs:   0.70rem;   /* 11px — labels, caps */
  --text-sm:   0.813rem;  /* 13px — helper, meta */
  --text-base: 0.938rem;  /* 15px — body */
  --text-md:   1.063rem;  /* 17px — subheadings */
  --text-lg:   1.25rem;   /* 20px — section headers */
  --text-xl:   1.5rem;    /* 24px — page titles */
  --text-2xl:  2rem;      /* 32px — dashboard hero numbers */

  --weight-normal:   400;
  --weight-medium:   500;
  --weight-semibold: 600;
  --weight-bold:     700;
}

Component Design Patterns

  • Cards: white background, var(--border) border, var(--radius) corners, var(--shadow-sm)
  • Buttons: filled primary = --teal, ghost = transparent + --teal border, danger = --danger
  • Inputs: --border border, focus ring --teal 2px outline-offset, --radius-sm corners
  • Tables: header row --surface-2, alternating rows, sticky header on scroll
  • Status pills: colored dot + label, pill shape, light background tint of the status color
  • Icon + label pattern everywhere — use Lucide icons loaded via CDN (no npm)

Icons

<!-- index.html — Lucide icon CDN, no API key, tree-shakeable -->
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>

Call lucide.createIcons() after each render. Use named icons: clipboard-list, users, truck, wrench, map-pin, camera, file-invoice, layout-dashboard, settings, bell, chevron-left, plus, search, filter


Project Structure

workorder/
├── CLAUDE.md                        ← !! READ FIRST — conventions for Claude Code
├── cmd/server/main.go
├── internal/
│   ├── api/
│   │   ├── router.go
│   │   ├── middleware/
│   │   │   ├── auth.go
│   │   │   ├── cors.go
│   │   │   └── logger.go
│   │   └── handlers/
│   │       ├── auth.go
│   │       ├── workorder.go
│   │       ├── step.go
│   │       ├── resource.go
│   │       ├── attachment.go
│   │       ├── accounting.go
│   │       ├── user.go
│   │       ├── registry.go          ← people/vehicle/equipment/material master lists
│   │       ├── dashboard.go
│   │       └── report.go
│   ├── service/
│   │   ├── workorder.go
│   │   ├── notification.go
│   │   ├── spatial.go
│   │   └── export.go               ← CSV/Excel export
│   ├── repository/
│   │   ├── db.go
│   │   ├── workorder.go
│   │   ├── step.go
│   │   ├── resource.go
│   │   ├── attachment.go
│   │   ├── accounting.go
│   │   ├── user.go
│   │   └── registry.go
│   ├── model/
│   │   ├── workorder.go
│   │   ├── step.go
│   │   ├── resource.go
│   │   ├── attachment.go
│   │   ├── accounting.go
│   │   ├── user.go
│   │   └── dashboard.go
│   └── config/config.go
│
├── web/                             ← All frontend — served as static files by Go
│   ├── index.html                   ← App shell, loads fonts, Lucide, Leaflet
│   ├── app.mjs                      ← <app-root> custom element + client router
│   │
│   ├── components/
│   │   ├── layout/
│   │   │   ├── app-sidebar.mjs      ← Left nav — collapsible, mobile drawer
│   │   │   ├── app-topbar.mjs       ← Top bar — breadcrumb, notifications bell, user avatar
│   │   │   ├── app-root.mjs         ← Shell that wires sidebar + topbar + <main>
│   │   │   └── app-mobile-nav.mjs   ← Bottom tab bar for mobile (≤768px)
│   │   │
│   │   ├── work-orders/
│   │   │   ├── wo-list.mjs          ← Searchable, filterable WO list
│   │   │   ├── wo-kanban.mjs        ← Kanban board view (by status column)
│   │   │   ├── wo-form.mjs          ← Create / edit full form
│   │   │   ├── wo-detail.mjs        ← Read-only detail — tabs for each section
│   │   │   ├── wo-checklist.mjs     ← Step checklist with check-off + progress bar
│   │   │   ├── wo-resource-panel.mjs← Assign people, vehicles, equipment, materials
│   │   │   ├── wo-photo-panel.mjs   ← Photo gallery, capture, before/during/after
│   │   │   ├── wo-map.mjs           ← Leaflet map + directions button
│   │   │   ├── wo-accounting.mjs    ← GL, cost center, WBS, billing ref fields
│   │   │   ├── wo-timeline.mjs      ← Audit/activity log feed
│   │   │   └── wo-print.mjs         ← Print-friendly layout for field packets
│   │   │
│   │   ├── dashboard/
│   │   │   ├── dash-root.mjs        ← Dashboard page shell
│   │   │   ├── dash-kpi-card.mjs    ← Reusable stat card (number + trend + icon)
│   │   │   ├── dash-status-chart.mjs← Doughnut chart — WOs by status
│   │   │   ├── dash-priority-bar.mjs← Bar chart — WOs by priority
│   │   │   └── dash-recent-feed.mjs ← Recent activity feed
│   │   │
│   │   ├── registry/
│   │   │   ├── people-list.mjs      ← Manage crew / technician records
│   │   │   ├── people-form.mjs
│   │   │   ├── vehicle-list.mjs     ← Manage fleet / vehicles
│   │   │   ├── vehicle-form.mjs
│   │   │   ├── equipment-list.mjs   ← Manage tools & equipment
│   │   │   ├── equipment-form.mjs
│   │   │   ├── material-list.mjs    ← Manage materials / inventory
│   │   │   └── material-form.mjs
│   │   │
│   │   ├── users/
│   │   │   ├── user-list.mjs        ← User management (admin only)
│   │   │   ├── user-form.mjs        ← Create / edit user, assign role
│   │   │   └── user-profile.mjs     ← Current user profile + password change
│   │   │
│   │   ├── reports/
│   │   │   ├── report-root.mjs      ← Reports landing page
│   │   │   ├── report-by-status.mjs
│   │   │   ├── report-by-cost.mjs
│   │   │   └── report-export.mjs    ← CSV / Excel download triggers
│   │   │
│   │   └── shared/
│   │       ├── ui-badge.mjs         ← <ui-badge> — status + priority pills
│   │       ├── ui-button.mjs        ← <ui-button> — primary/ghost/danger variants
│   │       ├── ui-card.mjs          ← <ui-card> — surface container
│   │       ├── ui-dialog.mjs        ← <ui-dialog> — modal with backdrop
│   │       ├── ui-drawer.mjs        ← <ui-drawer> — slide-in panel (mobile forms)
│   │       ├── ui-toast.mjs         ← <ui-toast> — success/error notifications
│   │       ├── ui-spinner.mjs       ← <ui-spinner> — loading state
│   │       ├── ui-empty.mjs         ← <ui-empty> — empty state illustration + CTA
│   │       ├── ui-confirm.mjs       ← <ui-confirm> — "Are you sure?" dialog
│   │       ├── ui-search.mjs        ← <ui-search> — debounced search input
│   │       ├── ui-tabs.mjs          ← <ui-tabs> — tab bar + panels
│   │       ├── ui-avatar.mjs        ← <ui-avatar> — initials or photo avatar
│   │       └── ui-tooltip.mjs       ← <ui-tooltip> — hover tooltip
│   │
│   ├── lib/
│   │   ├── api.mjs                  ← Fetch wrapper, auth header, error handling
│   │   ├── router.mjs               ← Hash/history client router
│   │   ├── store.mjs                ← Reactive state (lightweight signal pattern)
│   │   ├── auth.mjs                 ← JWT storage, decode, role checks
│   │   ├── format.mjs               ← Date, currency, phone formatters
│   │   ├── validate.mjs             ← Form field validators
│   │   └── utils.mjs                ← Misc helpers, debounce, deepMerge
│   │
│   └── styles/
│       ├── global.css               ← CSS custom properties (design tokens above)
│       ├── reset.css                ← Modern CSS reset
│       ├── typography.css           ← Base font rules
│       ├── forms.css                ← Shared input/select/textarea styles
│       ├── tables.css               ← Shared table styles
│       └── print.css               ← Print overrides for wo-print.mjs
│
├── migrations/
│   ├── 001_initial.sql
│   ├── 002_resources.sql
│   ├── 003_attachments.sql
│   ├── 004_accounting.sql
│   ├── 005_users_roles.sql
│   └── 006_audit_log.sql
│
├── uploads/                         ← Bind-mounted in Docker
├── Dockerfile
├── docker-compose.yml
├── .env.example
└── go.mod

Phase 1 — Foundation

Goal: Running app with shell, sidebar, auth, and WO CRUD.

1.1 CLAUDE.md (create first)

  • Document .mjs extension convention for all frontend JS modules
  • Document Go conventions: package names, error wrapping, named DB params
  • Document CSS convention: always use design token vars, never hardcoded hex
  • Document component convention: Shadow DOM, #private fields, custom events
  • List all CDN dependencies and their globals (L for Leaflet, lucide, Chart)

1.2 Go Project Bootstrap

  • go mod init github.com/yourorg/workorder
  • Add dependencies: chi/v5, sqlx, go-mssqldb, golang-jwt/jwt/v5, google/uuid, joho/godotenv
  • internal/config/config.go — load from env: ADDR, DB_DSN, JWT_SECRET, UPLOAD_PATH, BASE_URL
  • internal/repository/db.gosqlx.Connect, pool settings (max open 25, max idle 5, lifetime 5min)
  • cmd/server/main.go — wire config → db → router → http.ListenAndServe

1.3 Database — Migration 001

-- work_orders table (full schema as planned)
-- Computed wo_number column: 'WO-' + zero-padded id
-- status CHECK constraint
-- priority CHECK constraint
-- Indexes on status, parent, scheduled_start

1.4 Auth

  • migrations/005_users_roles.sql
    CREATE TABLE users (
        id           INT IDENTITY PRIMARY KEY,
        username     NVARCHAR(100) NOT NULL UNIQUE,
        email        NVARCHAR(200) NOT NULL UNIQUE,
        display_name NVARCHAR(200),
        password_hash NVARCHAR(200) NOT NULL,   -- bcrypt
        role         NVARCHAR(30) NOT NULL DEFAULT 'viewer',
                     -- admin | dispatcher | field_tech | viewer
        avatar_url   NVARCHAR(500),
        active       BIT NOT NULL DEFAULT 1,
        last_login   DATETIME2,
        created_at   DATETIME2 NOT NULL DEFAULT GETUTCDATE()
    );
    -- Seed one admin user
    
  • POST /api/auth/login — bcrypt compare, return { token, user } with 8hr JWT
  • POST /api/auth/refresh — accepts valid token, returns new token
  • GET /api/auth/me — current user from token claims
  • JWT middleware: attach user to context.Context, return 401 JSON on failure
  • Role helper: RequireRole(roles ...string) middleware

1.5 Work Order Backend (CRUD)

  • model/workorder.goWorkOrder, WorkOrderListItem, WorkOrderDetail structs with db: and json: tags
  • repository/workorder.go
    • List(ctx, filters) — status, search, priority, parentType, page/limit
    • GetByID(ctx, id) — single WO
    • Create(ctx, wo) — insert + return generated ID
    • Update(ctx, wo) — partial update, always set updated_at = GETUTCDATE()
    • UpdateStatus(ctx, id, status, userID) — transition + audit log insert
    • Delete(ctx, id) — soft delete (add deleted_at column)
    • GetDetail(ctx, id) — joins WO + steps + resources + attachments + accounting in one call
  • Handlers: List, Get, Create, Update, Delete, UpdateStatus
  • Paginated response: { data: [...], meta: { page, per_page, total } }

1.6 Frontend Shell

  • web/index.html

    • Load Google Fonts (Inter + JetBrains Mono)
    • Load Lucide CDN
    • Load Leaflet CSS + JS CDN
    • Load Chart.js CDN
    • <script type="module" src="app.mjs">
    • Viewport meta tag: width=device-width, initial-scale=1
  • web/styles/reset.css — modern reset (box-sizing, margin 0, img max-width)

  • web/styles/global.css — all design tokens, base body/font rules

  • web/styles/forms.css — input, select, textarea, label, fieldset base styles

  • web/lib/api.mjs — fetch wrapper, auto-inject Authorization, handle 401 → emit auth:expired

  • web/lib/auth.mjsgetToken(), setToken(), clearToken(), getUser(), hasRole()

  • web/lib/router.mjs — hash router, on(pattern, handler), navigate(path), start()

  • web/lib/format.mjsformatDate(), formatDateTime(), formatRelative(), formatPhone()

  • web/lib/store.mjs — reactive signal: createStore(initial) returns { get, set, subscribe }

1.7 Sidebar Layout

  • web/components/layout/app-sidebar.mjs

    • Dark navy background (var(--sidebar-bg))
    • Top: app logo / wordmark
    • Nav items with Lucide icon + label (see nav items below)
    • Active item: left accent bar + --teal background
    • Hover: --sidebar-hover background, smooth transition
    • Bottom: current user avatar + name + logout button
    • Collapsible: click toggle collapses to icon-only mode (--sidebar-collapsed width)
    • Collapse state stored in localStorage
    • Collapsed: show only icons with <ui-tooltip> on hover showing label
  • Nav items (in order):

    ─── Main ────────────────
    Dashboard        /          (layout-dashboard icon)
    Work Orders      /work-orders (clipboard-list icon)
    ─── Resources ───────────
    People           /registry/people  (users icon)
    Vehicles         /registry/vehicles (truck icon)
    Equipment        /registry/equipment (wrench icon)
    Materials        /registry/materials (package icon)
    ─── Operations ──────────
    Reports          /reports   (bar-chart-2 icon)
    ─── Admin ────────────────  (only shown if role = admin)
    Users            /users     (user-cog icon)
    Settings         /settings  (settings icon)
    
  • web/components/layout/app-topbar.mjs

    • Left: hamburger menu button (mobile) / breadcrumb (desktop)
    • Right: notification bell with unread count badge, user avatar dropdown
    • Dropdown: "My Profile", "Change Password", divider, "Sign Out"
    • Topbar is fixed/sticky at top, z-index above content
  • web/components/layout/app-mobile-nav.mjs

    • Fixed bottom tab bar on screens ≤ 768px
    • Show 5 main items: Dashboard, Work Orders, People, Reports, Menu (opens drawer)
    • Active tab: --teal color + underline indicator
    • Safe area padding for iOS notch: padding-bottom: env(safe-area-inset-bottom)
  • web/components/layout/app-root.mjs

    • Orchestrates sidebar + topbar + <main> content slot
    • Desktop: sidebar left, content fills remaining width
    • Mobile: sidebar hidden (drawer), topbar + bottom tab bar
    • CSS Grid layout:
      /* Desktop */
      display: grid;
      grid-template-columns: var(--sidebar-w) 1fr;
      grid-template-rows: 56px 1fr;
      
      /* Mobile ≤768px */
      grid-template-columns: 1fr;
      grid-template-rows: 56px 1fr 56px;
      
  • web/app.mjs

    • Register all components
    • Initialize router: map hash routes to <main> innerHTML swaps
    • Listen for auth:expired → clear token → navigate to /login

1.8 Shared UI Components (needed in Phase 1)

  • ui-badge.mjs — status & priority pills

    <ui-badge type="status" value="in_progress">In Progress</ui-badge>
    <ui-badge type="priority" value="urgent">Urgent</ui-badge>
    

    Colored dot + label, background is 15% opacity of the status color

  • ui-button.mjsvariant="primary|ghost|danger|icon", size="sm|md|lg", loading attr shows spinner

  • ui-spinner.mjs — CSS animated ring, size attr

  • ui-toast.mjs — fixed top-right, slide-in animation, auto-dismiss after 4s, type="success|error|info"

  • ui-empty.mjs — centered illustration + heading + body + optional CTA button

  • ui-dialog.mjs<dialog> element, backdrop, close on Escape + backdrop click, size="sm|md|lg"

1.9 WO List Page

  • wo-list.mjs

    • Page header: "Work Orders" title + "New Work Order" primary button
    • Filter bar: search input, status dropdown, priority dropdown, date range pickers
    • View toggle: List view | Kanban view (icon buttons, remember choice in localStorage)
    • List view: table on desktop, card stack on mobile
      • Columns: WO#, Title, Site, Status, Priority, Scheduled, Steps progress, Photos count, Actions
      • Row click → navigate to detail
      • Actions column: Edit (pencil), delete (trash, confirm dialog)
      • Sticky header, sortable columns (client-side sort for Phase 1)
    • Loading: skeleton rows (CSS animation, not a spinner)
    • Empty state: <ui-empty> with clipboard illustration + "Create your first Work Order"
    • Pagination: page controls at bottom
  • wo-kanban.mjs

    • Columns: Draft | Assigned | Scheduled | In Progress | Pending Review | Closed
    • Each column header: status color + count badge
    • Cards: WO#, title, site name, priority badge, assignee avatars, step progress bar
    • Horizontal scroll on mobile (each column is scrollable vertically)
    • Drag-and-drop to change status (HTML5 drag API, no lib needed)

1.10 WO Form Page

  • wo-form.mjs — used for both Create and Edit
    • Page header: "New Work Order" or "Edit WO-000123" + breadcrumb
    • Layout: two-column on desktop (grid-template-columns: 2fr 1fr), single column mobile
    • Left column — main fields:
      • Title (required, full width)
      • Description (textarea, 3 rows)
      • Instructions (textarea, 5 rows — will become rich text in Phase 4)
      • Site Name
      • Address (with optional geocode button → fills Lat/Lng)
      • Lat / Lng (read-only display, set by geocoder or map click)
      • Access Notes (gate codes, road conditions)
    • Right column — metadata:
      • Status (select)
      • Priority (select with colored indicator)
      • Parent Type (select: None / Project / Trouble Ticket / Service Order)
      • Parent ID (text field, shown only when parent type selected)
      • Scheduled Start (datetime-local input)
      • Scheduled End (datetime-local input)
    • Footer: Cancel (ghost) | Save Draft | Save & Open (primary)
    • Validation: highlight invalid fields, scroll to first error, toast on success
    • Unsaved-changes guard: warn before navigating away if form is dirty

1.11 WO Detail Page

  • wo-detail.mjs — tabbed layout
    • Header section: WO number (mono font), title, status badge, priority badge
    • Metadata strip: site, scheduled date, created by, parent link (clickable)
    • Action buttons: Edit, Print, Change Status (dropdown), Close WO
    • Tabs: Overview | Checklist | Resources | Photos | Accounting | Activity
    • Each tab is its own component loaded lazily when tab is clicked
    • Tab indicator: --teal underline, smooth slide transition

Phase 2 — Field Features

Goal: Everything a crew needs on their phone in the field.

2.1 Database Migrations

  • 002_resources.sqlwo_steps, wo_resources, resource_people, resource_vehicles, resource_equipment, resource_materials
  • 003_attachments.sqlwo_attachments

2.2 Checklist / Steps

Backend:

  • GET /api/work-orders/{id}/steps — ordered list
  • POST /api/work-orders/{id}/steps — add step { title, description, step_order, required }
  • PUT /api/work-orders/{id}/steps/{sid} — edit step (title, description, reorder)
  • PUT /api/work-orders/{id}/steps/reorder — bulk reorder { steps: [{id, order}] }
  • POST /api/work-orders/{id}/steps/{sid}/complete — check off, record completed_by, completed_at, optional notes
  • POST /api/work-orders/{id}/steps/{sid}/uncomplete — undo check (dispatcher+ only)
  • DELETE /api/work-orders/{id}/steps/{sid}

Frontend wo-checklist.mjs:

  • Progress bar at top: X of Y steps complete with animated fill
  • Each step row:
    • Checkbox (large, touch-friendly — min 44px tap target)
    • Step number + title (strikethrough when complete)
    • Description (collapsed, expand on tap)
    • "Add note" button → inline textarea for completion note
    • Photo camera button → triggers photo capture attached to this step
    • Completed by + timestamp (shown when done, muted)
  • Add Step form at bottom: title field + optional description + Add button
  • Drag handle for reordering (desktop; long-press on mobile)
  • Steps auto-saved — no save button, immediate API call on check
  • If all steps complete → confetti animation + prompt to move status to Pending Review

2.3 Resource Registry (Master Lists)

Backend:

  • GET /api/registry/people?search=&active=true — paginated list
  • POST /api/registry/people — create person record
  • PUT /api/registry/people/{id} — update
  • DELETE /api/registry/people/{id} — soft delete (set active = 0)
  • Same CRUD for /registry/vehicles, /registry/equipment, /registry/materials

Frontend — People (people-list.mjs, people-form.mjs):

  • List page: avatar + name + role + phone + email + active badge + edit/deactivate actions
  • Form fields: display name, role/title, email, phone, notes, active toggle
  • Avatar: show initials in colored circle (hash name to color), or upload photo

Frontend — Vehicles (vehicle-list.mjs, vehicle-form.mjs):

  • List: unit number, description, type, license plate, active status
  • Form fields: unit number, description, vehicle type (bucket truck / van / trailer / other), license plate, notes, active toggle

Frontend — Equipment (equipment-list.mjs, equipment-form.mjs):

  • List: name, asset tag, category, last assigned WO, active status
  • Form fields: name, asset tag, category (fusion splicer / OTDR / blower / reel / other), serial number, notes, active toggle

Frontend — Materials (material-list.mjs, material-form.mjs):

  • List: name, part number, unit of measure, active status
  • Form fields: name, part number, unit (ft / ea / box / roll), notes, active toggle

2.4 Resource Assignment Panel

Backend:

  • GET /api/work-orders/{id}/resources
  • POST /api/work-orders/{id}/resources{ resource_type, resource_id, quantity?, notes? }
  • DELETE /api/work-orders/{id}/resources/{rid}
  • GET /api/registry/people?search= — for typeahead pickers

Frontend wo-resource-panel.mjs:

  • Four sections: People · Vehicles · Equipment · Materials
  • Each section:
    • Assigned list: avatar/icon + name + remove (×) button
    • "Add" button → opens <ui-dialog> with searchable picker
    • Picker: search input + scrollable list with checkboxes → "Add Selected" button
    • Materials: quantity field next to each assigned item
  • Empty section shows <ui-empty> inline: "No people assigned yet"
  • Changes reflect immediately, API call on each add/remove
  • Total counts shown in section header: "People (3)"

2.5 Photo Panel

Backend:

  • POST /api/work-orders/{id}/attachments — multipart, fields: file, phase, caption, step_id?
  • GET /api/work-orders/{id}/attachments
  • PUT /api/work-orders/{id}/attachments/{aid} — update caption/phase
  • DELETE /api/work-orders/{id}/attachments/{aid}
  • Extract EXIF GPS on server if present in JPEG
  • Generate thumbnail on upload (use imaging Go package, 400px wide JPEG)
  • Serve originals at /uploads/{path}, thumbs at /uploads/thumbs/{path}

Frontend wo-photo-panel.mjs:

  • Phase tabs at top: All · Before · During · After — filter photos by phase
  • Photo grid: 3 columns desktop, 2 columns tablet, 2 columns mobile
  • Each photo tile: thumbnail, hover overlay with caption + phase badge
  • Click → lightbox modal with full-size image, prev/next arrows, swipe on mobile
  • Upload button:
    • Desktop: file picker (multi-select, drag-and-drop zone)
    • Mobile: <input type="file" accept="image/*" capture="environment"> for camera
  • Upload flow: show thumbnail preview + phase selector + caption field before confirm
  • Upload progress: per-file progress bar, queue multiple uploads
  • Geo-tagged indicator: small pin icon if lat/lng present
  • Empty state per phase: "No 'before' photos yet — capture site condition before starting"

2.6 Map & Location

Backend:

  • GET /api/work-orders/{id}/location — return { lat, lng, address, site_name, access_notes }
  • PUT /api/work-orders/{id}/location — update coordinates + address

Frontend wo-map.mjs:

  • Leaflet map, 300px height (expandable to full-screen on mobile)
  • OpenStreetMap tile layer (no API key)
  • Custom pin: teal SVG marker with WO number label
  • "Get Directions" button → deeplink:
    • iOS: maps://maps.apple.com/?daddr={lat},{lng}
    • Android/other: https://maps.google.com/maps?daddr={lat},{lng}
  • "Set Location" mode: click map to drop pin → updates lat/lng fields in WO form
  • Geocode button in WO form: type address → calls OpenStreetMap Nominatim → fills lat/lng (free, no key)
  • Access notes shown below map in an info box with lock icon

2.7 Mobile Responsiveness Audit

  • All tap targets ≥ 44×44px
  • No horizontal scroll on any page at 375px width
  • Forms: single column, full-width inputs
  • Tables: horizontal scroll container or card-based list on mobile
  • Sidebar: hidden, accessible via hamburger → slides in as drawer with backdrop
  • Bottom nav app-mobile-nav.mjs visible on ≤ 768px, hidden on desktop
  • Photo capture uses capture="environment" on mobile
  • Map "Get Directions" opens native maps app
  • Dialogs: full-screen on mobile (position: fixed; inset: 0)
  • Test breakpoints: 375px (iPhone SE), 390px (iPhone 14), 768px (iPad), 1024px, 1440px

Phase 3 — Integrations & Admin

3.1 Accounting Codes

Backend:

  • 004_accounting.sqlwo_accounting table (wo_id, code_type, code_value, description)
  • GET /api/work-orders/{id}/accounting
  • PUT /api/work-orders/{id}/accounting — upsert all codes in one request [{ code_type, code_value, description }]

Frontend wo-accounting.mjs:

  • Four fields in a clean two-column grid:
    • GL Account Code (text + description)
    • Cost Center / Department (text + description)
    • Project WBS / Phase (text + description)
    • Billing Reference (text + description)
  • Each field: code input + description input on same row
  • Auto-save on blur (no save button)
  • Show "auto-populated from parent" chip if code came from parent record
  • Future: lookup/typeahead from your chart of accounts

3.2 Polymorphic Parent Linking

  • WO form: parent type select (None / Project / Trouble Ticket / Service Order)
  • When type selected: show search/ID input for the parent record
  • In WO detail header: linked parent shown as a chip with icon
    📋 Project: Elm Street FTTH Build  →
    
  • Clicking the chip opens a drawer with parent record summary (Phase 4: deep link)
  • Backend: GET /api/work-orders?parent_type=project&parent_id=42 — list WOs for a given parent

3.3 Audit Log / Activity Timeline

Backend:

  • 006_audit_log.sqlwo_audit_log (id, wo_id, action, old_value, new_value, performed_by, performed_at)
  • Log every status change, resource assignment, step completion, photo upload, accounting change
  • GET /api/work-orders/{id}/activity — paginated, newest first

Frontend wo-timeline.mjs:

  • Vertical timeline feed: dot + connector line
  • Each entry: user avatar + action description + timestamp (relative + absolute on hover)
  • Status changes: colored badge showing old → new
  • Photo uploads: thumbnail preview inline
  • Step completions: step title + optional note
  • Load more button at bottom (pagination)
  • Auto-refresh every 30s if WO is in_progress

3.4 User Management (Admin Only)

Backend:

  • GET /api/users — list all users (admin only)
  • POST /api/users — create user, auto-send invite email
  • PUT /api/users/{id} — update name, role, active status
  • PUT /api/users/{id}/password — admin reset
  • DELETE /api/users/{id} — deactivate (soft delete)
  • GET /api/users/me — current user profile
  • PUT /api/users/me — update own display name, avatar
  • PUT /api/users/me/password — change own password (requires current password)

Frontend:

  • user-list.mjs — table: avatar, name, email, role badge, last login, active toggle, edit button
  • user-form.mjs — dialog: display name, email, role select, active toggle, force password reset checkbox
  • user-profile.mjs — own profile: avatar upload, display name, current password + new password fields
  • Role badges with colors: Admin=danger, Dispatcher=teal, Field Tech=warning, Viewer=muted

3.5 Notifications

Backend:

  • wo_notifications table: (id, user_id, wo_id, type, message, read, created_at)
  • Create notifications when:
    • WO assigned to you
    • WO status changes (for creator and assignees)
    • All steps completed (for dispatcher/admin)
    • WO approaching scheduled time (30 min warning, cron job)
  • GET /api/notifications?unread=true — list notifications
  • PUT /api/notifications/{id}/read — mark read
  • PUT /api/notifications/read-all — mark all read
  • Email notification: Go net/smtp with HTML template, send on assignment

Frontend:

  • Bell icon in topbar: unread count badge (red dot with number)
  • Click bell → dropdown panel with notification list
  • Each notification: icon, message, WO link, relative time, unread dot
  • "Mark all read" button at top
  • Click notification → navigate to WO detail + mark read
  • Poll /api/notifications?unread=true every 60s for new count

3.6 Print / Field Packet

Frontend wo-print.mjs:

  • Triggered by "Print" button in WO detail header
  • Opens print-preview page (new tab or print dialog)
  • Printed layout includes:
    • WO number, title, site info, scheduled date
    • All resource assignments (people, vehicles, equipment, materials)
    • Full instructions + access notes
    • Map static image (use Leaflet's map.printPlugin or OpenStreetMap static tiles)
    • Numbered step checklist (checkbox squares for pen check-off)
    • Accounting codes
    • Photo thumbnails (before photos only, 2×3 grid)
    • Signature / completion line
  • print.css: hide sidebar, topbar, action buttons; show print-only elements; break pages sensibly

3.7 Reports

Backend:

  • GET /api/reports/summary — total WOs by status, by priority, by date range
  • GET /api/reports/by-cost-center — WO count and open/closed by cost center
  • GET /api/reports/by-resource — WOs per person/vehicle over date range
  • GET /api/reports/overdue — WOs past scheduled end still not closed
  • GET /api/reports/export?format=csv&status=closed&from=&to= — CSV download

Frontend:

  • report-root.mjs — filter bar: date range, status, cost center, parent type
  • report-by-status.mjs — doughnut chart (Chart.js) + summary table
  • report-by-cost.mjs — bar chart by cost center + table with WO list
  • report-export.mjs — "Export to CSV" button + date range picker + format select

Phase 4 — Polish & Optimization

4.1 Dashboard

Backend:

  • GET /api/dashboard — single endpoint returns all KPIs:
    {
      "open_count": 42,
      "in_progress_count": 12,
      "overdue_count": 5,
      "closed_today": 8,
      "by_status": [...],
      "by_priority": [...],
      "recent_activity": [...],
      "upcoming": [...]
    }
    

Frontend dash-root.mjs:

  • KPI row — four <dash-kpi-card> components:

    • Open Work Orders (blue, clipboard icon)
    • In Progress (orange, activity icon)
    • Overdue (red, alert-circle icon)
    • Closed Today (green, check-circle icon)
    • Each card: large number, trend indicator (↑↓ vs yesterday), icon, click → filtered WO list
  • Charts row (2 columns):

    • Doughnut: WOs by Status (Chart.js, use status colors from design system)
    • Bar: WOs by Priority (horizontal bars)
  • Bottom row (2 columns):

    • Recent Activity feed (<dash-recent-feed>)
    • Upcoming WOs this week (compact list, sorted by scheduled_start)
  • Dashboard auto-refreshes every 2 minutes

  • Skeleton loading state while data loads

4.2 Offline / PWA Basics

  • manifest.json — app name, icons, display: standalone, theme color #0D2137
  • sw.js — Service Worker:
    • Cache shell (index.html, CSS, .mjs files) on install
    • Cache API GET responses in IndexedDB for offline read
    • Queue photo uploads when offline, sync on reconnect (Background Sync API)
  • Offline indicator banner: "You're offline — viewing cached data"
  • "Add to Home Screen" prompt on mobile

4.3 Rich Text Instructions

  • Swap <textarea> for Trix editor (no build needed, CDN available)
  • Store as HTML in instructions column (NVARCHAR(MAX))
  • Display with sanitized innerHTML (strip <script> tags server-side)
  • SQL Server CONTAINS or FREETEXT on work_orders.title, description, instructions, site_name
  • Global search bar in topbar — results show WOs, people, equipment matching query
  • Keyboard shortcut: Ctrl+K / Cmd+K opens search spotlight dialog

4.5 Scheduling Calendar

  • wo-calendar.mjs — monthly/weekly view
  • Each WO block: title, priority color stripe, assignee avatars
  • Click WO block → detail drawer slides in without leaving calendar
  • Drag WO block to reschedule (updates scheduled_start, scheduled_end)

4.6 Crew Scheduling View

  • Gantt-style view: rows = people, columns = days
  • Each WO shown as a bar on the assigned person's row
  • Spot conflicts: overlapping WOs on same person highlighted in red

4.7 Additional Polish

  • Dark mode: add [data-theme="dark"] overrides for all CSS vars, toggle stored in localStorage
  • Keyboard navigation: all interactive elements focusable, visible focus rings, tab order logical
  • Error boundaries: if a component throws, show error card instead of crashing whole page
  • Rate limiting on API (chi middleware: 100 req/min per IP)
  • API response compression (gzip middleware)
  • Input sanitization: strip HTML from all text fields server-side before saving
  • ETag / Last-Modified headers on GET responses for browser caching

Component Inventory

Component File Status Phase
App shell app-root.mjs 1
Sidebar app-sidebar.mjs 1
Topbar app-topbar.mjs 1
Mobile nav app-mobile-nav.mjs 1
WO List wo-list.mjs 1
WO Kanban wo-kanban.mjs 1
WO Form wo-form.mjs 1
WO Detail wo-detail.mjs 1
Status Badge ui-badge.mjs 1
Button ui-button.mjs 1
Dialog ui-dialog.mjs 1
Toast ui-toast.mjs 1
Spinner ui-spinner.mjs 1
Empty state ui-empty.mjs 1
Checklist wo-checklist.mjs 2
Resource panel wo-resource-panel.mjs 2
Photo panel wo-photo-panel.mjs 2
Map wo-map.mjs 2
People list/form people-list.mjs 2
Vehicle list/form vehicle-list.mjs 2
Equipment list/form equipment-list.mjs 2
Material list/form material-list.mjs 2
Accounting wo-accounting.mjs 3
Timeline wo-timeline.mjs 3
Print packet wo-print.mjs 3
User list/form user-list.mjs 3
User profile user-profile.mjs 3
Reports report-root.mjs 3
Dashboard dash-root.mjs 4
KPI card dash-kpi-card.mjs 4
Calendar wo-calendar.mjs 4
Search spotlight (in app-topbar.mjs) 4

API Endpoints

Auth

Method Path Auth Description
POST /api/auth/login Login, returns JWT + user
POST /api/auth/refresh Refresh token
GET /api/auth/me Current user

Work Orders

Method Path Role Description
GET /api/work-orders viewer+ List (filter: status, priority, search, parent, date)
POST /api/work-orders dispatcher+ Create
GET /api/work-orders/{id} viewer+ Full detail
PUT /api/work-orders/{id} dispatcher+ Update
DELETE /api/work-orders/{id} admin Soft delete
PUT /api/work-orders/{id}/status field_tech+ Status transition
GET /api/work-orders/{id}/steps viewer+ List steps
POST /api/work-orders/{id}/steps dispatcher+ Add step
PUT /api/work-orders/{id}/steps/{sid} dispatcher+ Edit step
PUT /api/work-orders/{id}/steps/reorder dispatcher+ Bulk reorder
POST /api/work-orders/{id}/steps/{sid}/complete field_tech+ Check off
POST /api/work-orders/{id}/steps/{sid}/uncomplete dispatcher+ Undo check
DELETE /api/work-orders/{id}/steps/{sid} dispatcher+ Delete step
GET /api/work-orders/{id}/resources viewer+ List assignments
POST /api/work-orders/{id}/resources dispatcher+ Assign resource
DELETE /api/work-orders/{id}/resources/{rid} dispatcher+ Remove assignment
GET /api/work-orders/{id}/attachments viewer+ List photos/files
POST /api/work-orders/{id}/attachments field_tech+ Upload photo/file
PUT /api/work-orders/{id}/attachments/{aid} field_tech+ Update caption/phase
DELETE /api/work-orders/{id}/attachments/{aid} dispatcher+ Delete attachment
GET /api/work-orders/{id}/accounting viewer+ Get accounting codes
PUT /api/work-orders/{id}/accounting dispatcher+ Upsert accounting codes
GET /api/work-orders/{id}/activity viewer+ Audit log feed
GET /api/work-orders/{id}/location viewer+ Location data
PUT /api/work-orders/{id}/location dispatcher+ Update location

Registries (Master Lists)

Method Path Description
GET /api/registry/people List people (search, active filter)
POST /api/registry/people Create person
PUT /api/registry/people/{id} Update person
DELETE /api/registry/people/{id} Deactivate person
GET/POST/PUT/DELETE /api/registry/vehicles Same CRUD for vehicles
GET/POST/PUT/DELETE /api/registry/equipment Same CRUD for equipment
GET/POST/PUT/DELETE /api/registry/materials Same CRUD for materials

Users & Notifications

Method Path Role Description
GET /api/users admin List all users
POST /api/users admin Create user
PUT /api/users/{id} admin Update user
DELETE /api/users/{id} admin Deactivate
GET /api/users/me any Own profile
PUT /api/users/me any Update own profile
PUT /api/users/me/password any Change password
GET /api/notifications any List notifications
PUT /api/notifications/{id}/read any Mark read
PUT /api/notifications/read-all any Mark all read

Reports & Dashboard

Method Path Role Description
GET /api/dashboard viewer+ All KPIs in one call
GET /api/reports/summary viewer+ WOs by status/priority
GET /api/reports/by-cost-center dispatcher+ Cost center breakdown
GET /api/reports/by-resource dispatcher+ Resource utilization
GET /api/reports/overdue dispatcher+ Overdue WOs
GET /api/reports/export dispatcher+ CSV download

Database Schema

Full Table List

Table Purpose
work_orders Core WO records
wo_steps Ordered checklist steps
wo_resources Resource assignments (polymorphic)
wo_attachments Photos and file uploads
wo_accounting GL/cost center/WBS codes
wo_audit_log All change events
wo_notifications User notification records
resource_people People master list
resource_vehicles Vehicle master list
resource_equipment Equipment master list
resource_materials Material/inventory master list
users App users with roles

Key Patterns

  • Soft deletes: deleted_at DATETIME2 NULL — never hard delete, always filter WHERE deleted_at IS NULL
  • Audit timestamps: every table has created_at, updated_at (auto via DEFAULT GETUTCDATE())
  • Polymorphic parent: parent_type NVARCHAR(50), parent_id INT — handles any parent record type
  • Named params only: never string-concatenate SQL — always use @param_name or @p1

Conventions

CLAUDE.md Content (create this file in project root)

# Work Order System — Claude Code Conventions

## Frontend
- ALL frontend JavaScript module files use `.mjs` extension — no exceptions
- No build step, no npm for frontend — pure ES modules via <script type="module">
- CDN dependencies: Lucide (icons), Leaflet (maps), Chart.js (charts), Google Fonts
- Always use CSS custom property vars — never hardcode hex colors
- Component pattern: Shadow DOM, #private class fields, custom events with namespaces
- Custom event naming: colon-namespaced — 'wo:select', 'steps:updated', 'auth:expired'
- Imports always include .mjs extension: import { api } from '../lib/api.mjs'

## Go Backend
- Package names: lowercase single word (handlers, repository, model, service)
- Errors: always wrap with context — fmt.Errorf("repo.Create: %w", err)
- DB queries: named params only — @param_name, never string concat
- HTTP errors: always JSON — {"error": "message"} with correct status code
- Logging: use log/slog with structured key-value pairs
- Response envelope: { data: ..., meta: {...}, error: null }

## Database
- Soft deletes only — deleted_at column, filter WHERE deleted_at IS NULL
- Every table: created_at, updated_at with DEFAULT GETUTCDATE()
- Polymorphic refs: parent_type + parent_id columns
- All SQL in repository layer — no raw SQL in handlers or service

## Git
- Branches: feature/*, fix/*, chore/*
- Commit messages: conventional commits (feat:, fix:, chore:, docs:)

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
    golang.org/x/crypto              latest    // bcrypt
    github.com/disintegration/imaging latest   // thumbnail generation
)

Frontend CDN Deps (loaded in index.html)

<!-- Fonts -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">

<!-- Icons — Lucide (tree-shakeable, no API key) -->
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>

<!-- Maps — Leaflet (no API key) -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9/dist/leaflet.css">
<script src="https://unpkg.com/leaflet@1.9/dist/leaflet.js"></script>

<!-- Charts — Chart.js -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>

Start with Phase 1 in order. Each checkbox is one commit. Don't skip CLAUDE.md — it must exist before any code is written.


Work Order System — Technical Build Plan

Stack: Go (backend API) · Pure JavaScript + Web Components (frontend) · SQL Server (MSSQL) · Docker


Table of Contents

  1. Architecture Overview
  2. Project Structure
  3. Database Schema
  4. Go Backend
  5. REST API Design
  6. Frontend — Web Components
  7. File & Photo Handling
  8. Maps & Location
  9. Authentication & Authorization
  10. Docker Deployment
  11. Phased Build Plan
  12. Development Conventions

1. Architecture Overview

┌─────────────────────────────────────────────────────────┐
│                     Browser Client                       │
│  ┌────────────────────────────────────────────────────┐ │
│  │          Pure JS + Web Components SPA              │ │
│  │  <wo-list>  <wo-form>  <wo-map>  <wo-checklist>   │ │
│  └───────────────────┬────────────────────────────────┘ │
└──────────────────────│──────────────────────────────────┘
                       │ REST/JSON over HTTPS
┌──────────────────────▼──────────────────────────────────┐
│                    Go API Server                         │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐              │
│  │  Router  │  │ Handlers │  │  Service │              │
│  │  (chi)   │  │          │  │  Layer   │              │
│  └──────────┘  └──────────┘  └──────────┘              │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐              │
│  │   Auth   │  │  Upload  │  │  Spatial │              │
│  │  (JWT)   │  │ Handler  │  │  Utils   │              │
│  └──────────┘  └──────────┘  └──────────┘              │
└──────────────────────┬──────────────────────────────────┘
                       │ sqlx / database/sql
┌──────────────────────▼──────────────────────────────────┐
│              SQL Server (MSSQL)                          │
│  work_orders · wo_resources · wo_steps · wo_attachments  │
│  wo_accounting · wo_parents · users · resource_registry  │
└─────────────────────────────────────────────────────────┘

Key decisions:

  • No framework on the frontend — Custom Elements v1, Shadow DOM, ES Modules. Works in all modern browsers with zero build step.
  • chi router for Go — lightweight, idiomatic, stdlib-compatible middleware.
  • sqlx for DB access — thin wrapper over database/sql, named params, struct scanning.
  • SQL Server — matches your existing infrastructure; geography type for spatial data.
  • Multipart uploads stored to local volume (or swap for Azure Blob / S3 later).
  • JWT stateless auth — fits well with a SPA + API split.

2. Project Structure

workorder/
├── cmd/
│   └── server/
│       └── main.go              # Entry point, wires everything together
├── internal/
│   ├── api/
│   │   ├── router.go            # chi router setup, middleware chain
│   │   ├── middleware.go        # JWT, CORS, logging, recovery
│   │   └── handlers/
│   │       ├── workorder.go     # WO CRUD handlers
│   │       ├── resource.go      # People / vehicle / equipment assignment
│   │       ├── step.go          # Checklist steps
│   │       ├── attachment.go    # Photo & file upload
│   │       ├── accounting.go    # Accounting code endpoints
│   │       ├── parent.go        # Parent record linking (polymorphic)
│   │       └── auth.go          # Login / token refresh
│   ├── service/
│   │   ├── workorder.go         # Business logic layer
│   │   ├── resource.go
│   │   ├── notification.go      # Email/push when WO assigned
│   │   └── spatial.go           # Geocoding, distance calc
│   ├── repository/
│   │   ├── workorder.go         # SQL queries — work orders
│   │   ├── resource.go          # SQL queries — resources
│   │   ├── step.go
│   │   ├── attachment.go
│   │   └── db.go                # sqlx connection pool setup
│   ├── model/
│   │   ├── workorder.go         # Structs: WorkOrder, WorkOrderSummary
│   │   ├── resource.go          # Person, Vehicle, Equipment, Material
│   │   ├── step.go              # Step, StepCompletion
│   │   ├── attachment.go        # Attachment
│   │   ├── accounting.go        # AccountingCode
│   │   └── auth.go              # User, Claims
│   └── config/
│       └── config.go            # Env-based config (DB DSN, JWT secret, upload path)
├── web/                         # Frontend — served as static files by Go
│   ├── index.html               # Shell — loads the app component
│   ├── app.mjs                   # <app-root> component, client-side router
│   ├── components/
│   │   ├── wo-list.mjs           # Work order list / search
│   │   ├── wo-form.mjs           # Create / edit work order
│   │   ├── wo-detail.mjs         # Read-only detail view
│   │   ├── wo-checklist.mjs      # Step checklist with check-off
│   │   ├── wo-resource-panel.mjs # Assign people, vehicles, equipment
│   │   ├── wo-photo-panel.mjs    # Photo capture and gallery
│   │   ├── wo-map.mjs            # Embedded map + directions
│   │   ├── wo-accounting.mjs     # Accounting code fields
│   │   ├── wo-status-badge.mjs   # Status pill (reusable)
│   │   └── wo-timeline.mjs       # Activity / audit log
│   ├── lib/
│   │   ├── api.mjs               # Fetch wrapper, auth header injection
│   │   ├── router.mjs            # Tiny hash/history router
│   │   ├── store.mjs             # Lightweight reactive state (no Redux)
│   │   └── utils.mjs             # Formatters, validators
│   └── styles/
│       ├── global.css           # CSS custom properties (design tokens)
│       └── reset.css
├── uploads/                     # Stored attachments (bind-mounted in Docker)
├── migrations/
│   ├── 001_initial_schema.sql
│   ├── 002_resource_registry.sql
│   └── 003_spatial_fields.sql
├── docker-compose.yml
├── Dockerfile
└── .env.example

3. Database Schema

Core Tables

-- ── Work Orders ───────────────────────────────────────────────────────────────
CREATE TABLE work_orders (
    id              INT IDENTITY PRIMARY KEY,
    wo_number       AS ('WO-' + RIGHT('000000' + CAST(id AS VARCHAR), 6)) PERSISTED,
    title           NVARCHAR(200)  NOT NULL,
    description     NVARCHAR(MAX),
    instructions    NVARCHAR(MAX),           -- Rich text / markdown
    status          NVARCHAR(30)   NOT NULL DEFAULT 'draft',
                    -- draft | assigned | scheduled | in_progress | pending_review | closed
    priority        NVARCHAR(10)   NOT NULL DEFAULT 'normal',
                    -- low | normal | high | urgent
    scheduled_start DATETIME2,
    scheduled_end   DATETIME2,
    actual_start    DATETIME2,
    actual_end      DATETIME2,
    -- Location
    site_name       NVARCHAR(200),
    address         NVARCHAR(400),
    lat             DECIMAL(10,7),
    lng             DECIMAL(10,7),
    access_notes    NVARCHAR(MAX),           -- Gate codes, access roads
    -- Polymorphic parent
    parent_type     NVARCHAR(50),            -- 'project' | 'ticket' | 'service_order' | NULL
    parent_id       INT,
    -- Audit
    created_by      INT            NOT NULL,
    created_at      DATETIME2      NOT NULL DEFAULT GETUTCDATE(),
    updated_at      DATETIME2      NOT NULL DEFAULT GETUTCDATE(),
    closed_at       DATETIME2,
    closed_by       INT,
    CONSTRAINT chk_wo_status   CHECK (status   IN ('draft','assigned','scheduled','in_progress','pending_review','closed')),
    CONSTRAINT chk_wo_priority CHECK (priority IN ('low','normal','high','urgent'))
);

-- ── Resource Registry (master lists) ─────────────────────────────────────────
CREATE TABLE resource_people (
    id          INT IDENTITY PRIMARY KEY,
    name        NVARCHAR(100) NOT NULL,
    role        NVARCHAR(100),
    email       NVARCHAR(200),
    phone       NVARCHAR(30),
    active      BIT NOT NULL DEFAULT 1
);

CREATE TABLE resource_vehicles (
    id          INT IDENTITY PRIMARY KEY,
    unit_number NVARCHAR(50)  NOT NULL,
    description NVARCHAR(200),
    vehicle_type NVARCHAR(100),
    active      BIT NOT NULL DEFAULT 1
);

CREATE TABLE resource_equipment (
    id          INT IDENTITY PRIMARY KEY,
    name        NVARCHAR(200) NOT NULL,
    asset_tag   NVARCHAR(100),
    category    NVARCHAR(100),
    active      BIT NOT NULL DEFAULT 1
);

CREATE TABLE resource_materials (
    id          INT IDENTITY PRIMARY KEY,
    name        NVARCHAR(200) NOT NULL,
    unit        NVARCHAR(30),               -- 'ft', 'each', 'box'
    part_number NVARCHAR(100),
    active      BIT NOT NULL DEFAULT 1
);

-- ── Work Order Resource Assignments ───────────────────────────────────────────
CREATE TABLE wo_resources (
    id              INT IDENTITY PRIMARY KEY,
    wo_id           INT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE,
    resource_type   NVARCHAR(20) NOT NULL,  -- 'person' | 'vehicle' | 'equipment' | 'material'
    resource_id     INT NOT NULL,
    quantity        DECIMAL(10,2),          -- For materials
    notes           NVARCHAR(500),
    assigned_at     DATETIME2 NOT NULL DEFAULT GETUTCDATE(),
    CONSTRAINT chk_resource_type CHECK (resource_type IN ('person','vehicle','equipment','material'))
);

-- ── Checklist Steps ───────────────────────────────────────────────────────────
CREATE TABLE wo_steps (
    id              INT IDENTITY PRIMARY KEY,
    wo_id           INT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE,
    step_order      INT NOT NULL,
    title           NVARCHAR(200) NOT NULL,
    description     NVARCHAR(MAX),
    required        BIT NOT NULL DEFAULT 1,
    completed       BIT NOT NULL DEFAULT 0,
    completed_by    INT,
    completed_at    DATETIME2,
    notes           NVARCHAR(MAX)           -- Field notes added at completion
);

-- ── Attachments (photos, docs) ────────────────────────────────────────────────
CREATE TABLE wo_attachments (
    id              INT IDENTITY PRIMARY KEY,
    wo_id           INT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE,
    step_id         INT REFERENCES wo_steps(id),   -- Optional: attach to a specific step
    file_name       NVARCHAR(500) NOT NULL,
    file_path       NVARCHAR(1000) NOT NULL,        -- Relative path under /uploads
    file_type       NVARCHAR(100),                  -- MIME type
    file_size       BIGINT,
    caption         NVARCHAR(500),
    phase           NVARCHAR(20),                   -- 'before' | 'during' | 'after'
    lat             DECIMAL(10,7),                  -- Geo-tag from EXIF or device
    lng             DECIMAL(10,7),
    uploaded_by     INT NOT NULL,
    uploaded_at     DATETIME2 NOT NULL DEFAULT GETUTCDATE()
);

-- ── Accounting Codes ──────────────────────────────────────────────────────────
CREATE TABLE wo_accounting (
    id              INT IDENTITY PRIMARY KEY,
    wo_id           INT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE,
    code_type       NVARCHAR(50) NOT NULL,   -- 'gl_account' | 'cost_center' | 'wbs' | 'billing_ref'
    code_value      NVARCHAR(200) NOT NULL,
    description     NVARCHAR(500),
    CONSTRAINT uq_wo_accounting UNIQUE (wo_id, code_type)
);

-- ── Audit Log ─────────────────────────────────────────────────────────────────
CREATE TABLE wo_audit_log (
    id              INT IDENTITY PRIMARY KEY,
    wo_id           INT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE,
    action          NVARCHAR(100) NOT NULL,
    old_value       NVARCHAR(MAX),
    new_value       NVARCHAR(MAX),
    performed_by    INT NOT NULL,
    performed_at    DATETIME2 NOT NULL DEFAULT GETUTCDATE()
);

-- ── Indexes ───────────────────────────────────────────────────────────────────
CREATE INDEX ix_wo_status       ON work_orders (status);
CREATE INDEX ix_wo_parent       ON work_orders (parent_type, parent_id);
CREATE INDEX ix_wo_scheduled    ON work_orders (scheduled_start);
CREATE INDEX ix_wo_resources    ON wo_resources (wo_id, resource_type);
CREATE INDEX ix_wo_attachments  ON wo_attachments (wo_id);

4. Go Backend

cmd/server/main.go

package main

import (
    "log"
    "net/http"

    "github.com/yourorg/workorder/internal/api"
    "github.com/yourorg/workorder/internal/config"
    "github.com/yourorg/workorder/internal/repository"
)

func main() {
    cfg := config.Load()         // reads .env / environment variables

    db, err := repository.Connect(cfg.DBDSN)
    if err != nil {
        log.Fatalf("db connect: %v", err)
    }
    defer db.Close()

    r := api.NewRouter(cfg, db)

    log.Printf("listening on %s", cfg.Addr)
    if err := http.ListenAndServe(cfg.Addr, r); err != nil {
        log.Fatal(err)
    }
}

internal/config/config.go

package config

import "os"

type Config struct {
    Addr       string
    DBDSN      string
    JWTSecret  string
    UploadPath string
    BaseURL    string
}

func Load() *Config {
    return &Config{
        Addr:       env("ADDR", ":8080"),
        DBDSN:      env("DB_DSN", ""),
        JWTSecret:  env("JWT_SECRET", "change-me"),
        UploadPath: env("UPLOAD_PATH", "./uploads"),
        BaseURL:    env("BASE_URL", "http://localhost:8080"),
    }
}

func env(key, fallback string) string {
    if v := os.Getenv(key); v != "" {
        return v
    }
    return fallback
}

internal/api/router.go

package api

import (
    "net/http"

    "github.com/go-chi/chi/v5"
    "github.com/go-chi/chi/v5/middleware"
    "github.com/yourorg/workorder/internal/api/handlers"
    mw "github.com/yourorg/workorder/internal/api/middleware"
    "github.com/yourorg/workorder/internal/config"
    "github.com/jmoiron/sqlx"
)

func NewRouter(cfg *config.Config, db *sqlx.DB) http.Handler {
    r := chi.NewRouter()

    // Global middleware
    r.Use(middleware.Logger)
    r.Use(middleware.Recoverer)
    r.Use(middleware.RealIP)
    r.Use(mw.CORS)

    // Serve frontend static files
    r.Handle("/*", http.FileServer(http.Dir("./web")))

    // Auth routes (no JWT required)
    r.Post("/api/auth/login",   handlers.NewAuthHandler(cfg, db).Login)
    r.Post("/api/auth/refresh", handlers.NewAuthHandler(cfg, db).Refresh)

    // Protected API routes
    r.Group(func(r chi.Router) {
        r.Use(mw.JWT(cfg.JWTSecret))

        wo := handlers.NewWorkOrderHandler(db)
        r.Get("/api/work-orders",          wo.List)
        r.Post("/api/work-orders",         wo.Create)
        r.Get("/api/work-orders/{id}",     wo.Get)
        r.Put("/api/work-orders/{id}",     wo.Update)
        r.Delete("/api/work-orders/{id}",  wo.Delete)
        r.Put("/api/work-orders/{id}/status", wo.UpdateStatus)

        res := handlers.NewResourceHandler(db)
        r.Get("/api/work-orders/{id}/resources",        res.List)
        r.Post("/api/work-orders/{id}/resources",       res.Assign)
        r.Delete("/api/work-orders/{id}/resources/{rid}", res.Remove)

        step := handlers.NewStepHandler(db)
        r.Get("/api/work-orders/{id}/steps",             step.List)
        r.Post("/api/work-orders/{id}/steps",            step.Create)
        r.Put("/api/work-orders/{id}/steps/{sid}",       step.Update)
        r.Post("/api/work-orders/{id}/steps/{sid}/complete", step.Complete)
        r.Delete("/api/work-orders/{id}/steps/{sid}",    step.Delete)

        att := handlers.NewAttachmentHandler(cfg, db)
        r.Get("/api/work-orders/{id}/attachments",        att.List)
        r.Post("/api/work-orders/{id}/attachments",       att.Upload)
        r.Delete("/api/work-orders/{id}/attachments/{aid}", att.Delete)
        r.Handle("/uploads/*", http.StripPrefix("/uploads/", http.FileServer(http.Dir(cfg.UploadPath))))

        acc := handlers.NewAccountingHandler(db)
        r.Get("/api/work-orders/{id}/accounting",    acc.Get)
        r.Put("/api/work-orders/{id}/accounting",    acc.Upsert)

        // Resource registries (master lists for assignment pickers)
        r.Get("/api/registry/people",    handlers.RegistryPeople(db))
        r.Get("/api/registry/vehicles",  handlers.RegistryVehicles(db))
        r.Get("/api/registry/equipment", handlers.RegistryEquipment(db))
        r.Get("/api/registry/materials", handlers.RegistryMaterials(db))
    })

    return r
}

internal/model/workorder.go

package model

import "time"

type WorkOrder struct {
    ID             int        `db:"id"             json:"id"`
    WONumber       string     `db:"wo_number"      json:"wo_number"`
    Title          string     `db:"title"          json:"title"`
    Description    string     `db:"description"    json:"description"`
    Instructions   string     `db:"instructions"   json:"instructions"`
    Status         string     `db:"status"         json:"status"`
    Priority       string     `db:"priority"       json:"priority"`
    ScheduledStart *time.Time `db:"scheduled_start" json:"scheduled_start"`
    ScheduledEnd   *time.Time `db:"scheduled_end"   json:"scheduled_end"`
    ActualStart    *time.Time `db:"actual_start"   json:"actual_start"`
    ActualEnd      *time.Time `db:"actual_end"     json:"actual_end"`
    SiteName       string     `db:"site_name"      json:"site_name"`
    Address        string     `db:"address"        json:"address"`
    Lat            *float64   `db:"lat"            json:"lat"`
    Lng            *float64   `db:"lng"            json:"lng"`
    AccessNotes    string     `db:"access_notes"   json:"access_notes"`
    ParentType     *string    `db:"parent_type"    json:"parent_type"`
    ParentID       *int       `db:"parent_id"      json:"parent_id"`
    CreatedBy      int        `db:"created_by"     json:"created_by"`
    CreatedAt      time.Time  `db:"created_at"     json:"created_at"`
    UpdatedAt      time.Time  `db:"updated_at"     json:"updated_at"`
}

// Enriched view returned by GET /work-orders/{id}
type WorkOrderDetail struct {
    WorkOrder
    Resources   []AssignedResource `json:"resources"`
    Steps       []Step             `json:"steps"`
    Attachments []Attachment       `json:"attachments"`
    Accounting  []AccountingCode   `json:"accounting"`
}

type WorkOrderListItem struct {
    ID           int        `db:"id"            json:"id"`
    WONumber     string     `db:"wo_number"     json:"wo_number"`
    Title        string     `db:"title"         json:"title"`
    Status       string     `db:"status"        json:"status"`
    Priority     string     `db:"priority"      json:"priority"`
    SiteName     string     `db:"site_name"     json:"site_name"`
    ScheduledStart *time.Time `db:"scheduled_start" json:"scheduled_start"`
    StepCount    int        `db:"step_count"    json:"step_count"`
    StepsDone    int        `db:"steps_done"    json:"steps_done"`
    PhotoCount   int        `db:"photo_count"   json:"photo_count"`
}

internal/repository/workorder.go

package repository

import (
    "context"
    "github.com/jmoiron/sqlx"
    "github.com/yourorg/workorder/internal/model"
)

type WorkOrderRepo struct{ db *sqlx.DB }

func NewWorkOrderRepo(db *sqlx.DB) *WorkOrderRepo {
    return &WorkOrderRepo{db: db}
}

func (r *WorkOrderRepo) List(ctx context.Context, status, search string) ([]model.WorkOrderListItem, error) {
    query := `
        SELECT
            wo.id, wo.wo_number, wo.title, wo.status, wo.priority,
            wo.site_name, wo.scheduled_start,
            COUNT(s.id)                             AS step_count,
            SUM(CAST(s.completed AS INT))           AS steps_done,
            COUNT(a.id)                             AS photo_count
        FROM work_orders wo
        LEFT JOIN wo_steps       s ON s.wo_id = wo.id
        LEFT JOIN wo_attachments a ON a.wo_id = wo.id
        WHERE (@status = '' OR wo.status = @status)
          AND (@search = '' OR wo.title LIKE '%' + @search + '%'
                            OR wo.wo_number LIKE '%' + @search + '%'
                            OR wo.site_name LIKE '%' + @search + '%')
        GROUP BY wo.id, wo.wo_number, wo.title, wo.status,
                 wo.priority, wo.site_name, wo.scheduled_start
        ORDER BY wo.scheduled_start DESC, wo.id DESC`

    rows := []model.WorkOrderListItem{}
    err := r.db.SelectContext(ctx, &rows, query,
        sqlx.Named("status", status),
        sqlx.Named("search", search),
    )
    return rows, err
}

func (r *WorkOrderRepo) GetByID(ctx context.Context, id int) (*model.WorkOrder, error) {
    var wo model.WorkOrder
    err := r.db.GetContext(ctx, &wo,
        `SELECT * FROM work_orders WHERE id = @p1`, id)
    if err != nil {
        return nil, err
    }
    return &wo, nil
}

func (r *WorkOrderRepo) Create(ctx context.Context, wo *model.WorkOrder) (int, error) {
    var id int
    err := r.db.QueryRowContext(ctx, `
        INSERT INTO work_orders
            (title, description, instructions, status, priority,
             scheduled_start, scheduled_end, site_name, address, lat, lng,
             access_notes, parent_type, parent_id, created_by, updated_at)
        OUTPUT INSERTED.id
        VALUES
            (@title, @description, @instructions, @status, @priority,
             @scheduled_start, @scheduled_end, @site_name, @address, @lat, @lng,
             @access_notes, @parent_type, @parent_id, @created_by, GETUTCDATE())`,
        sqlx.Named("title", wo.Title),
        // ... remaining named params
    ).Scan(&id)
    return id, err
}

func (r *WorkOrderRepo) UpdateStatus(ctx context.Context, id int, status string, userID int) error {
    _, err := r.db.ExecContext(ctx, `
        UPDATE work_orders
        SET status = @p1, updated_at = GETUTCDATE(),
            actual_start = CASE WHEN @p1 = 'in_progress' AND actual_start IS NULL
                                THEN GETUTCDATE() ELSE actual_start END,
            actual_end   = CASE WHEN @p1 = 'closed' THEN GETUTCDATE() ELSE actual_end END,
            closed_at    = CASE WHEN @p1 = 'closed' THEN GETUTCDATE() ELSE closed_at END,
            closed_by    = CASE WHEN @p1 = 'closed' THEN @p2 ELSE closed_by END
        WHERE id = @p3`, status, userID, id)
    return err
}

5. REST API Design

Method Path Description
POST /api/auth/login Login, returns JWT
GET /api/work-orders?status=&search=&page= Paginated list
POST /api/work-orders Create work order
GET /api/work-orders/{id} Full detail (WO + resources + steps + photos + accounting)
PUT /api/work-orders/{id} Update work order fields
PUT /api/work-orders/{id}/status Status transition with audit
GET /api/work-orders/{id}/resources List assigned resources
POST /api/work-orders/{id}/resources Assign a resource
DELETE /api/work-orders/{id}/resources/{rid} Remove assignment
GET /api/work-orders/{id}/steps List checklist steps
POST /api/work-orders/{id}/steps Add a step
PUT /api/work-orders/{id}/steps/{sid} Edit step
POST /api/work-orders/{id}/steps/{sid}/complete Check off step
POST /api/work-orders/{id}/attachments Upload photo/file (multipart)
GET /api/work-orders/{id}/attachments List attachments
PUT /api/work-orders/{id}/accounting Upsert accounting codes
GET /api/registry/people Lookup: people for assignment picker
GET /api/registry/vehicles Lookup: vehicles
GET /api/registry/equipment Lookup: equipment
GET /api/registry/materials Lookup: materials

Standard Response Envelope

{
  "data": { ... },
  "meta": { "page": 1, "per_page": 25, "total": 142 },
  "error": null
}

Status Transition Rules (enforced server-side)

draft → assigned → scheduled → in_progress → pending_review → closed
                                           ↘ (re-open)  ↗

6. Frontend — Web Components

Design Principles

  • No build step — plain <script type="module"> imports. Works directly in browser.
  • Shadow DOM per component — style isolation, no CSS leakage.
  • Custom events for cross-component communication — no shared global state pollution.
  • api.mjs is the single HTTP layer — all fetch calls go through it.

web/lib/api.mjs

const BASE = '/api';

let _token = localStorage.getItem('wo_token') || '';

export function setToken(t) {
  _token = t;
  localStorage.setItem('wo_token', t);
}

async function request(method, path, body, isFormData = false) {
  const headers = { Authorization: `Bearer ${_token}` };
  if (!isFormData) headers['Content-Type'] = 'application/json';

  const res = await fetch(BASE + path, {
    method,
    headers,
    body: body
      ? (isFormData ? body : JSON.stringify(body))
      : undefined,
  });

  if (res.status === 401) {
    // Token expired — redirect to login
    window.dispatchEvent(new CustomEvent('auth:expired'));
    return;
  }

  const json = await res.mjson();
  if (!res.ok) throw new Error(json.error || 'Request failed');
  return json.data;
}

export const api = {
  get:    (path)         => request('GET', path),
  post:   (path, body)   => request('POST', path, body),
  put:    (path, body)   => request('PUT', path, body),
  delete: (path)         => request('DELETE', path),
  upload: (path, form)   => request('POST', path, form, true),
};

web/lib/router.mjs

// Hash-based router — no server config needed
class Router {
  #routes = [];

  on(pattern, handler) {
    this.#routes.push({ pattern: new URLPattern({ pathname: pattern }), handler });
    return this;
  }

  start() {
    const dispatch = () => {
      const path = location.hash.slice(1) || '/';
      for (const { pattern, handler } of this.#routes) {
        const m = pattern.exec({ pathname: path });
        if (m) { handler(m.pathname.groups); return; }
      }
    };
    window.addEventListener('hashchange', dispatch);
    dispatch();
  }
}

export const router = new Router();

web/components/wo-list.mjs

import { api } from '../lib/api.mjs';

class WoList extends HTMLElement {
  #data = [];
  #status = '';

  connectedCallback() {
    this.attachShadow({ mode: 'open' });
    this.#render();
    this.#load();
  }

  async #load() {
    const status = this.getAttribute('filter-status') || '';
    this.#data = await api.get(`/work-orders?status=${status}`);
    this.#render();
  }

  #render() {
    this.shadowRoot.innerHTML = `
      <style>
        :host { display: block; }
        .grid { display: grid; gap: 0.75rem; }
        .card {
          background: var(--surface, #fff);
          border: 1px solid var(--border, #e2e8f0);
          border-radius: 8px;
          padding: 1rem 1.25rem;
          cursor: pointer;
          display: grid;
          grid-template-columns: 1fr auto;
          gap: 0.25rem 1rem;
          transition: box-shadow .15s;
        }
        .card:hover { box-shadow: 0 4px 12px rgba(0,0,0,.08); }
        .wo-number { font-size: .75rem; color: var(--muted); }
        .title { font-weight: 600; color: var(--text); }
        .progress { font-size: .8rem; color: var(--muted); }
      </style>
      <div class="grid">
        ${this.#data.map(wo => `
          <div class="card" data-id="${wo.id}">
            <div>
              <div class="wo-number">${wo.wo_number}</div>
              <div class="title">${wo.title}</div>
              <div class="progress">
                ${wo.site_name}  ·  ${wo.steps_done}/${wo.step_count} steps
              </div>
            </div>
            <wo-status-badge status="${wo.status}"></wo-status-badge>
          </div>
        `).join('')}
      </div>`;

    this.shadowRoot.querySelectorAll('.card').forEach(card => {
      card.addEventListener('click', () => {
        this.dispatchEvent(new CustomEvent('wo:select',
          { detail: { id: card.dataset.id }, bubbles: true, composed: true }));
      });
    });
  }
}

customElements.define('wo-list', WoList);

web/components/wo-resource-panel.mjs

import { api } from '../lib/api.mjs';

class WoResourcePanel extends HTMLElement {
  #woId = null;
  #resources = [];
  #registry = { people: [], vehicles: [], equipment: [], materials: [] };

  static get observedAttributes() { return ['wo-id']; }

  attributeChangedCallback(name, _, val) {
    if (name === 'wo-id') { this.#woId = val; this.#load(); }
  }

  connectedCallback() {
    this.attachShadow({ mode: 'open' });
    if (this.#woId) this.#load();
  }

  async #load() {
    const [resources, people, vehicles, equipment, materials] = await Promise.all([
      api.get(`/work-orders/${this.#woId}/resources`),
      api.get('/registry/people'),
      api.get('/registry/vehicles'),
      api.get('/registry/equipment'),
      api.get('/registry/materials'),
    ]);
    this.#resources = resources;
    this.#registry  = { people, vehicles, equipment, materials };
    this.#render();
  }

  async #assign(type, resourceId) {
    await api.post(`/work-orders/${this.#woId}/resources`, {
      resource_type: type, resource_id: Number(resourceId)
    });
    await this.#load();
  }

  async #remove(assignmentId) {
    await api.delete(`/work-orders/${this.#woId}/resources/${assignmentId}`);
    await this.#load();
  }

  #sectionHTML(label, type, list) {
    const assigned = this.#resources.filter(r => r.resource_type === type);
    const available = list.filter(item =>
      !assigned.some(a => a.resource_id === item.id));

    return `
      <section>
        <h3>${label}</h3>
        <ul>
          ${assigned.map(a => `
            <li>${a.name}
              <button class="remove-btn" data-id="${a.id}">×</button>
            </li>`).join('')}
        </ul>
        <select class="add-select" data-type="${type}">
          <option value="">Add ${label.slice(0,-1)}...</option>
          ${available.map(item =>
            `<option value="${item.id}">${item.name ?? item.unit_number}</option>`
          ).join('')}
        </select>
      </section>`;
  }

  #render() {
    const { people, vehicles, equipment, materials } = this.#registry;
    this.shadowRoot.innerHTML = `
      <style>
        :host { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
        section { background: var(--surface); border-radius: 8px; padding: 1rem; }
        h3 { margin: 0 0 .5rem; font-size: .9rem; text-transform: uppercase;
             letter-spacing: .05em; color: var(--muted); }
        ul { list-style: none; padding: 0; margin: 0 0 .5rem; }
        li { display: flex; justify-content: space-between; padding: .25rem 0;
             border-bottom: 1px solid var(--border); font-size: .9rem; }
        select { width: 100%; padding: .4rem; border-radius: 6px;
                 border: 1px solid var(--border); }
        .remove-btn { background: none; border: none; cursor: pointer;
                      color: var(--danger, #c0392b); font-size: 1.1rem; }
      </style>
      ${this.#sectionHTML('People',    'person',    people)}
      ${this.#sectionHTML('Vehicles',  'vehicle',   vehicles)}
      ${this.#sectionHTML('Equipment', 'equipment', equipment)}
      ${this.#sectionHTML('Materials', 'material',  materials)}`;

    this.shadowRoot.querySelectorAll('.add-select').forEach(sel => {
      sel.addEventListener('change', e => {
        if (e.target.value) this.#assign(e.target.dataset.type, e.target.value);
      });
    });
    this.shadowRoot.querySelectorAll('.remove-btn').forEach(btn => {
      btn.addEventListener('click', () => this.#remove(btn.dataset.id));
    });
  }
}

customElements.define('wo-resource-panel', WoResourcePanel);

web/components/wo-checklist.mjs

import { api } from '../lib/api.mjs';

class WoChecklist extends HTMLElement {
  #woId = null;
  #steps = [];

  static get observedAttributes() { return ['wo-id']; }
  attributeChangedCallback(_, __, val) { this.#woId = val; this.#load(); }
  connectedCallback() { this.attachShadow({ mode: 'open' }); }

  async #load() {
    this.#steps = await api.get(`/work-orders/${this.#woId}/steps`);
    this.#render();
  }

  async #complete(stepId) {
    await api.post(`/work-orders/${this.#woId}/steps/${stepId}/complete`, {});
    await this.#load();
    this.dispatchEvent(new CustomEvent('steps:updated', { bubbles: true, composed: true }));
  }

  #render() {
    const done  = this.#steps.filter(s => s.completed).length;
    const total = this.#steps.length;
    const pct   = total ? Math.round(done / total * 100) : 0;

    this.shadowRoot.innerHTML = `
      <style>
        .progress-bar { height: 6px; background: var(--border); border-radius: 3px; margin-bottom: 1rem; }
        .progress-fill { height: 100%; border-radius: 3px;
                         background: var(--accent, #0a7ea4); width: ${pct}%; transition: width .3s; }
        .step { display: flex; gap: .75rem; align-items: flex-start;
                padding: .75rem 0; border-bottom: 1px solid var(--border); }
        .step.done .step-title { text-decoration: line-through; color: var(--muted); }
        input[type=checkbox] { width: 1.2rem; height: 1.2rem; accent-color: var(--accent); margin-top: .15rem; }
        .step-title { font-weight: 500; }
        .step-desc  { font-size: .85rem; color: var(--muted); margin-top: .2rem; }
        .summary    { font-size: .85rem; color: var(--muted); margin-bottom: .5rem; }
      </style>
      <div class="progress-bar"><div class="progress-fill"></div></div>
      <div class="summary">${done} of ${total} steps complete</div>
      ${this.#steps.map(s => `
        <div class="step ${s.completed ? 'done' : ''}">
          <input type="checkbox" data-id="${s.id}" ${s.completed ? 'checked disabled' : ''}>
          <div>
            <div class="step-title">${s.step_order}. ${s.title}</div>
            ${s.description ? `<div class="step-desc">${s.description}</div>` : ''}
          </div>
        </div>`).join('')}`;

    this.shadowRoot.querySelectorAll('input[type=checkbox]:not([disabled])').forEach(cb => {
      cb.addEventListener('change', () => this.#complete(cb.dataset.id));
    });
  }
}

customElements.define('wo-checklist', WoChecklist);

web/styles/global.css — Design Tokens

:root {
  --navy:    #0d2137;
  --accent:  #0a7ea4;
  --accent-lt: #14b8d4;
  --surface: #ffffff;
  --bg:      #f0f6fa;
  --border:  #e2ebf0;
  --text:    #1a2e3b;
  --muted:   #64748b;
  --danger:  #c0392b;
  --success: #1d9d6c;
  --warning: #e07b39;

  --radius:  8px;
  --shadow:  0 2px 8px rgba(0,0,0,.08);

  font-family: Calibri, 'Segoe UI', system-ui, sans-serif;
  font-size: 16px;
  color: var(--text);
  background: var(--bg);
}

7. File & Photo Handling

Go Upload Handler

func (h *AttachmentHandler) Upload(w http.ResponseWriter, r *http.Request) {
    woID := chi.URLParam(r, "id")
    r.ParseMultipartForm(32 << 20) // 32 MB max

    file, header, err := r.FormFile("file")
    if err != nil { respondError(w, 400, "missing file"); return }
    defer file.Close()

    // Build storage path: uploads/{wo_id}/{uuid}{ext}
    ext     := filepath.Ext(header.Filename)
    fname   := uuid.New().String() + ext
    relPath := filepath.Join(woID, fname)
    absPath := filepath.Join(h.cfg.UploadPath, relPath)

    os.MkdirAll(filepath.Dir(absPath), 0755)
    dst, _ := os.Create(absPath)
    defer dst.Close()
    io.Copy(dst, file)

    // Extract EXIF GPS if it's a JPEG
    lat, lng := extractGPS(absPath)

    // Save record
    att := &model.Attachment{
        WOID:       mustInt(woID),
        FileName:   header.Filename,
        FilePath:   relPath,
        FileType:   header.Header.Get("Content-Type"),
        FileSize:   header.Size,
        Phase:      r.FormValue("phase"),
        Caption:    r.FormValue("caption"),
        Lat:        lat,
        Lng:        lng,
        UploadedBy: userIDFromCtx(r.Context()),
    }
    id, _ := h.repo.Create(r.Context(), att)
    respond(w, 201, map[string]any{"id": id, "url": "/uploads/" + relPath})
}

Frontend Photo Capture (wo-photo-panel.mjs key logic)

// Use camera on mobile devices
async #capturePhoto() {
  const input = document.createElement('input');
  input.type = 'file';
  input.accept = 'image/*';
  input.capture = 'environment';  // rear camera
  input.onchange = async (e) => {
    const file = e.target.files[0];
    const form = new FormData();
    form.append('file', file);
    form.append('phase', this.#phase);       // 'before' | 'during' | 'after'

    // Attach GPS from browser if available
    if (navigator.geolocation) {
      await new Promise(res => navigator.geolocation.getCurrentPosition(pos => {
        form.append('lat', pos.coords.latitude);
        form.append('lng', pos.coords.longitude);
        res();
      }, res));
    }

    await api.upload(`/work-orders/${this.#woId}/attachments`, form);
    await this.#load();
  };
  input.click();
}

8. Maps & Location

  • Leaflet.mjs (open source, no API key required) loaded via CDN for the embedded map.
  • Google Maps Directions API (or OpenRouteService if you want fully open source) for turn-by-turn routing.
  • Coordinates stored as lat/lng decimals in SQL Server. No geography type required unless you add spatial radius queries later.

web/components/wo-map.mjs skeleton

// Leaflet loaded via <script> in index.html
class WoMap extends HTMLElement {
  connectedCallback() {
    this.style.display = 'block';
    this.style.height  = '300px';
    const map = L.map(this).setView([0, 0], 13);
    L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png').addTo(map);

    const lat = parseFloat(this.getAttribute('lat'));
    const lng = parseFloat(this.getAttribute('lng'));
    if (lat && lng) {
      map.setView([lat, lng], 15);
      L.marker([lat, lng]).addTo(map)
        .bindPopup(this.getAttribute('site-name') || 'Work Site').openPopup();
    }
  }
}
customElements.define('wo-map', WoMap);

Directions button

// Opens Google Maps / Apple Maps turn-by-turn in native app on mobile
function openDirections(lat, lng, label) {
  const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
  const url = isIOS
    ? `maps://maps.apple.com/?daddr=${lat},${lng}&dirflg=d`
    : `https://maps.google.com/maps?daddr=${lat},${lng}`;
  window.open(url, '_blank');
}

9. Authentication & Authorization

JWT Middleware (Go)

func JWT(secret string) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            auth := r.Header.Get("Authorization")
            if !strings.HasPrefix(auth, "Bearer ") {
                http.Error(w, `{"error":"unauthorized"}`, 401); return
            }
            token, err := jwt.ParseWithClaims(strings.TrimPrefix(auth, "Bearer "),
                &Claims{}, func(t *jwt.Token) (any, error) {
                    return []byte(secret), nil
                })
            if err != nil || !token.Valid {
                http.Error(w, `{"error":"invalid token"}`, 401); return
            }
            claims := token.Claims.(*Claims)
            ctx := context.WithValue(r.Context(), ctxKeyUser, claims)
            next.ServeHTTP(w, r.WithContext(ctx))
        })
    }
}

Roles to implement

Role Can Do
admin Full CRUD, close any WO, manage registries
dispatcher Create & assign WOs, edit all fields
field_tech View assigned WOs, complete steps, upload photos
viewer Read-only access

10. Docker Deployment

Dockerfile

# ── Build stage ───────────────────────────────────────────
FROM golang:1.23-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o workorder ./cmd/server

# ── Runtime stage ─────────────────────────────────────────
FROM alpine:3.20
WORKDIR /app
RUN apk add --no-cache tzdata ca-certificates
COPY --from=builder /app/workorder .
COPY web/ ./web/

EXPOSE 8080
CMD ["./workorder"]

docker-compose.yml

services:
  api:
    build: .
    ports:
      - "8080:8080"
    environment:
      ADDR:        ":8080"
      DB_DSN:      "sqlserver://sa:${SA_PASSWORD}@mssql:1433?database=workorders"
      JWT_SECRET:  "${JWT_SECRET}"
      UPLOAD_PATH: "/uploads"
    volumes:
      - uploads:/uploads
    depends_on:
      - mssql
    restart: unless-stopped

  mssql:
    image: mcr.microsoft.com/mssql/server:2022-latest
    environment:
      SA_PASSWORD:        "${SA_PASSWORD}"
      ACCEPT_EULA:        "Y"
      MSSQL_PID:          "Developer"
    volumes:
      - mssql_data:/var/opt/mssql
    ports:
      - "1433:1433"    # Remove in production

volumes:
  uploads:
  mssql_data:

If you're connecting to your existing SQL Server instance, just point DB_DSN at it and drop the mssql service.


11. Phased Build Plan

Phase 1 — Foundation (Weeks 14)

Goal: Working CRUD with project linking and status flow.

  • Repo setup: Go module, chi, sqlx, godotenv
  • DB migration: work_orders table + indexes
  • Auth: login endpoint, JWT middleware, user table
  • Work Order CRUD endpoints (list, get, create, update, status)
  • Frontend shell: index.html, router, api.mjs, global CSS
  • <wo-list> — list with search + filter by status
  • <wo-form> — create / edit form with all base fields
  • <wo-status-badge> — reusable status pill
  • Docker Compose working locally
  • Basic role checking (admin vs viewer)

Phase 2 — Field Features (Weeks 58)

Goal: Everything a field crew needs on their phone.

  • DB: wo_steps, wo_resources, resource registry tables
  • Step endpoints + <wo-checklist> with check-off
  • Resource registry endpoints + <wo-resource-panel> picker
  • DB: wo_attachments table
  • Photo upload endpoint with GPS extraction
  • <wo-photo-panel> — capture, gallery, before/during/after phases
  • <wo-map> — Leaflet map + site pin
  • Directions button (native maps deeplink)
  • Mobile-responsive layout (CSS Grid, touch-friendly targets)

Phase 3 — Integrations (Weeks 912)

Goal: Connects to the rest of the system.

  • DB: wo_accounting table
  • <wo-accounting> — GL, cost center, WBS, billing ref fields
  • Parent linking: polymorphic parent_type/parent_id
  • Link WOs to existing Projects (query existing project IDs)
  • Link to Trouble Tickets / Service Orders (when those APIs exist)
  • Audit log table + <wo-timeline> component
  • Email notification on assignment (Go net/smtp or SendGrid)
  • Reporting endpoint: WOs by status, by cost center, by date range

Phase 4 — Optimization (Week 13+)

  • Dashboard <wo-dashboard> — KPI cards + charts (Chart.mjs via CDN)
  • Crew scheduling calendar view
  • Offline support (Service Worker + IndexedDB cache for field use)
  • CSV / Excel export for accounting reconciliation
  • Full-text search across WO title, site, instructions
  • Webhook / API for third-party integrations

12. Development Conventions

Go

Packages:     lowercase, single word  (handlers, repository, model)
Errors:       always wrapped          (fmt.Errorf("repo.Create: %w", err))
HTTP errors:  JSON envelope           {"error": "message"} with correct status code
DB queries:   named params only       @param_name — never string concat
Logging:      log/slog                structured key-value pairs
Tests:        _test.go files          table-driven, httptest.NewRecorder for handlers

JavaScript

Components:   kebab-case custom elements  <wo-form>, <wo-map>
Private:      # prefix for class fields   #data, #load(), #render()
Events:       colon namespaced            'wo:select', 'steps:updated', 'auth:expired'
API calls:    always in the component     no inline fetch(), always via api.mjs
Render:       full innerHTML redraw       simple, no virtual DOM needed at this scale
State:        component-local            lift to app-root only when truly shared

Git Branch Strategy

main          — production-ready, tagged releases
develop       — integration branch
feature/*     — individual features (feature/wo-checklist)
fix/*         — bug fixes

Go Dependencies

// go.mod
require (
    github.com/go-chi/chi/v5         v5.1.0
    github.com/jmoiron/sqlx          v1.3.5
    github.com/microsoft/go-mssqldb  v1.7.1
    github.com/golang-jwt/jwt/v5     v5.2.1
    github.com/google/uuid           v1.6.0
    github.com/joho/godotenv         v1.5.1
)

Frontend Dependencies (CDN, no npm)

<!-- in index.html -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9/dist/leaflet.css">
<script src="https://unpkg.com/leaflet@1.9/dist/leaflet.mjs"></script>
<!-- Chart.mjs added in Phase 4 only -->
<script src="https://cdn.mjsdelivr.net/npm/chart.mjs@4/dist/chart.umd.min.mjs"></script>

Zero build tooling. Zero npm for the frontend. Open in browser and go.

Work Order System — Build TODO

Hand this file to Claude Code. All frontend files use .mjs extensions. Stack: Go · Pure JS + Web Components · SQL Server · Docker


Table of Contents


Design System

Color Palette

:root {
  /* Brand */
  --navy:       #0D2137;   /* Primary dark — sidebar, headers */
  --teal:       #0A7EA4;   /* Primary accent — buttons, links, active states */
  --teal-lt:    #14B8D4;   /* Light accent — hover states, highlights */
  --teal-dk:    #075E7A;   /* Dark accent — pressed states */

  /* Surfaces */
  --bg:         #F0F6FA;   /* Page background */
  --surface:    #FFFFFF;   /* Cards, panels */
  --surface-2:  #E8F0F5;   /* Inset areas, table stripes */
  --sidebar-bg: #0D2137;   /* Sidebar background */
  --sidebar-hover: #153248;
  --sidebar-active: #0A7EA4;

  /* Text */
  --text:       #1A2E3B;   /* Primary text */
  --text-muted: #64748B;   /* Secondary / helper text */
  --text-inv:   #FFFFFF;   /* Text on dark backgrounds */

  /* Semantic */
  --success:    #1D9D6C;
  --warning:    #E07B39;
  --danger:     #C0392B;
  --info:       #0A7EA4;

  /* Borders & Shadows */
  --border:     #D1DDE6;
  --border-lt:  #E8F0F5;
  --shadow-sm:  0 1px 3px rgba(0,0,0,.08);
  --shadow-md:  0 4px 12px rgba(0,0,0,.10);
  --shadow-lg:  0 8px 24px rgba(0,0,0,.12);

  /* Status Pills */
  --status-draft:          #94A3B8;
  --status-assigned:       #0A7EA4;
  --status-scheduled:      #8B5CF6;
  --status-in_progress:    #E07B39;
  --status-pending_review: #D97706;
  --status-closed:         #1D9D6C;

  /* Priority */
  --priority-low:    #64748B;
  --priority-normal: #0A7EA4;
  --priority-high:   #E07B39;
  --priority-urgent: #C0392B;

  /* Spacing */
  --radius-sm:  4px;
  --radius:     8px;
  --radius-lg:  12px;
  --radius-xl:  16px;

  /* Sidebar width */
  --sidebar-w:  260px;
  --sidebar-collapsed: 64px;
}

Typography

Load via <link> in index.html — no build step needed:

<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
:root {
  --font-body: 'Inter', 'Segoe UI', system-ui, sans-serif;
  --font-mono: 'JetBrains Mono', 'Cascadia Code', monospace;

  --text-xs:   0.70rem;   /* 11px — labels, caps */
  --text-sm:   0.813rem;  /* 13px — helper, meta */
  --text-base: 0.938rem;  /* 15px — body */
  --text-md:   1.063rem;  /* 17px — subheadings */
  --text-lg:   1.25rem;   /* 20px — section headers */
  --text-xl:   1.5rem;    /* 24px — page titles */
  --text-2xl:  2rem;      /* 32px — dashboard hero numbers */

  --weight-normal:   400;
  --weight-medium:   500;
  --weight-semibold: 600;
  --weight-bold:     700;
}

Component Design Patterns

  • Cards: white background, var(--border) border, var(--radius) corners, var(--shadow-sm)
  • Buttons: filled primary = --teal, ghost = transparent + --teal border, danger = --danger
  • Inputs: --border border, focus ring --teal 2px outline-offset, --radius-sm corners
  • Tables: header row --surface-2, alternating rows, sticky header on scroll
  • Status pills: colored dot + label, pill shape, light background tint of the status color
  • Icon + label pattern everywhere — use Lucide icons loaded via CDN (no npm)

Icons

<!-- index.html — Lucide icon CDN, no API key, tree-shakeable -->
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>

Call lucide.createIcons() after each render. Use named icons: clipboard-list, users, truck, wrench, map-pin, camera, file-invoice, layout-dashboard, settings, bell, chevron-left, plus, search, filter


Project Structure

workorder/
├── CLAUDE.md                        ← !! READ FIRST — conventions for Claude Code
├── cmd/server/main.go
├── internal/
│   ├── api/
│   │   ├── router.go
│   │   ├── middleware/
│   │   │   ├── auth.go
│   │   │   ├── cors.go
│   │   │   └── logger.go
│   │   └── handlers/
│   │       ├── auth.go
│   │       ├── workorder.go
│   │       ├── step.go
│   │       ├── resource.go
│   │       ├── attachment.go
│   │       ├── accounting.go
│   │       ├── user.go
│   │       ├── registry.go          ← people/vehicle/equipment/material master lists
│   │       ├── dashboard.go
│   │       └── report.go
│   ├── service/
│   │   ├── workorder.go
│   │   ├── notification.go
│   │   ├── spatial.go
│   │   └── export.go               ← CSV/Excel export
│   ├── repository/
│   │   ├── db.go
│   │   ├── workorder.go
│   │   ├── step.go
│   │   ├── resource.go
│   │   ├── attachment.go
│   │   ├── accounting.go
│   │   ├── user.go
│   │   └── registry.go
│   ├── model/
│   │   ├── workorder.go
│   │   ├── step.go
│   │   ├── resource.go
│   │   ├── attachment.go
│   │   ├── accounting.go
│   │   ├── user.go
│   │   └── dashboard.go
│   └── config/config.go
│
├── web/                             ← All frontend — served as static files by Go
│   ├── index.html                   ← App shell, loads fonts, Lucide, Leaflet
│   ├── app.mjs                      ← <app-root> custom element + client router
│   │
│   ├── components/
│   │   ├── layout/
│   │   │   ├── app-sidebar.mjs      ← Left nav — collapsible, mobile drawer
│   │   │   ├── app-topbar.mjs       ← Top bar — breadcrumb, notifications bell, user avatar
│   │   │   ├── app-root.mjs         ← Shell that wires sidebar + topbar + <main>
│   │   │   └── app-mobile-nav.mjs   ← Bottom tab bar for mobile (≤768px)
│   │   │
│   │   ├── work-orders/
│   │   │   ├── wo-list.mjs          ← Searchable, filterable WO list
│   │   │   ├── wo-kanban.mjs        ← Kanban board view (by status column)
│   │   │   ├── wo-form.mjs          ← Create / edit full form
│   │   │   ├── wo-detail.mjs        ← Read-only detail — tabs for each section
│   │   │   ├── wo-checklist.mjs     ← Step checklist with check-off + progress bar
│   │   │   ├── wo-resource-panel.mjs← Assign people, vehicles, equipment, materials
│   │   │   ├── wo-photo-panel.mjs   ← Photo gallery, capture, before/during/after
│   │   │   ├── wo-map.mjs           ← Leaflet map + directions button
│   │   │   ├── wo-accounting.mjs    ← GL, cost center, WBS, billing ref fields
│   │   │   ├── wo-timeline.mjs      ← Audit/activity log feed
│   │   │   └── wo-print.mjs         ← Print-friendly layout for field packets
│   │   │
│   │   ├── dashboard/
│   │   │   ├── dash-root.mjs        ← Dashboard page shell
│   │   │   ├── dash-kpi-card.mjs    ← Reusable stat card (number + trend + icon)
│   │   │   ├── dash-status-chart.mjs← Doughnut chart — WOs by status
│   │   │   ├── dash-priority-bar.mjs← Bar chart — WOs by priority
│   │   │   └── dash-recent-feed.mjs ← Recent activity feed
│   │   │
│   │   ├── registry/
│   │   │   ├── people-list.mjs      ← Manage crew / technician records
│   │   │   ├── people-form.mjs
│   │   │   ├── vehicle-list.mjs     ← Manage fleet / vehicles
│   │   │   ├── vehicle-form.mjs
│   │   │   ├── equipment-list.mjs   ← Manage tools & equipment
│   │   │   ├── equipment-form.mjs
│   │   │   ├── material-list.mjs    ← Manage materials / inventory
│   │   │   └── material-form.mjs
│   │   │
│   │   ├── users/
│   │   │   ├── user-list.mjs        ← User management (admin only)
│   │   │   ├── user-form.mjs        ← Create / edit user, assign role
│   │   │   └── user-profile.mjs     ← Current user profile + password change
│   │   │
│   │   ├── reports/
│   │   │   ├── report-root.mjs      ← Reports landing page
│   │   │   ├── report-by-status.mjs
│   │   │   ├── report-by-cost.mjs
│   │   │   └── report-export.mjs    ← CSV / Excel download triggers
│   │   │
│   │   └── shared/
│   │       ├── ui-badge.mjs         ← <ui-badge> — status + priority pills
│   │       ├── ui-button.mjs        ← <ui-button> — primary/ghost/danger variants
│   │       ├── ui-card.mjs          ← <ui-card> — surface container
│   │       ├── ui-dialog.mjs        ← <ui-dialog> — modal with backdrop
│   │       ├── ui-drawer.mjs        ← <ui-drawer> — slide-in panel (mobile forms)
│   │       ├── ui-toast.mjs         ← <ui-toast> — success/error notifications
│   │       ├── ui-spinner.mjs       ← <ui-spinner> — loading state
│   │       ├── ui-empty.mjs         ← <ui-empty> — empty state illustration + CTA
│   │       ├── ui-confirm.mjs       ← <ui-confirm> — "Are you sure?" dialog
│   │       ├── ui-search.mjs        ← <ui-search> — debounced search input
│   │       ├── ui-tabs.mjs          ← <ui-tabs> — tab bar + panels
│   │       ├── ui-avatar.mjs        ← <ui-avatar> — initials or photo avatar
│   │       └── ui-tooltip.mjs       ← <ui-tooltip> — hover tooltip
│   │
│   ├── lib/
│   │   ├── api.mjs                  ← Fetch wrapper, auth header, error handling
│   │   ├── router.mjs               ← Hash/history client router
│   │   ├── store.mjs                ← Reactive state (lightweight signal pattern)
│   │   ├── auth.mjs                 ← JWT storage, decode, role checks
│   │   ├── format.mjs               ← Date, currency, phone formatters
│   │   ├── validate.mjs             ← Form field validators
│   │   └── utils.mjs                ← Misc helpers, debounce, deepMerge
│   │
│   └── styles/
│       ├── global.css               ← CSS custom properties (design tokens above)
│       ├── reset.css                ← Modern CSS reset
│       ├── typography.css           ← Base font rules
│       ├── forms.css                ← Shared input/select/textarea styles
│       ├── tables.css               ← Shared table styles
│       └── print.css               ← Print overrides for wo-print.mjs
│
├── migrations/
│   ├── 001_initial.sql
│   ├── 002_resources.sql
│   ├── 003_attachments.sql
│   ├── 004_accounting.sql
│   ├── 005_users_roles.sql
│   └── 006_audit_log.sql
│
├── uploads/                         ← Bind-mounted in Docker
├── Dockerfile
├── docker-compose.yml
├── .env.example
└── go.mod

Phase 1 — Foundation

Goal: Running app with shell, sidebar, auth, and WO CRUD.

1.1 CLAUDE.md (create first)

  • Document .mjs extension convention for all frontend JS modules
  • Document Go conventions: package names, error wrapping, named DB params
  • Document CSS convention: always use design token vars, never hardcoded hex
  • Document component convention: Shadow DOM, #private fields, custom events
  • List all CDN dependencies and their globals (L for Leaflet, lucide, Chart)

1.2 Go Project Bootstrap

  • go mod init github.com/yourorg/workorder
  • Add dependencies: chi/v5, sqlx, go-mssqldb, golang-jwt/jwt/v5, google/uuid, joho/godotenv
  • internal/config/config.go — load from env: ADDR, DB_DSN, JWT_SECRET, UPLOAD_PATH, BASE_URL
  • internal/repository/db.gosqlx.Connect, pool settings (max open 25, max idle 5, lifetime 5min)
  • cmd/server/main.go — wire config → db → router → http.ListenAndServe

1.3 Database — Migration 001

-- work_orders table (full schema as planned)
-- Computed wo_number column: 'WO-' + zero-padded id
-- status CHECK constraint
-- priority CHECK constraint
-- Indexes on status, parent, scheduled_start

1.4 Auth

  • migrations/005_users_roles.sql
    CREATE TABLE users (
        id           INT IDENTITY PRIMARY KEY,
        username     NVARCHAR(100) NOT NULL UNIQUE,
        email        NVARCHAR(200) NOT NULL UNIQUE,
        display_name NVARCHAR(200),
        password_hash NVARCHAR(200) NOT NULL,   -- bcrypt
        role         NVARCHAR(30) NOT NULL DEFAULT 'viewer',
                     -- admin | dispatcher | field_tech | viewer
        avatar_url   NVARCHAR(500),
        active       BIT NOT NULL DEFAULT 1,
        last_login   DATETIME2,
        created_at   DATETIME2 NOT NULL DEFAULT GETUTCDATE()
    );
    -- Seed one admin user
    
  • POST /api/auth/login — bcrypt compare, return { token, user } with 8hr JWT
  • POST /api/auth/refresh — accepts valid token, returns new token
  • GET /api/auth/me — current user from token claims
  • JWT middleware: attach user to context.Context, return 401 JSON on failure
  • Role helper: RequireRole(roles ...string) middleware

1.5 Work Order Backend (CRUD)

  • model/workorder.goWorkOrder, WorkOrderListItem, WorkOrderDetail structs with db: and json: tags
  • repository/workorder.go
    • List(ctx, filters) — status, search, priority, parentType, page/limit
    • GetByID(ctx, id) — single WO
    • Create(ctx, wo) — insert + return generated ID
    • Update(ctx, wo) — partial update, always set updated_at = GETUTCDATE()
    • UpdateStatus(ctx, id, status, userID) — transition + audit log insert
    • Delete(ctx, id) — soft delete (add deleted_at column)
    • GetDetail(ctx, id) — joins WO + steps + resources + attachments + accounting in one call
  • Handlers: List, Get, Create, Update, Delete, UpdateStatus
  • Paginated response: { data: [...], meta: { page, per_page, total } }

1.6 Frontend Shell

  • web/index.html

    • Load Google Fonts (Inter + JetBrains Mono)
    • Load Lucide CDN
    • Load Leaflet CSS + JS CDN
    • Load Chart.js CDN
    • <script type="module" src="app.mjs">
    • Viewport meta tag: width=device-width, initial-scale=1
  • web/styles/reset.css — modern reset (box-sizing, margin 0, img max-width)

  • web/styles/global.css — all design tokens, base body/font rules

  • web/styles/forms.css — input, select, textarea, label, fieldset base styles

  • web/lib/api.mjs — fetch wrapper, auto-inject Authorization, handle 401 → emit auth:expired

  • web/lib/auth.mjsgetToken(), setToken(), clearToken(), getUser(), hasRole()

  • web/lib/router.mjs — hash router, on(pattern, handler), navigate(path), start()

  • web/lib/format.mjsformatDate(), formatDateTime(), formatRelative(), formatPhone()

  • web/lib/store.mjs — reactive signal: createStore(initial) returns { get, set, subscribe }

1.7 Sidebar Layout

  • web/components/layout/app-sidebar.mjs

    • Dark navy background (var(--sidebar-bg))
    • Top: app logo / wordmark
    • Nav items with Lucide icon + label (see nav items below)
    • Active item: left accent bar + --teal background
    • Hover: --sidebar-hover background, smooth transition
    • Bottom: current user avatar + name + logout button
    • Collapsible: click toggle collapses to icon-only mode (--sidebar-collapsed width)
    • Collapse state stored in localStorage
    • Collapsed: show only icons with <ui-tooltip> on hover showing label
  • Nav items (in order):

    ─── Main ────────────────
    Dashboard        /          (layout-dashboard icon)
    Work Orders      /work-orders (clipboard-list icon)
    ─── Resources ───────────
    People           /registry/people  (users icon)
    Vehicles         /registry/vehicles (truck icon)
    Equipment        /registry/equipment (wrench icon)
    Materials        /registry/materials (package icon)
    ─── Operations ──────────
    Reports          /reports   (bar-chart-2 icon)
    ─── Admin ────────────────  (only shown if role = admin)
    Users            /users     (user-cog icon)
    Settings         /settings  (settings icon)
    
  • web/components/layout/app-topbar.mjs

    • Left: hamburger menu button (mobile) / breadcrumb (desktop)
    • Right: notification bell with unread count badge, user avatar dropdown
    • Dropdown: "My Profile", "Change Password", divider, "Sign Out"
    • Topbar is fixed/sticky at top, z-index above content
  • web/components/layout/app-mobile-nav.mjs

    • Fixed bottom tab bar on screens ≤ 768px
    • Show 5 main items: Dashboard, Work Orders, People, Reports, Menu (opens drawer)
    • Active tab: --teal color + underline indicator
    • Safe area padding for iOS notch: padding-bottom: env(safe-area-inset-bottom)
  • web/components/layout/app-root.mjs

    • Orchestrates sidebar + topbar + <main> content slot
    • Desktop: sidebar left, content fills remaining width
    • Mobile: sidebar hidden (drawer), topbar + bottom tab bar
    • CSS Grid layout:
      /* Desktop */
      display: grid;
      grid-template-columns: var(--sidebar-w) 1fr;
      grid-template-rows: 56px 1fr;
      
      /* Mobile ≤768px */
      grid-template-columns: 1fr;
      grid-template-rows: 56px 1fr 56px;
      
  • web/app.mjs

    • Register all components
    • Initialize router: map hash routes to <main> innerHTML swaps
    • Listen for auth:expired → clear token → navigate to /login

1.8 Shared UI Components (needed in Phase 1)

  • ui-badge.mjs — status & priority pills

    <ui-badge type="status" value="in_progress">In Progress</ui-badge>
    <ui-badge type="priority" value="urgent">Urgent</ui-badge>
    

    Colored dot + label, background is 15% opacity of the status color

  • ui-button.mjsvariant="primary|ghost|danger|icon", size="sm|md|lg", loading attr shows spinner

  • ui-spinner.mjs — CSS animated ring, size attr

  • ui-toast.mjs — fixed top-right, slide-in animation, auto-dismiss after 4s, type="success|error|info"

  • ui-empty.mjs — centered illustration + heading + body + optional CTA button

  • ui-dialog.mjs<dialog> element, backdrop, close on Escape + backdrop click, size="sm|md|lg"

1.9 WO List Page

  • wo-list.mjs

    • Page header: "Work Orders" title + "New Work Order" primary button
    • Filter bar: search input, status dropdown, priority dropdown, date range pickers
    • View toggle: List view | Kanban view (icon buttons, remember choice in localStorage)
    • List view: table on desktop, card stack on mobile
      • Columns: WO#, Title, Site, Status, Priority, Scheduled, Steps progress, Photos count, Actions
      • Row click → navigate to detail
      • Actions column: Edit (pencil), delete (trash, confirm dialog)
      • Sticky header, sortable columns (client-side sort for Phase 1)
    • Loading: skeleton rows (CSS animation, not a spinner)
    • Empty state: <ui-empty> with clipboard illustration + "Create your first Work Order"
    • Pagination: page controls at bottom
  • wo-kanban.mjs

    • Columns: Draft | Assigned | Scheduled | In Progress | Pending Review | Closed
    • Each column header: status color + count badge
    • Cards: WO#, title, site name, priority badge, assignee avatars, step progress bar
    • Horizontal scroll on mobile (each column is scrollable vertically)
    • Drag-and-drop to change status (HTML5 drag API, no lib needed)

1.10 WO Form Page

  • wo-form.mjs — used for both Create and Edit
    • Page header: "New Work Order" or "Edit WO-000123" + breadcrumb
    • Layout: two-column on desktop (grid-template-columns: 2fr 1fr), single column mobile
    • Left column — main fields:
      • Title (required, full width)
      • Description (textarea, 3 rows)
      • Instructions (textarea, 5 rows — will become rich text in Phase 4)
      • Site Name
      • Address (with optional geocode button → fills Lat/Lng)
      • Lat / Lng (read-only display, set by geocoder or map click)
      • Access Notes (gate codes, road conditions)
    • Right column — metadata:
      • Status (select)
      • Priority (select with colored indicator)
      • Parent Type (select: None / Project / Trouble Ticket / Service Order)
      • Parent ID (text field, shown only when parent type selected)
      • Scheduled Start (datetime-local input)
      • Scheduled End (datetime-local input)
    • Footer: Cancel (ghost) | Save Draft | Save & Open (primary)
    • Validation: highlight invalid fields, scroll to first error, toast on success
    • Unsaved-changes guard: warn before navigating away if form is dirty

1.11 WO Detail Page

  • wo-detail.mjs — tabbed layout
    • Header section: WO number (mono font), title, status badge, priority badge
    • Metadata strip: site, scheduled date, created by, parent link (clickable)
    • Action buttons: Edit, Print, Change Status (dropdown), Close WO
    • Tabs: Overview | Checklist | Resources | Photos | Accounting | Activity
    • Each tab is its own component loaded lazily when tab is clicked
    • Tab indicator: --teal underline, smooth slide transition

Phase 2 — Field Features

Goal: Everything a crew needs on their phone in the field.

2.1 Database Migrations

  • 002_resources.sqlwo_steps, wo_resources, resource_people, resource_vehicles, resource_equipment, resource_materials
  • 003_attachments.sqlwo_attachments

2.2 Checklist / Steps

Backend:

  • GET /api/work-orders/{id}/steps — ordered list
  • POST /api/work-orders/{id}/steps — add step { title, description, step_order, required }
  • PUT /api/work-orders/{id}/steps/{sid} — edit step (title, description, reorder)
  • PUT /api/work-orders/{id}/steps/reorder — bulk reorder { steps: [{id, order}] }
  • POST /api/work-orders/{id}/steps/{sid}/complete — check off, record completed_by, completed_at, optional notes
  • POST /api/work-orders/{id}/steps/{sid}/uncomplete — undo check (dispatcher+ only)
  • DELETE /api/work-orders/{id}/steps/{sid}

Frontend wo-checklist.mjs:

  • Progress bar at top: X of Y steps complete with animated fill
  • Each step row:
    • Checkbox (large, touch-friendly — min 44px tap target)
    • Step number + title (strikethrough when complete)
    • Description (collapsed, expand on tap)
    • "Add note" button → inline textarea for completion note
    • Photo camera button → triggers photo capture attached to this step
    • Completed by + timestamp (shown when done, muted)
  • Add Step form at bottom: title field + optional description + Add button
  • Drag handle for reordering (desktop; long-press on mobile)
  • Steps auto-saved — no save button, immediate API call on check
  • If all steps complete → confetti animation + prompt to move status to Pending Review

2.3 Resource Registry (Master Lists)

Backend:

  • GET /api/registry/people?search=&active=true — paginated list
  • POST /api/registry/people — create person record
  • PUT /api/registry/people/{id} — update
  • DELETE /api/registry/people/{id} — soft delete (set active = 0)
  • Same CRUD for /registry/vehicles, /registry/equipment, /registry/materials

Frontend — People (people-list.mjs, people-form.mjs):

  • List page: avatar + name + role + phone + email + active badge + edit/deactivate actions
  • Form fields: display name, role/title, email, phone, notes, active toggle
  • Avatar: show initials in colored circle (hash name to color), or upload photo

Frontend — Vehicles (vehicle-list.mjs, vehicle-form.mjs):

  • List: unit number, description, type, license plate, active status
  • Form fields: unit number, description, vehicle type (bucket truck / van / trailer / other), license plate, notes, active toggle

Frontend — Equipment (equipment-list.mjs, equipment-form.mjs):

  • List: name, asset tag, category, last assigned WO, active status
  • Form fields: name, asset tag, category (fusion splicer / OTDR / blower / reel / other), serial number, notes, active toggle

Frontend — Materials (material-list.mjs, material-form.mjs):

  • List: name, part number, unit of measure, active status
  • Form fields: name, part number, unit (ft / ea / box / roll), notes, active toggle

2.4 Resource Assignment Panel

Backend:

  • GET /api/work-orders/{id}/resources
  • POST /api/work-orders/{id}/resources{ resource_type, resource_id, quantity?, notes? }
  • DELETE /api/work-orders/{id}/resources/{rid}
  • GET /api/registry/people?search= — for typeahead pickers

Frontend wo-resource-panel.mjs:

  • Four sections: People · Vehicles · Equipment · Materials
  • Each section:
    • Assigned list: avatar/icon + name + remove (×) button
    • "Add" button → opens <ui-dialog> with searchable picker
    • Picker: search input + scrollable list with checkboxes → "Add Selected" button
    • Materials: quantity field next to each assigned item
  • Empty section shows <ui-empty> inline: "No people assigned yet"
  • Changes reflect immediately, API call on each add/remove
  • Total counts shown in section header: "People (3)"

2.5 Photo Panel

Backend:

  • POST /api/work-orders/{id}/attachments — multipart, fields: file, phase, caption, step_id?
  • GET /api/work-orders/{id}/attachments
  • PUT /api/work-orders/{id}/attachments/{aid} — update caption/phase
  • DELETE /api/work-orders/{id}/attachments/{aid}
  • Extract EXIF GPS on server if present in JPEG
  • Generate thumbnail on upload (use imaging Go package, 400px wide JPEG)
  • Serve originals at /uploads/{path}, thumbs at /uploads/thumbs/{path}

Frontend wo-photo-panel.mjs:

  • Phase tabs at top: All · Before · During · After — filter photos by phase
  • Photo grid: 3 columns desktop, 2 columns tablet, 2 columns mobile
  • Each photo tile: thumbnail, hover overlay with caption + phase badge
  • Click → lightbox modal with full-size image, prev/next arrows, swipe on mobile
  • Upload button:
    • Desktop: file picker (multi-select, drag-and-drop zone)
    • Mobile: <input type="file" accept="image/*" capture="environment"> for camera
  • Upload flow: show thumbnail preview + phase selector + caption field before confirm
  • Upload progress: per-file progress bar, queue multiple uploads
  • Geo-tagged indicator: small pin icon if lat/lng present
  • Empty state per phase: "No 'before' photos yet — capture site condition before starting"

2.6 Map & Location

Backend:

  • GET /api/work-orders/{id}/location — return { lat, lng, address, site_name, access_notes }
  • PUT /api/work-orders/{id}/location — update coordinates + address

Frontend wo-map.mjs:

  • Leaflet map, 300px height (expandable to full-screen on mobile)
  • OpenStreetMap tile layer (no API key)
  • Custom pin: teal SVG marker with WO number label
  • "Get Directions" button → deeplink:
    • iOS: maps://maps.apple.com/?daddr={lat},{lng}
    • Android/other: https://maps.google.com/maps?daddr={lat},{lng}
  • "Set Location" mode: click map to drop pin → updates lat/lng fields in WO form
  • Geocode button in WO form: type address → calls OpenStreetMap Nominatim → fills lat/lng (free, no key)
  • Access notes shown below map in an info box with lock icon

2.7 Mobile Responsiveness Audit

  • All tap targets ≥ 44×44px
  • No horizontal scroll on any page at 375px width
  • Forms: single column, full-width inputs
  • Tables: horizontal scroll container or card-based list on mobile
  • Sidebar: hidden, accessible via hamburger → slides in as drawer with backdrop
  • Bottom nav app-mobile-nav.mjs visible on ≤ 768px, hidden on desktop
  • Photo capture uses capture="environment" on mobile
  • Map "Get Directions" opens native maps app
  • Dialogs: full-screen on mobile (position: fixed; inset: 0)
  • Test breakpoints: 375px (iPhone SE), 390px (iPhone 14), 768px (iPad), 1024px, 1440px

Phase 3 — Integrations & Admin

3.1 Accounting Codes

Backend:

  • 004_accounting.sqlwo_accounting table (wo_id, code_type, code_value, description)
  • GET /api/work-orders/{id}/accounting
  • PUT /api/work-orders/{id}/accounting — upsert all codes in one request [{ code_type, code_value, description }]

Frontend wo-accounting.mjs:

  • Four fields in a clean two-column grid:
    • GL Account Code (text + description)
    • Cost Center / Department (text + description)
    • Project WBS / Phase (text + description)
    • Billing Reference (text + description)
  • Each field: code input + description input on same row
  • Auto-save on blur (no save button)
  • Show "auto-populated from parent" chip if code came from parent record
  • Future: lookup/typeahead from your chart of accounts

3.2 Polymorphic Parent Linking

  • WO form: parent type select (None / Project / Trouble Ticket / Service Order)
  • When type selected: show search/ID input for the parent record
  • In WO detail header: linked parent shown as a chip with icon
    📋 Project: Elm Street FTTH Build  →
    
  • Clicking the chip opens a drawer with parent record summary (Phase 4: deep link)
  • Backend: GET /api/work-orders?parent_type=project&parent_id=42 — list WOs for a given parent

3.3 Audit Log / Activity Timeline

Backend:

  • 006_audit_log.sqlwo_audit_log (id, wo_id, action, old_value, new_value, performed_by, performed_at)
  • Log every status change, resource assignment, step completion, photo upload, accounting change
  • GET /api/work-orders/{id}/activity — paginated, newest first

Frontend wo-timeline.mjs:

  • Vertical timeline feed: dot + connector line
  • Each entry: user avatar + action description + timestamp (relative + absolute on hover)
  • Status changes: colored badge showing old → new
  • Photo uploads: thumbnail preview inline
  • Step completions: step title + optional note
  • Load more button at bottom (pagination)
  • Auto-refresh every 30s if WO is in_progress

3.4 User Management (Admin Only)

Backend:

  • GET /api/users — list all users (admin only)
  • POST /api/users — create user, auto-send invite email
  • PUT /api/users/{id} — update name, role, active status
  • PUT /api/users/{id}/password — admin reset
  • DELETE /api/users/{id} — deactivate (soft delete)
  • GET /api/users/me — current user profile
  • PUT /api/users/me — update own display name, avatar
  • PUT /api/users/me/password — change own password (requires current password)

Frontend:

  • user-list.mjs — table: avatar, name, email, role badge, last login, active toggle, edit button
  • user-form.mjs — dialog: display name, email, role select, active toggle, force password reset checkbox
  • user-profile.mjs — own profile: avatar upload, display name, current password + new password fields
  • Role badges with colors: Admin=danger, Dispatcher=teal, Field Tech=warning, Viewer=muted

3.5 Notifications

Backend:

  • wo_notifications table: (id, user_id, wo_id, type, message, read, created_at)
  • Create notifications when:
    • WO assigned to you
    • WO status changes (for creator and assignees)
    • All steps completed (for dispatcher/admin)
    • WO approaching scheduled time (30 min warning, cron job)
  • GET /api/notifications?unread=true — list notifications
  • PUT /api/notifications/{id}/read — mark read
  • PUT /api/notifications/read-all — mark all read
  • Email notification: Go net/smtp with HTML template, send on assignment

Frontend:

  • Bell icon in topbar: unread count badge (red dot with number)
  • Click bell → dropdown panel with notification list
  • Each notification: icon, message, WO link, relative time, unread dot
  • "Mark all read" button at top
  • Click notification → navigate to WO detail + mark read
  • Poll /api/notifications?unread=true every 60s for new count

3.6 Print / Field Packet

Frontend wo-print.mjs:

  • Triggered by "Print" button in WO detail header
  • Opens print-preview page (new tab or print dialog)
  • Printed layout includes:
    • WO number, title, site info, scheduled date
    • All resource assignments (people, vehicles, equipment, materials)
    • Full instructions + access notes
    • Map static image (use Leaflet's map.printPlugin or OpenStreetMap static tiles)
    • Numbered step checklist (checkbox squares for pen check-off)
    • Accounting codes
    • Photo thumbnails (before photos only, 2×3 grid)
    • Signature / completion line
  • print.css: hide sidebar, topbar, action buttons; show print-only elements; break pages sensibly

3.7 Reports

Backend:

  • GET /api/reports/summary — total WOs by status, by priority, by date range
  • GET /api/reports/by-cost-center — WO count and open/closed by cost center
  • GET /api/reports/by-resource — WOs per person/vehicle over date range
  • GET /api/reports/overdue — WOs past scheduled end still not closed
  • GET /api/reports/export?format=csv&status=closed&from=&to= — CSV download

Frontend:

  • report-root.mjs — filter bar: date range, status, cost center, parent type
  • report-by-status.mjs — doughnut chart (Chart.js) + summary table
  • report-by-cost.mjs — bar chart by cost center + table with WO list
  • report-export.mjs — "Export to CSV" button + date range picker + format select

Phase 4 — Polish & Optimization

4.1 Dashboard

Backend:

  • GET /api/dashboard — single endpoint returns all KPIs:
    {
      "open_count": 42,
      "in_progress_count": 12,
      "overdue_count": 5,
      "closed_today": 8,
      "by_status": [...],
      "by_priority": [...],
      "recent_activity": [...],
      "upcoming": [...]
    }
    

Frontend dash-root.mjs:

  • KPI row — four <dash-kpi-card> components:

    • Open Work Orders (blue, clipboard icon)
    • In Progress (orange, activity icon)
    • Overdue (red, alert-circle icon)
    • Closed Today (green, check-circle icon)
    • Each card: large number, trend indicator (↑↓ vs yesterday), icon, click → filtered WO list
  • Charts row (2 columns):

    • Doughnut: WOs by Status (Chart.js, use status colors from design system)
    • Bar: WOs by Priority (horizontal bars)
  • Bottom row (2 columns):

    • Recent Activity feed (<dash-recent-feed>)
    • Upcoming WOs this week (compact list, sorted by scheduled_start)
  • Dashboard auto-refreshes every 2 minutes

  • Skeleton loading state while data loads

4.2 Offline / PWA Basics

  • manifest.json — app name, icons, display: standalone, theme color #0D2137
  • sw.js — Service Worker:
    • Cache shell (index.html, CSS, .mjs files) on install
    • Cache API GET responses in IndexedDB for offline read
    • Queue photo uploads when offline, sync on reconnect (Background Sync API)
  • Offline indicator banner: "You're offline — viewing cached data"
  • "Add to Home Screen" prompt on mobile

4.3 Rich Text Instructions

  • Swap <textarea> for Trix editor (no build needed, CDN available)
  • Store as HTML in instructions column (NVARCHAR(MAX))
  • Display with sanitized innerHTML (strip <script> tags server-side)
  • SQL Server CONTAINS or FREETEXT on work_orders.title, description, instructions, site_name
  • Global search bar in topbar — results show WOs, people, equipment matching query
  • Keyboard shortcut: Ctrl+K / Cmd+K opens search spotlight dialog

4.5 Scheduling Calendar

  • wo-calendar.mjs — monthly/weekly view
  • Each WO block: title, priority color stripe, assignee avatars
  • Click WO block → detail drawer slides in without leaving calendar
  • Drag WO block to reschedule (updates scheduled_start, scheduled_end)

4.6 Crew Scheduling View

  • Gantt-style view: rows = people, columns = days
  • Each WO shown as a bar on the assigned person's row
  • Spot conflicts: overlapping WOs on same person highlighted in red

4.7 Additional Polish

  • Dark mode: add [data-theme="dark"] overrides for all CSS vars, toggle stored in localStorage
  • Keyboard navigation: all interactive elements focusable, visible focus rings, tab order logical
  • Error boundaries: if a component throws, show error card instead of crashing whole page
  • Rate limiting on API (chi middleware: 100 req/min per IP)
  • API response compression (gzip middleware)
  • Input sanitization: strip HTML from all text fields server-side before saving
  • ETag / Last-Modified headers on GET responses for browser caching

Component Inventory

Component File Status Phase
App shell app-root.mjs 1
Sidebar app-sidebar.mjs 1
Topbar app-topbar.mjs 1
Mobile nav app-mobile-nav.mjs 1
WO List wo-list.mjs 1
WO Kanban wo-kanban.mjs 1
WO Form wo-form.mjs 1
WO Detail wo-detail.mjs 1
Status Badge ui-badge.mjs 1
Button ui-button.mjs 1
Dialog ui-dialog.mjs 1
Toast ui-toast.mjs 1
Spinner ui-spinner.mjs 1
Empty state ui-empty.mjs 1
Checklist wo-checklist.mjs 2
Resource panel wo-resource-panel.mjs 2
Photo panel wo-photo-panel.mjs 2
Map wo-map.mjs 2
People list/form people-list.mjs 2
Vehicle list/form vehicle-list.mjs 2
Equipment list/form equipment-list.mjs 2
Material list/form material-list.mjs 2
Accounting wo-accounting.mjs 3
Timeline wo-timeline.mjs 3
Print packet wo-print.mjs 3
User list/form user-list.mjs 3
User profile user-profile.mjs 3
Reports report-root.mjs 3
Dashboard dash-root.mjs 4
KPI card dash-kpi-card.mjs 4
Calendar wo-calendar.mjs 4
Search spotlight (in app-topbar.mjs) 4

API Endpoints

Auth

Method Path Auth Description
POST /api/auth/login Login, returns JWT + user
POST /api/auth/refresh Refresh token
GET /api/auth/me Current user

Work Orders

Method Path Role Description
GET /api/work-orders viewer+ List (filter: status, priority, search, parent, date)
POST /api/work-orders dispatcher+ Create
GET /api/work-orders/{id} viewer+ Full detail
PUT /api/work-orders/{id} dispatcher+ Update
DELETE /api/work-orders/{id} admin Soft delete
PUT /api/work-orders/{id}/status field_tech+ Status transition
GET /api/work-orders/{id}/steps viewer+ List steps
POST /api/work-orders/{id}/steps dispatcher+ Add step
PUT /api/work-orders/{id}/steps/{sid} dispatcher+ Edit step
PUT /api/work-orders/{id}/steps/reorder dispatcher+ Bulk reorder
POST /api/work-orders/{id}/steps/{sid}/complete field_tech+ Check off
POST /api/work-orders/{id}/steps/{sid}/uncomplete dispatcher+ Undo check
DELETE /api/work-orders/{id}/steps/{sid} dispatcher+ Delete step
GET /api/work-orders/{id}/resources viewer+ List assignments
POST /api/work-orders/{id}/resources dispatcher+ Assign resource
DELETE /api/work-orders/{id}/resources/{rid} dispatcher+ Remove assignment
GET /api/work-orders/{id}/attachments viewer+ List photos/files
POST /api/work-orders/{id}/attachments field_tech+ Upload photo/file
PUT /api/work-orders/{id}/attachments/{aid} field_tech+ Update caption/phase
DELETE /api/work-orders/{id}/attachments/{aid} dispatcher+ Delete attachment
GET /api/work-orders/{id}/accounting viewer+ Get accounting codes
PUT /api/work-orders/{id}/accounting dispatcher+ Upsert accounting codes
GET /api/work-orders/{id}/activity viewer+ Audit log feed
GET /api/work-orders/{id}/location viewer+ Location data
PUT /api/work-orders/{id}/location dispatcher+ Update location

Registries (Master Lists)

Method Path Description
GET /api/registry/people List people (search, active filter)
POST /api/registry/people Create person
PUT /api/registry/people/{id} Update person
DELETE /api/registry/people/{id} Deactivate person
GET/POST/PUT/DELETE /api/registry/vehicles Same CRUD for vehicles
GET/POST/PUT/DELETE /api/registry/equipment Same CRUD for equipment
GET/POST/PUT/DELETE /api/registry/materials Same CRUD for materials

Users & Notifications

Method Path Role Description
GET /api/users admin List all users
POST /api/users admin Create user
PUT /api/users/{id} admin Update user
DELETE /api/users/{id} admin Deactivate
GET /api/users/me any Own profile
PUT /api/users/me any Update own profile
PUT /api/users/me/password any Change password
GET /api/notifications any List notifications
PUT /api/notifications/{id}/read any Mark read
PUT /api/notifications/read-all any Mark all read

Reports & Dashboard

Method Path Role Description
GET /api/dashboard viewer+ All KPIs in one call
GET /api/reports/summary viewer+ WOs by status/priority
GET /api/reports/by-cost-center dispatcher+ Cost center breakdown
GET /api/reports/by-resource dispatcher+ Resource utilization
GET /api/reports/overdue dispatcher+ Overdue WOs
GET /api/reports/export dispatcher+ CSV download

Database Schema

Full Table List

Table Purpose
work_orders Core WO records
wo_steps Ordered checklist steps
wo_resources Resource assignments (polymorphic)
wo_attachments Photos and file uploads
wo_accounting GL/cost center/WBS codes
wo_audit_log All change events
wo_notifications User notification records
resource_people People master list
resource_vehicles Vehicle master list
resource_equipment Equipment master list
resource_materials Material/inventory master list
users App users with roles

Key Patterns

  • Soft deletes: deleted_at DATETIME2 NULL — never hard delete, always filter WHERE deleted_at IS NULL
  • Audit timestamps: every table has created_at, updated_at (auto via DEFAULT GETUTCDATE())
  • Polymorphic parent: parent_type NVARCHAR(50), parent_id INT — handles any parent record type
  • Named params only: never string-concatenate SQL — always use @param_name or @p1

Conventions

CLAUDE.md Content (create this file in project root)

# Work Order System — Claude Code Conventions

## Frontend
- ALL frontend JavaScript module files use `.mjs` extension — no exceptions
- No build step, no npm for frontend — pure ES modules via <script type="module">
- CDN dependencies: Lucide (icons), Leaflet (maps), Chart.js (charts), Google Fonts
- Always use CSS custom property vars — never hardcode hex colors
- Component pattern: Shadow DOM, #private class fields, custom events with namespaces
- Custom event naming: colon-namespaced — 'wo:select', 'steps:updated', 'auth:expired'
- Imports always include .mjs extension: import { api } from '../lib/api.mjs'

## Go Backend
- Package names: lowercase single word (handlers, repository, model, service)
- Errors: always wrap with context — fmt.Errorf("repo.Create: %w", err)
- DB queries: named params only — @param_name, never string concat
- HTTP errors: always JSON — {"error": "message"} with correct status code
- Logging: use log/slog with structured key-value pairs
- Response envelope: { data: ..., meta: {...}, error: null }

## Database
- Soft deletes only — deleted_at column, filter WHERE deleted_at IS NULL
- Every table: created_at, updated_at with DEFAULT GETUTCDATE()
- Polymorphic refs: parent_type + parent_id columns
- All SQL in repository layer — no raw SQL in handlers or service

## Git
- Branches: feature/*, fix/*, chore/*
- Commit messages: conventional commits (feat:, fix:, chore:, docs:)

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
    golang.org/x/crypto              latest    // bcrypt
    github.com/disintegration/imaging latest   // thumbnail generation
)

Frontend CDN Deps (loaded in index.html)

<!-- Fonts -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">

<!-- Icons — Lucide (tree-shakeable, no API key) -->
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>

<!-- Maps — Leaflet (no API key) -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9/dist/leaflet.css">
<script src="https://unpkg.com/leaflet@1.9/dist/leaflet.js"></script>

<!-- Charts — Chart.js -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>

Start with Phase 1 in order. Each checkbox is one commit. Don't skip CLAUDE.md — it must exist before any code is written.