147 KiB
Work Order System — Build TODO
Hand this file to Claude Code. All frontend files use
.mjsextensions. Stack: Go · Pure JS + Web Components · SQL Server · Docker
Table of Contents
- Design System
- Project Structure
- Phase 1 — Foundation
- Phase 2 — Field Features
- Phase 3 — Integrations & Admin
- Phase 4 — Polish & Optimization
- Component Inventory
- API Endpoints
- Database Schema
- Conventions
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 +--tealborder, danger =--danger - Inputs:
--borderborder, focus ring--teal2px outline-offset,--radius-smcorners - 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
.mjsextension 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,
#privatefields, custom events - List all CDN dependencies and their globals (
Lfor 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_URLinternal/repository/db.go—sqlx.Connect, pool settings (max open 25, max idle 5, lifetime 5min)cmd/server/main.go— wire config → db → router →http.ListenAndServe
1.3 Database — Migration 001
-- 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.sqlCREATE 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 userPOST /api/auth/login— bcrypt compare, return{ token, user }with 8hr JWTPOST /api/auth/refresh— accepts valid token, returns new tokenGET /api/auth/me— current user from token claims- JWT middleware: attach user to
context.Context, return 401 JSON on failure - Role helper:
RequireRole(roles ...string)middleware
1.5 Work Order Backend (CRUD)
model/workorder.go—WorkOrder,WorkOrderListItem,WorkOrderDetailstructs withdb:andjson:tagsrepository/workorder.goList(ctx, filters)— status, search, priority, parentType, page/limitGetByID(ctx, id)— single WOCreate(ctx, wo)— insert + return generated IDUpdate(ctx, wo)— partial update, always setupdated_at = GETUTCDATE()UpdateStatus(ctx, id, status, userID)— transition + audit log insertDelete(ctx, id)— soft delete (adddeleted_atcolumn)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-injectAuthorization, handle 401 → emitauth:expired -
web/lib/auth.mjs—getToken(),setToken(),clearToken(),getUser(),hasRole() -
web/lib/router.mjs— hash router,on(pattern, handler),navigate(path),start() -
web/lib/format.mjs—formatDate(),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 +
--tealbackground - Hover:
--sidebar-hoverbackground, smooth transition - Bottom: current user avatar + name + logout button
- Collapsible: click toggle collapses to icon-only mode (
--sidebar-collapsedwidth) - Collapse state stored in
localStorage - Collapsed: show only icons with
<ui-tooltip>on hover showing label
- Dark navy background (
-
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-indexabove 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:
--tealcolor + 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;
- Orchestrates sidebar + topbar +
-
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.mjs—variant="primary|ghost|danger|icon",size="sm|md|lg",loadingattr shows spinner -
ui-spinner.mjs— CSS animated ring,sizeattr -
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:
--tealunderline, smooth slide transition
Phase 2 — Field Features
Goal: Everything a crew needs on their phone in the field.
2.1 Database Migrations
002_resources.sql—wo_steps,wo_resources,resource_people,resource_vehicles,resource_equipment,resource_materials003_attachments.sql—wo_attachments
2.2 Checklist / Steps
Backend:
GET /api/work-orders/{id}/steps— ordered listPOST /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, recordcompleted_by,completed_at, optionalnotesPOST /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 completewith 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 listPOST /api/registry/people— create person recordPUT /api/registry/people/{id}— updateDELETE /api/registry/people/{id}— soft delete (setactive = 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}/resourcesPOST /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}/attachmentsPUT /api/work-orders/{id}/attachments/{aid}— update caption/phaseDELETE /api/work-orders/{id}/attachments/{aid}- Extract EXIF GPS on server if present in JPEG
- Generate thumbnail on upload (use
imagingGo 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}
- iOS:
- "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.mjsvisible 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.sql—wo_accountingtable (wo_id, code_type, code_value, description)GET /api/work-orders/{id}/accountingPUT /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.sql—wo_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 emailPUT /api/users/{id}— update name, role, active statusPUT /api/users/{id}/password— admin resetDELETE /api/users/{id}— deactivate (soft delete)GET /api/users/me— current user profilePUT /api/users/me— update own display name, avatarPUT /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 buttonuser-form.mjs— dialog: display name, email, role select, active toggle, force password reset checkboxuser-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_notificationstable: (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 notificationsPUT /api/notifications/{id}/read— mark readPUT /api/notifications/read-all— mark all read- Email notification: Go
net/smtpwith 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=trueevery 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.printPluginor 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 rangeGET /api/reports/by-cost-center— WO count and open/closed by cost centerGET /api/reports/by-resource— WOs per person/vehicle over date rangeGET /api/reports/overdue— WOs past scheduled end still not closedGET /api/reports/export?format=csv&status=closed&from=&to=— CSV download
Frontend:
report-root.mjs— filter bar: date range, status, cost center, parent typereport-by-status.mjs— doughnut chart (Chart.js) + summary tablereport-by-cost.mjs— bar chart by cost center + table with WO listreport-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)
- Recent Activity feed (
-
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#0D2137sw.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
instructionscolumn (NVARCHAR(MAX)) - Display with sanitized
innerHTML(strip<script>tags server-side)
4.4 Full-Text Search
- SQL Server
CONTAINSorFREETEXTonwork_orders.title,description,instructions,site_name - Global search bar in topbar — results show WOs, people, equipment matching query
- Keyboard shortcut:
Ctrl+K/Cmd+Kopens 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 (
gzipmiddleware) - Input sanitization: strip HTML from all text fields server-side before saving
ETag/Last-Modifiedheaders 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 filterWHERE deleted_at IS NULL - Audit timestamps: every table has
created_at,updated_at(auto viaDEFAULT 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_nameor@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
- Architecture Overview
- Project Structure
- Database Schema
- Go Backend
- REST API Design
- Frontend — Web Components
- File & Photo Handling
- Maps & Location
- Authentication & Authorization
- Docker Deployment
- Phased Build Plan
- Development Conventions
1. Architecture Overview
┌─────────────────────────────────────────────────────────┐
│ Browser Client │
│ ┌────────────────────────────────────────────────────┐ │
│ │ Pure JS + Web Components SPA │ │
│ │ <wo-list> <wo-form> <wo-map> <wo-checklist> │ │
│ └───────────────────┬────────────────────────────────┘ │
└──────────────────────│──────────────────────────────────┘
│ REST/JSON over HTTPS
┌──────────────────────▼──────────────────────────────────┐
│ Go API Server │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Router │ │ Handlers │ │ Service │ │
│ │ (chi) │ │ │ │ Layer │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Auth │ │ Upload │ │ Spatial │ │
│ │ (JWT) │ │ Handler │ │ Utils │ │
│ └──────────┘ └──────────┘ └──────────┘ │
└──────────────────────┬──────────────────────────────────┘
│ sqlx / database/sql
┌──────────────────────▼──────────────────────────────────┐
│ SQL Server (MSSQL) │
│ work_orders · wo_resources · wo_steps · wo_attachments │
│ wo_accounting · wo_parents · users · resource_registry │
└─────────────────────────────────────────────────────────┘
Key decisions:
- No framework on the frontend — Custom Elements v1, Shadow DOM, ES Modules. Works in all modern browsers with zero build step.
- chi router for Go — lightweight, idiomatic, stdlib-compatible middleware.
- sqlx for DB access — thin wrapper over
database/sql, named params, struct scanning. - SQL Server — matches your existing infrastructure; geography type for spatial data.
- Multipart uploads stored to local volume (or swap for Azure Blob / S3 later).
- JWT stateless auth — fits well with a SPA + API split.
2. Project Structure
workorder/
├── cmd/
│ └── server/
│ └── main.go # Entry point, wires everything together
├── internal/
│ ├── api/
│ │ ├── router.go # chi router setup, middleware chain
│ │ ├── middleware.go # JWT, CORS, logging, recovery
│ │ └── handlers/
│ │ ├── workorder.go # WO CRUD handlers
│ │ ├── resource.go # People / vehicle / equipment assignment
│ │ ├── step.go # Checklist steps
│ │ ├── attachment.go # Photo & file upload
│ │ ├── accounting.go # Accounting code endpoints
│ │ ├── parent.go # Parent record linking (polymorphic)
│ │ └── auth.go # Login / token refresh
│ ├── service/
│ │ ├── workorder.go # Business logic layer
│ │ ├── resource.go
│ │ ├── notification.go # Email/push when WO assigned
│ │ └── spatial.go # Geocoding, distance calc
│ ├── repository/
│ │ ├── workorder.go # SQL queries — work orders
│ │ ├── resource.go # SQL queries — resources
│ │ ├── step.go
│ │ ├── attachment.go
│ │ └── db.go # sqlx connection pool setup
│ ├── model/
│ │ ├── workorder.go # Structs: WorkOrder, WorkOrderSummary
│ │ ├── resource.go # Person, Vehicle, Equipment, Material
│ │ ├── step.go # Step, StepCompletion
│ │ ├── attachment.go # Attachment
│ │ ├── accounting.go # AccountingCode
│ │ └── auth.go # User, Claims
│ └── config/
│ └── config.go # Env-based config (DB DSN, JWT secret, upload path)
├── web/ # Frontend — served as static files by Go
│ ├── index.html # Shell — loads the app component
│ ├── app.mjs # <app-root> component, client-side router
│ ├── components/
│ │ ├── wo-list.mjs # Work order list / search
│ │ ├── wo-form.mjs # Create / edit work order
│ │ ├── wo-detail.mjs # Read-only detail view
│ │ ├── wo-checklist.mjs # Step checklist with check-off
│ │ ├── wo-resource-panel.mjs # Assign people, vehicles, equipment
│ │ ├── wo-photo-panel.mjs # Photo capture and gallery
│ │ ├── wo-map.mjs # Embedded map + directions
│ │ ├── wo-accounting.mjs # Accounting code fields
│ │ ├── wo-status-badge.mjs # Status pill (reusable)
│ │ └── wo-timeline.mjs # Activity / audit log
│ ├── lib/
│ │ ├── api.mjs # Fetch wrapper, auth header injection
│ │ ├── router.mjs # Tiny hash/history router
│ │ ├── store.mjs # Lightweight reactive state (no Redux)
│ │ └── utils.mjs # Formatters, validators
│ └── styles/
│ ├── global.css # CSS custom properties (design tokens)
│ └── reset.css
├── uploads/ # Stored attachments (bind-mounted in Docker)
├── migrations/
│ ├── 001_initial_schema.sql
│ ├── 002_resource_registry.sql
│ └── 003_spatial_fields.sql
├── docker-compose.yml
├── Dockerfile
└── .env.example
3. Database Schema
Core Tables
-- ── Work Orders ───────────────────────────────────────────────────────────────
CREATE TABLE work_orders (
id INT IDENTITY PRIMARY KEY,
wo_number AS ('WO-' + RIGHT('000000' + CAST(id AS VARCHAR), 6)) PERSISTED,
title NVARCHAR(200) NOT NULL,
description NVARCHAR(MAX),
instructions NVARCHAR(MAX), -- Rich text / markdown
status NVARCHAR(30) NOT NULL DEFAULT 'draft',
-- draft | assigned | scheduled | in_progress | pending_review | closed
priority NVARCHAR(10) NOT NULL DEFAULT 'normal',
-- low | normal | high | urgent
scheduled_start DATETIME2,
scheduled_end DATETIME2,
actual_start DATETIME2,
actual_end DATETIME2,
-- Location
site_name NVARCHAR(200),
address NVARCHAR(400),
lat DECIMAL(10,7),
lng DECIMAL(10,7),
access_notes NVARCHAR(MAX), -- Gate codes, access roads
-- Polymorphic parent
parent_type NVARCHAR(50), -- 'project' | 'ticket' | 'service_order' | NULL
parent_id INT,
-- Audit
created_by INT NOT NULL,
created_at DATETIME2 NOT NULL DEFAULT GETUTCDATE(),
updated_at DATETIME2 NOT NULL DEFAULT GETUTCDATE(),
closed_at DATETIME2,
closed_by INT,
CONSTRAINT chk_wo_status CHECK (status IN ('draft','assigned','scheduled','in_progress','pending_review','closed')),
CONSTRAINT chk_wo_priority CHECK (priority IN ('low','normal','high','urgent'))
);
-- ── Resource Registry (master lists) ─────────────────────────────────────────
CREATE TABLE resource_people (
id INT IDENTITY PRIMARY KEY,
name NVARCHAR(100) NOT NULL,
role NVARCHAR(100),
email NVARCHAR(200),
phone NVARCHAR(30),
active BIT NOT NULL DEFAULT 1
);
CREATE TABLE resource_vehicles (
id INT IDENTITY PRIMARY KEY,
unit_number NVARCHAR(50) NOT NULL,
description NVARCHAR(200),
vehicle_type NVARCHAR(100),
active BIT NOT NULL DEFAULT 1
);
CREATE TABLE resource_equipment (
id INT IDENTITY PRIMARY KEY,
name NVARCHAR(200) NOT NULL,
asset_tag NVARCHAR(100),
category NVARCHAR(100),
active BIT NOT NULL DEFAULT 1
);
CREATE TABLE resource_materials (
id INT IDENTITY PRIMARY KEY,
name NVARCHAR(200) NOT NULL,
unit NVARCHAR(30), -- 'ft', 'each', 'box'
part_number NVARCHAR(100),
active BIT NOT NULL DEFAULT 1
);
-- ── Work Order Resource Assignments ───────────────────────────────────────────
CREATE TABLE wo_resources (
id INT IDENTITY PRIMARY KEY,
wo_id INT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE,
resource_type NVARCHAR(20) NOT NULL, -- 'person' | 'vehicle' | 'equipment' | 'material'
resource_id INT NOT NULL,
quantity DECIMAL(10,2), -- For materials
notes NVARCHAR(500),
assigned_at DATETIME2 NOT NULL DEFAULT GETUTCDATE(),
CONSTRAINT chk_resource_type CHECK (resource_type IN ('person','vehicle','equipment','material'))
);
-- ── Checklist Steps ───────────────────────────────────────────────────────────
CREATE TABLE wo_steps (
id INT IDENTITY PRIMARY KEY,
wo_id INT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE,
step_order INT NOT NULL,
title NVARCHAR(200) NOT NULL,
description NVARCHAR(MAX),
required BIT NOT NULL DEFAULT 1,
completed BIT NOT NULL DEFAULT 0,
completed_by INT,
completed_at DATETIME2,
notes NVARCHAR(MAX) -- Field notes added at completion
);
-- ── Attachments (photos, docs) ────────────────────────────────────────────────
CREATE TABLE wo_attachments (
id INT IDENTITY PRIMARY KEY,
wo_id INT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE,
step_id INT REFERENCES wo_steps(id), -- Optional: attach to a specific step
file_name NVARCHAR(500) NOT NULL,
file_path NVARCHAR(1000) NOT NULL, -- Relative path under /uploads
file_type NVARCHAR(100), -- MIME type
file_size BIGINT,
caption NVARCHAR(500),
phase NVARCHAR(20), -- 'before' | 'during' | 'after'
lat DECIMAL(10,7), -- Geo-tag from EXIF or device
lng DECIMAL(10,7),
uploaded_by INT NOT NULL,
uploaded_at DATETIME2 NOT NULL DEFAULT GETUTCDATE()
);
-- ── Accounting Codes ──────────────────────────────────────────────────────────
CREATE TABLE wo_accounting (
id INT IDENTITY PRIMARY KEY,
wo_id INT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE,
code_type NVARCHAR(50) NOT NULL, -- 'gl_account' | 'cost_center' | 'wbs' | 'billing_ref'
code_value NVARCHAR(200) NOT NULL,
description NVARCHAR(500),
CONSTRAINT uq_wo_accounting UNIQUE (wo_id, code_type)
);
-- ── Audit Log ─────────────────────────────────────────────────────────────────
CREATE TABLE wo_audit_log (
id INT IDENTITY PRIMARY KEY,
wo_id INT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE,
action NVARCHAR(100) NOT NULL,
old_value NVARCHAR(MAX),
new_value NVARCHAR(MAX),
performed_by INT NOT NULL,
performed_at DATETIME2 NOT NULL DEFAULT GETUTCDATE()
);
-- ── Indexes ───────────────────────────────────────────────────────────────────
CREATE INDEX ix_wo_status ON work_orders (status);
CREATE INDEX ix_wo_parent ON work_orders (parent_type, parent_id);
CREATE INDEX ix_wo_scheduled ON work_orders (scheduled_start);
CREATE INDEX ix_wo_resources ON wo_resources (wo_id, resource_type);
CREATE INDEX ix_wo_attachments ON wo_attachments (wo_id);
4. Go Backend
cmd/server/main.go
package main
import (
"log"
"net/http"
"github.com/yourorg/workorder/internal/api"
"github.com/yourorg/workorder/internal/config"
"github.com/yourorg/workorder/internal/repository"
)
func main() {
cfg := config.Load() // reads .env / environment variables
db, err := repository.Connect(cfg.DBDSN)
if err != nil {
log.Fatalf("db connect: %v", err)
}
defer db.Close()
r := api.NewRouter(cfg, db)
log.Printf("listening on %s", cfg.Addr)
if err := http.ListenAndServe(cfg.Addr, r); err != nil {
log.Fatal(err)
}
}
internal/config/config.go
package config
import "os"
type Config struct {
Addr string
DBDSN string
JWTSecret string
UploadPath string
BaseURL string
}
func Load() *Config {
return &Config{
Addr: env("ADDR", ":8080"),
DBDSN: env("DB_DSN", ""),
JWTSecret: env("JWT_SECRET", "change-me"),
UploadPath: env("UPLOAD_PATH", "./uploads"),
BaseURL: env("BASE_URL", "http://localhost:8080"),
}
}
func env(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
internal/api/router.go
package api
import (
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/yourorg/workorder/internal/api/handlers"
mw "github.com/yourorg/workorder/internal/api/middleware"
"github.com/yourorg/workorder/internal/config"
"github.com/jmoiron/sqlx"
)
func NewRouter(cfg *config.Config, db *sqlx.DB) http.Handler {
r := chi.NewRouter()
// Global middleware
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.RealIP)
r.Use(mw.CORS)
// Serve frontend static files
r.Handle("/*", http.FileServer(http.Dir("./web")))
// Auth routes (no JWT required)
r.Post("/api/auth/login", handlers.NewAuthHandler(cfg, db).Login)
r.Post("/api/auth/refresh", handlers.NewAuthHandler(cfg, db).Refresh)
// Protected API routes
r.Group(func(r chi.Router) {
r.Use(mw.JWT(cfg.JWTSecret))
wo := handlers.NewWorkOrderHandler(db)
r.Get("/api/work-orders", wo.List)
r.Post("/api/work-orders", wo.Create)
r.Get("/api/work-orders/{id}", wo.Get)
r.Put("/api/work-orders/{id}", wo.Update)
r.Delete("/api/work-orders/{id}", wo.Delete)
r.Put("/api/work-orders/{id}/status", wo.UpdateStatus)
res := handlers.NewResourceHandler(db)
r.Get("/api/work-orders/{id}/resources", res.List)
r.Post("/api/work-orders/{id}/resources", res.Assign)
r.Delete("/api/work-orders/{id}/resources/{rid}", res.Remove)
step := handlers.NewStepHandler(db)
r.Get("/api/work-orders/{id}/steps", step.List)
r.Post("/api/work-orders/{id}/steps", step.Create)
r.Put("/api/work-orders/{id}/steps/{sid}", step.Update)
r.Post("/api/work-orders/{id}/steps/{sid}/complete", step.Complete)
r.Delete("/api/work-orders/{id}/steps/{sid}", step.Delete)
att := handlers.NewAttachmentHandler(cfg, db)
r.Get("/api/work-orders/{id}/attachments", att.List)
r.Post("/api/work-orders/{id}/attachments", att.Upload)
r.Delete("/api/work-orders/{id}/attachments/{aid}", att.Delete)
r.Handle("/uploads/*", http.StripPrefix("/uploads/", http.FileServer(http.Dir(cfg.UploadPath))))
acc := handlers.NewAccountingHandler(db)
r.Get("/api/work-orders/{id}/accounting", acc.Get)
r.Put("/api/work-orders/{id}/accounting", acc.Upsert)
// Resource registries (master lists for assignment pickers)
r.Get("/api/registry/people", handlers.RegistryPeople(db))
r.Get("/api/registry/vehicles", handlers.RegistryVehicles(db))
r.Get("/api/registry/equipment", handlers.RegistryEquipment(db))
r.Get("/api/registry/materials", handlers.RegistryMaterials(db))
})
return r
}
internal/model/workorder.go
package model
import "time"
type WorkOrder struct {
ID int `db:"id" json:"id"`
WONumber string `db:"wo_number" json:"wo_number"`
Title string `db:"title" json:"title"`
Description string `db:"description" json:"description"`
Instructions string `db:"instructions" json:"instructions"`
Status string `db:"status" json:"status"`
Priority string `db:"priority" json:"priority"`
ScheduledStart *time.Time `db:"scheduled_start" json:"scheduled_start"`
ScheduledEnd *time.Time `db:"scheduled_end" json:"scheduled_end"`
ActualStart *time.Time `db:"actual_start" json:"actual_start"`
ActualEnd *time.Time `db:"actual_end" json:"actual_end"`
SiteName string `db:"site_name" json:"site_name"`
Address string `db:"address" json:"address"`
Lat *float64 `db:"lat" json:"lat"`
Lng *float64 `db:"lng" json:"lng"`
AccessNotes string `db:"access_notes" json:"access_notes"`
ParentType *string `db:"parent_type" json:"parent_type"`
ParentID *int `db:"parent_id" json:"parent_id"`
CreatedBy int `db:"created_by" json:"created_by"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// Enriched view returned by GET /work-orders/{id}
type WorkOrderDetail struct {
WorkOrder
Resources []AssignedResource `json:"resources"`
Steps []Step `json:"steps"`
Attachments []Attachment `json:"attachments"`
Accounting []AccountingCode `json:"accounting"`
}
type WorkOrderListItem struct {
ID int `db:"id" json:"id"`
WONumber string `db:"wo_number" json:"wo_number"`
Title string `db:"title" json:"title"`
Status string `db:"status" json:"status"`
Priority string `db:"priority" json:"priority"`
SiteName string `db:"site_name" json:"site_name"`
ScheduledStart *time.Time `db:"scheduled_start" json:"scheduled_start"`
StepCount int `db:"step_count" json:"step_count"`
StepsDone int `db:"steps_done" json:"steps_done"`
PhotoCount int `db:"photo_count" json:"photo_count"`
}
internal/repository/workorder.go
package repository
import (
"context"
"github.com/jmoiron/sqlx"
"github.com/yourorg/workorder/internal/model"
)
type WorkOrderRepo struct{ db *sqlx.DB }
func NewWorkOrderRepo(db *sqlx.DB) *WorkOrderRepo {
return &WorkOrderRepo{db: db}
}
func (r *WorkOrderRepo) List(ctx context.Context, status, search string) ([]model.WorkOrderListItem, error) {
query := `
SELECT
wo.id, wo.wo_number, wo.title, wo.status, wo.priority,
wo.site_name, wo.scheduled_start,
COUNT(s.id) AS step_count,
SUM(CAST(s.completed AS INT)) AS steps_done,
COUNT(a.id) AS photo_count
FROM work_orders wo
LEFT JOIN wo_steps s ON s.wo_id = wo.id
LEFT JOIN wo_attachments a ON a.wo_id = wo.id
WHERE (@status = '' OR wo.status = @status)
AND (@search = '' OR wo.title LIKE '%' + @search + '%'
OR wo.wo_number LIKE '%' + @search + '%'
OR wo.site_name LIKE '%' + @search + '%')
GROUP BY wo.id, wo.wo_number, wo.title, wo.status,
wo.priority, wo.site_name, wo.scheduled_start
ORDER BY wo.scheduled_start DESC, wo.id DESC`
rows := []model.WorkOrderListItem{}
err := r.db.SelectContext(ctx, &rows, query,
sqlx.Named("status", status),
sqlx.Named("search", search),
)
return rows, err
}
func (r *WorkOrderRepo) GetByID(ctx context.Context, id int) (*model.WorkOrder, error) {
var wo model.WorkOrder
err := r.db.GetContext(ctx, &wo,
`SELECT * FROM work_orders WHERE id = @p1`, id)
if err != nil {
return nil, err
}
return &wo, nil
}
func (r *WorkOrderRepo) Create(ctx context.Context, wo *model.WorkOrder) (int, error) {
var id int
err := r.db.QueryRowContext(ctx, `
INSERT INTO work_orders
(title, description, instructions, status, priority,
scheduled_start, scheduled_end, site_name, address, lat, lng,
access_notes, parent_type, parent_id, created_by, updated_at)
OUTPUT INSERTED.id
VALUES
(@title, @description, @instructions, @status, @priority,
@scheduled_start, @scheduled_end, @site_name, @address, @lat, @lng,
@access_notes, @parent_type, @parent_id, @created_by, GETUTCDATE())`,
sqlx.Named("title", wo.Title),
// ... remaining named params
).Scan(&id)
return id, err
}
func (r *WorkOrderRepo) UpdateStatus(ctx context.Context, id int, status string, userID int) error {
_, err := r.db.ExecContext(ctx, `
UPDATE work_orders
SET status = @p1, updated_at = GETUTCDATE(),
actual_start = CASE WHEN @p1 = 'in_progress' AND actual_start IS NULL
THEN GETUTCDATE() ELSE actual_start END,
actual_end = CASE WHEN @p1 = 'closed' THEN GETUTCDATE() ELSE actual_end END,
closed_at = CASE WHEN @p1 = 'closed' THEN GETUTCDATE() ELSE closed_at END,
closed_by = CASE WHEN @p1 = 'closed' THEN @p2 ELSE closed_by END
WHERE id = @p3`, status, userID, id)
return err
}
5. REST API Design
| Method | Path | Description |
|---|---|---|
| POST | /api/auth/login |
Login, returns JWT |
| GET | /api/work-orders?status=&search=&page= |
Paginated list |
| POST | /api/work-orders |
Create work order |
| GET | /api/work-orders/{id} |
Full detail (WO + resources + steps + photos + accounting) |
| PUT | /api/work-orders/{id} |
Update work order fields |
| PUT | /api/work-orders/{id}/status |
Status transition with audit |
| GET | /api/work-orders/{id}/resources |
List assigned resources |
| POST | /api/work-orders/{id}/resources |
Assign a resource |
| DELETE | /api/work-orders/{id}/resources/{rid} |
Remove assignment |
| GET | /api/work-orders/{id}/steps |
List checklist steps |
| POST | /api/work-orders/{id}/steps |
Add a step |
| PUT | /api/work-orders/{id}/steps/{sid} |
Edit step |
| POST | /api/work-orders/{id}/steps/{sid}/complete |
Check off step |
| POST | /api/work-orders/{id}/attachments |
Upload photo/file (multipart) |
| GET | /api/work-orders/{id}/attachments |
List attachments |
| PUT | /api/work-orders/{id}/accounting |
Upsert accounting codes |
| GET | /api/registry/people |
Lookup: people for assignment picker |
| GET | /api/registry/vehicles |
Lookup: vehicles |
| GET | /api/registry/equipment |
Lookup: equipment |
| GET | /api/registry/materials |
Lookup: materials |
Standard Response Envelope
{
"data": { ... },
"meta": { "page": 1, "per_page": 25, "total": 142 },
"error": null
}
Status Transition Rules (enforced server-side)
draft → assigned → scheduled → in_progress → pending_review → closed
↘ (re-open) ↗
6. Frontend — Web Components
Design Principles
- No build step — plain
<script type="module">imports. Works directly in browser. - Shadow DOM per component — style isolation, no CSS leakage.
- Custom events for cross-component communication — no shared global state pollution.
api.mjsis the single HTTP layer — all fetch calls go through it.
web/lib/api.mjs
const BASE = '/api';
let _token = localStorage.getItem('wo_token') || '';
export function setToken(t) {
_token = t;
localStorage.setItem('wo_token', t);
}
async function request(method, path, body, isFormData = false) {
const headers = { Authorization: `Bearer ${_token}` };
if (!isFormData) headers['Content-Type'] = 'application/json';
const res = await fetch(BASE + path, {
method,
headers,
body: body
? (isFormData ? body : JSON.stringify(body))
: undefined,
});
if (res.status === 401) {
// Token expired — redirect to login
window.dispatchEvent(new CustomEvent('auth:expired'));
return;
}
const json = await res.mjson();
if (!res.ok) throw new Error(json.error || 'Request failed');
return json.data;
}
export const api = {
get: (path) => request('GET', path),
post: (path, body) => request('POST', path, body),
put: (path, body) => request('PUT', path, body),
delete: (path) => request('DELETE', path),
upload: (path, form) => request('POST', path, form, true),
};
web/lib/router.mjs
// Hash-based router — no server config needed
class Router {
#routes = [];
on(pattern, handler) {
this.#routes.push({ pattern: new URLPattern({ pathname: pattern }), handler });
return this;
}
start() {
const dispatch = () => {
const path = location.hash.slice(1) || '/';
for (const { pattern, handler } of this.#routes) {
const m = pattern.exec({ pathname: path });
if (m) { handler(m.pathname.groups); return; }
}
};
window.addEventListener('hashchange', dispatch);
dispatch();
}
}
export const router = new Router();
web/components/wo-list.mjs
import { api } from '../lib/api.mjs';
class WoList extends HTMLElement {
#data = [];
#status = '';
connectedCallback() {
this.attachShadow({ mode: 'open' });
this.#render();
this.#load();
}
async #load() {
const status = this.getAttribute('filter-status') || '';
this.#data = await api.get(`/work-orders?status=${status}`);
this.#render();
}
#render() {
this.shadowRoot.innerHTML = `
<style>
:host { display: block; }
.grid { display: grid; gap: 0.75rem; }
.card {
background: var(--surface, #fff);
border: 1px solid var(--border, #e2e8f0);
border-radius: 8px;
padding: 1rem 1.25rem;
cursor: pointer;
display: grid;
grid-template-columns: 1fr auto;
gap: 0.25rem 1rem;
transition: box-shadow .15s;
}
.card:hover { box-shadow: 0 4px 12px rgba(0,0,0,.08); }
.wo-number { font-size: .75rem; color: var(--muted); }
.title { font-weight: 600; color: var(--text); }
.progress { font-size: .8rem; color: var(--muted); }
</style>
<div class="grid">
${this.#data.map(wo => `
<div class="card" data-id="${wo.id}">
<div>
<div class="wo-number">${wo.wo_number}</div>
<div class="title">${wo.title}</div>
<div class="progress">
${wo.site_name} · ${wo.steps_done}/${wo.step_count} steps
</div>
</div>
<wo-status-badge status="${wo.status}"></wo-status-badge>
</div>
`).join('')}
</div>`;
this.shadowRoot.querySelectorAll('.card').forEach(card => {
card.addEventListener('click', () => {
this.dispatchEvent(new CustomEvent('wo:select',
{ detail: { id: card.dataset.id }, bubbles: true, composed: true }));
});
});
}
}
customElements.define('wo-list', WoList);
web/components/wo-resource-panel.mjs
import { api } from '../lib/api.mjs';
class WoResourcePanel extends HTMLElement {
#woId = null;
#resources = [];
#registry = { people: [], vehicles: [], equipment: [], materials: [] };
static get observedAttributes() { return ['wo-id']; }
attributeChangedCallback(name, _, val) {
if (name === 'wo-id') { this.#woId = val; this.#load(); }
}
connectedCallback() {
this.attachShadow({ mode: 'open' });
if (this.#woId) this.#load();
}
async #load() {
const [resources, people, vehicles, equipment, materials] = await Promise.all([
api.get(`/work-orders/${this.#woId}/resources`),
api.get('/registry/people'),
api.get('/registry/vehicles'),
api.get('/registry/equipment'),
api.get('/registry/materials'),
]);
this.#resources = resources;
this.#registry = { people, vehicles, equipment, materials };
this.#render();
}
async #assign(type, resourceId) {
await api.post(`/work-orders/${this.#woId}/resources`, {
resource_type: type, resource_id: Number(resourceId)
});
await this.#load();
}
async #remove(assignmentId) {
await api.delete(`/work-orders/${this.#woId}/resources/${assignmentId}`);
await this.#load();
}
#sectionHTML(label, type, list) {
const assigned = this.#resources.filter(r => r.resource_type === type);
const available = list.filter(item =>
!assigned.some(a => a.resource_id === item.id));
return `
<section>
<h3>${label}</h3>
<ul>
${assigned.map(a => `
<li>${a.name}
<button class="remove-btn" data-id="${a.id}">×</button>
</li>`).join('')}
</ul>
<select class="add-select" data-type="${type}">
<option value="">Add ${label.slice(0,-1)}...</option>
${available.map(item =>
`<option value="${item.id}">${item.name ?? item.unit_number}</option>`
).join('')}
</select>
</section>`;
}
#render() {
const { people, vehicles, equipment, materials } = this.#registry;
this.shadowRoot.innerHTML = `
<style>
:host { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
section { background: var(--surface); border-radius: 8px; padding: 1rem; }
h3 { margin: 0 0 .5rem; font-size: .9rem; text-transform: uppercase;
letter-spacing: .05em; color: var(--muted); }
ul { list-style: none; padding: 0; margin: 0 0 .5rem; }
li { display: flex; justify-content: space-between; padding: .25rem 0;
border-bottom: 1px solid var(--border); font-size: .9rem; }
select { width: 100%; padding: .4rem; border-radius: 6px;
border: 1px solid var(--border); }
.remove-btn { background: none; border: none; cursor: pointer;
color: var(--danger, #c0392b); font-size: 1.1rem; }
</style>
${this.#sectionHTML('People', 'person', people)}
${this.#sectionHTML('Vehicles', 'vehicle', vehicles)}
${this.#sectionHTML('Equipment', 'equipment', equipment)}
${this.#sectionHTML('Materials', 'material', materials)}`;
this.shadowRoot.querySelectorAll('.add-select').forEach(sel => {
sel.addEventListener('change', e => {
if (e.target.value) this.#assign(e.target.dataset.type, e.target.value);
});
});
this.shadowRoot.querySelectorAll('.remove-btn').forEach(btn => {
btn.addEventListener('click', () => this.#remove(btn.dataset.id));
});
}
}
customElements.define('wo-resource-panel', WoResourcePanel);
web/components/wo-checklist.mjs
import { api } from '../lib/api.mjs';
class WoChecklist extends HTMLElement {
#woId = null;
#steps = [];
static get observedAttributes() { return ['wo-id']; }
attributeChangedCallback(_, __, val) { this.#woId = val; this.#load(); }
connectedCallback() { this.attachShadow({ mode: 'open' }); }
async #load() {
this.#steps = await api.get(`/work-orders/${this.#woId}/steps`);
this.#render();
}
async #complete(stepId) {
await api.post(`/work-orders/${this.#woId}/steps/${stepId}/complete`, {});
await this.#load();
this.dispatchEvent(new CustomEvent('steps:updated', { bubbles: true, composed: true }));
}
#render() {
const done = this.#steps.filter(s => s.completed).length;
const total = this.#steps.length;
const pct = total ? Math.round(done / total * 100) : 0;
this.shadowRoot.innerHTML = `
<style>
.progress-bar { height: 6px; background: var(--border); border-radius: 3px; margin-bottom: 1rem; }
.progress-fill { height: 100%; border-radius: 3px;
background: var(--accent, #0a7ea4); width: ${pct}%; transition: width .3s; }
.step { display: flex; gap: .75rem; align-items: flex-start;
padding: .75rem 0; border-bottom: 1px solid var(--border); }
.step.done .step-title { text-decoration: line-through; color: var(--muted); }
input[type=checkbox] { width: 1.2rem; height: 1.2rem; accent-color: var(--accent); margin-top: .15rem; }
.step-title { font-weight: 500; }
.step-desc { font-size: .85rem; color: var(--muted); margin-top: .2rem; }
.summary { font-size: .85rem; color: var(--muted); margin-bottom: .5rem; }
</style>
<div class="progress-bar"><div class="progress-fill"></div></div>
<div class="summary">${done} of ${total} steps complete</div>
${this.#steps.map(s => `
<div class="step ${s.completed ? 'done' : ''}">
<input type="checkbox" data-id="${s.id}" ${s.completed ? 'checked disabled' : ''}>
<div>
<div class="step-title">${s.step_order}. ${s.title}</div>
${s.description ? `<div class="step-desc">${s.description}</div>` : ''}
</div>
</div>`).join('')}`;
this.shadowRoot.querySelectorAll('input[type=checkbox]:not([disabled])').forEach(cb => {
cb.addEventListener('change', () => this.#complete(cb.dataset.id));
});
}
}
customElements.define('wo-checklist', WoChecklist);
web/styles/global.css — Design Tokens
:root {
--navy: #0d2137;
--accent: #0a7ea4;
--accent-lt: #14b8d4;
--surface: #ffffff;
--bg: #f0f6fa;
--border: #e2ebf0;
--text: #1a2e3b;
--muted: #64748b;
--danger: #c0392b;
--success: #1d9d6c;
--warning: #e07b39;
--radius: 8px;
--shadow: 0 2px 8px rgba(0,0,0,.08);
font-family: Calibri, 'Segoe UI', system-ui, sans-serif;
font-size: 16px;
color: var(--text);
background: var(--bg);
}
7. File & Photo Handling
Go Upload Handler
func (h *AttachmentHandler) Upload(w http.ResponseWriter, r *http.Request) {
woID := chi.URLParam(r, "id")
r.ParseMultipartForm(32 << 20) // 32 MB max
file, header, err := r.FormFile("file")
if err != nil { respondError(w, 400, "missing file"); return }
defer file.Close()
// Build storage path: uploads/{wo_id}/{uuid}{ext}
ext := filepath.Ext(header.Filename)
fname := uuid.New().String() + ext
relPath := filepath.Join(woID, fname)
absPath := filepath.Join(h.cfg.UploadPath, relPath)
os.MkdirAll(filepath.Dir(absPath), 0755)
dst, _ := os.Create(absPath)
defer dst.Close()
io.Copy(dst, file)
// Extract EXIF GPS if it's a JPEG
lat, lng := extractGPS(absPath)
// Save record
att := &model.Attachment{
WOID: mustInt(woID),
FileName: header.Filename,
FilePath: relPath,
FileType: header.Header.Get("Content-Type"),
FileSize: header.Size,
Phase: r.FormValue("phase"),
Caption: r.FormValue("caption"),
Lat: lat,
Lng: lng,
UploadedBy: userIDFromCtx(r.Context()),
}
id, _ := h.repo.Create(r.Context(), att)
respond(w, 201, map[string]any{"id": id, "url": "/uploads/" + relPath})
}
Frontend Photo Capture (wo-photo-panel.mjs key logic)
// Use camera on mobile devices
async #capturePhoto() {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.capture = 'environment'; // rear camera
input.onchange = async (e) => {
const file = e.target.files[0];
const form = new FormData();
form.append('file', file);
form.append('phase', this.#phase); // 'before' | 'during' | 'after'
// Attach GPS from browser if available
if (navigator.geolocation) {
await new Promise(res => navigator.geolocation.getCurrentPosition(pos => {
form.append('lat', pos.coords.latitude);
form.append('lng', pos.coords.longitude);
res();
}, res));
}
await api.upload(`/work-orders/${this.#woId}/attachments`, form);
await this.#load();
};
input.click();
}
8. Maps & Location
- Leaflet.mjs (open source, no API key required) loaded via CDN for the embedded map.
- Google Maps Directions API (or OpenRouteService if you want fully open source) for turn-by-turn routing.
- Coordinates stored as
lat/lngdecimals in SQL Server. No geography type required unless you add spatial radius queries later.
web/components/wo-map.mjs skeleton
// Leaflet loaded via <script> in index.html
class WoMap extends HTMLElement {
connectedCallback() {
this.style.display = 'block';
this.style.height = '300px';
const map = L.map(this).setView([0, 0], 13);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png').addTo(map);
const lat = parseFloat(this.getAttribute('lat'));
const lng = parseFloat(this.getAttribute('lng'));
if (lat && lng) {
map.setView([lat, lng], 15);
L.marker([lat, lng]).addTo(map)
.bindPopup(this.getAttribute('site-name') || 'Work Site').openPopup();
}
}
}
customElements.define('wo-map', WoMap);
Directions button
// Opens Google Maps / Apple Maps turn-by-turn in native app on mobile
function openDirections(lat, lng, label) {
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
const url = isIOS
? `maps://maps.apple.com/?daddr=${lat},${lng}&dirflg=d`
: `https://maps.google.com/maps?daddr=${lat},${lng}`;
window.open(url, '_blank');
}
9. Authentication & Authorization
JWT Middleware (Go)
func JWT(secret string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth := r.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer ") {
http.Error(w, `{"error":"unauthorized"}`, 401); return
}
token, err := jwt.ParseWithClaims(strings.TrimPrefix(auth, "Bearer "),
&Claims{}, func(t *jwt.Token) (any, error) {
return []byte(secret), nil
})
if err != nil || !token.Valid {
http.Error(w, `{"error":"invalid token"}`, 401); return
}
claims := token.Claims.(*Claims)
ctx := context.WithValue(r.Context(), ctxKeyUser, claims)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
Roles to implement
| Role | Can Do |
|---|---|
admin |
Full CRUD, close any WO, manage registries |
dispatcher |
Create & assign WOs, edit all fields |
field_tech |
View assigned WOs, complete steps, upload photos |
viewer |
Read-only access |
10. Docker Deployment
Dockerfile
# ── Build stage ───────────────────────────────────────────
FROM golang:1.23-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o workorder ./cmd/server
# ── Runtime stage ─────────────────────────────────────────
FROM alpine:3.20
WORKDIR /app
RUN apk add --no-cache tzdata ca-certificates
COPY --from=builder /app/workorder .
COPY web/ ./web/
EXPOSE 8080
CMD ["./workorder"]
docker-compose.yml
services:
api:
build: .
ports:
- "8080:8080"
environment:
ADDR: ":8080"
DB_DSN: "sqlserver://sa:${SA_PASSWORD}@mssql:1433?database=workorders"
JWT_SECRET: "${JWT_SECRET}"
UPLOAD_PATH: "/uploads"
volumes:
- uploads:/uploads
depends_on:
- mssql
restart: unless-stopped
mssql:
image: mcr.microsoft.com/mssql/server:2022-latest
environment:
SA_PASSWORD: "${SA_PASSWORD}"
ACCEPT_EULA: "Y"
MSSQL_PID: "Developer"
volumes:
- mssql_data:/var/opt/mssql
ports:
- "1433:1433" # Remove in production
volumes:
uploads:
mssql_data:
If you're connecting to your existing SQL Server instance, just point DB_DSN at it and drop the mssql service.
11. Phased Build Plan
Phase 1 — Foundation (Weeks 1–4)
Goal: Working CRUD with project linking and status flow.
- Repo setup: Go module, chi, sqlx, godotenv
- DB migration:
work_orderstable + indexes - Auth: login endpoint, JWT middleware, user table
- Work Order CRUD endpoints (list, get, create, update, status)
- Frontend shell:
index.html, router,api.mjs, global CSS <wo-list>— list with search + filter by status<wo-form>— create / edit form with all base fields<wo-status-badge>— reusable status pill- Docker Compose working locally
- Basic role checking (admin vs viewer)
Phase 2 — Field Features (Weeks 5–8)
Goal: Everything a field crew needs on their phone.
- DB:
wo_steps,wo_resources, resource registry tables - Step endpoints +
<wo-checklist>with check-off - Resource registry endpoints +
<wo-resource-panel>picker - DB:
wo_attachmentstable - Photo upload endpoint with GPS extraction
<wo-photo-panel>— capture, gallery, before/during/after phases<wo-map>— Leaflet map + site pin- Directions button (native maps deeplink)
- Mobile-responsive layout (CSS Grid, touch-friendly targets)
Phase 3 — Integrations (Weeks 9–12)
Goal: Connects to the rest of the system.
- DB:
wo_accountingtable <wo-accounting>— GL, cost center, WBS, billing ref fields- Parent linking: polymorphic
parent_type/parent_id - Link WOs to existing Projects (query existing project IDs)
- Link to Trouble Tickets / Service Orders (when those APIs exist)
- Audit log table +
<wo-timeline>component - Email notification on assignment (Go
net/smtpor SendGrid) - Reporting endpoint: WOs by status, by cost center, by date range
Phase 4 — Optimization (Week 13+)
- Dashboard
<wo-dashboard>— KPI cards + charts (Chart.mjs via CDN) - Crew scheduling calendar view
- Offline support (Service Worker + IndexedDB cache for field use)
- CSV / Excel export for accounting reconciliation
- Full-text search across WO title, site, instructions
- Webhook / API for third-party integrations
12. Development Conventions
Go
Packages: lowercase, single word (handlers, repository, model)
Errors: always wrapped (fmt.Errorf("repo.Create: %w", err))
HTTP errors: JSON envelope {"error": "message"} with correct status code
DB queries: named params only @param_name — never string concat
Logging: log/slog structured key-value pairs
Tests: _test.go files table-driven, httptest.NewRecorder for handlers
JavaScript
Components: kebab-case custom elements <wo-form>, <wo-map>
Private: # prefix for class fields #data, #load(), #render()
Events: colon namespaced 'wo:select', 'steps:updated', 'auth:expired'
API calls: always in the component no inline fetch(), always via api.mjs
Render: full innerHTML redraw simple, no virtual DOM needed at this scale
State: component-local lift to app-root only when truly shared
Git Branch Strategy
main — production-ready, tagged releases
develop — integration branch
feature/* — individual features (feature/wo-checklist)
fix/* — bug fixes
Go Dependencies
// go.mod
require (
github.com/go-chi/chi/v5 v5.1.0
github.com/jmoiron/sqlx v1.3.5
github.com/microsoft/go-mssqldb v1.7.1
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
)
Frontend Dependencies (CDN, no npm)
<!-- in index.html -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9/dist/leaflet.css">
<script src="https://unpkg.com/leaflet@1.9/dist/leaflet.mjs"></script>
<!-- Chart.mjs added in Phase 4 only -->
<script src="https://cdn.mjsdelivr.net/npm/chart.mjs@4/dist/chart.umd.min.mjs"></script>
Zero build tooling. Zero npm for the frontend. Open in browser and go.
Work Order System — Build TODO
Hand this file to Claude Code. All frontend files use
.mjsextensions. Stack: Go · Pure JS + Web Components · SQL Server · Docker
Table of Contents
- Design System
- Project Structure
- Phase 1 — Foundation
- Phase 2 — Field Features
- Phase 3 — Integrations & Admin
- Phase 4 — Polish & Optimization
- Component Inventory
- API Endpoints
- Database Schema
- Conventions
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 +--tealborder, danger =--danger - Inputs:
--borderborder, focus ring--teal2px outline-offset,--radius-smcorners - 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
.mjsextension 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,
#privatefields, custom events - List all CDN dependencies and their globals (
Lfor 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_URLinternal/repository/db.go—sqlx.Connect, pool settings (max open 25, max idle 5, lifetime 5min)cmd/server/main.go— wire config → db → router →http.ListenAndServe
1.3 Database — Migration 001
-- 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.sqlCREATE 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 userPOST /api/auth/login— bcrypt compare, return{ token, user }with 8hr JWTPOST /api/auth/refresh— accepts valid token, returns new tokenGET /api/auth/me— current user from token claims- JWT middleware: attach user to
context.Context, return 401 JSON on failure - Role helper:
RequireRole(roles ...string)middleware
1.5 Work Order Backend (CRUD)
model/workorder.go—WorkOrder,WorkOrderListItem,WorkOrderDetailstructs withdb:andjson:tagsrepository/workorder.goList(ctx, filters)— status, search, priority, parentType, page/limitGetByID(ctx, id)— single WOCreate(ctx, wo)— insert + return generated IDUpdate(ctx, wo)— partial update, always setupdated_at = GETUTCDATE()UpdateStatus(ctx, id, status, userID)— transition + audit log insertDelete(ctx, id)— soft delete (adddeleted_atcolumn)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-injectAuthorization, handle 401 → emitauth:expired -
web/lib/auth.mjs—getToken(),setToken(),clearToken(),getUser(),hasRole() -
web/lib/router.mjs— hash router,on(pattern, handler),navigate(path),start() -
web/lib/format.mjs—formatDate(),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 +
--tealbackground - Hover:
--sidebar-hoverbackground, smooth transition - Bottom: current user avatar + name + logout button
- Collapsible: click toggle collapses to icon-only mode (
--sidebar-collapsedwidth) - Collapse state stored in
localStorage - Collapsed: show only icons with
<ui-tooltip>on hover showing label
- Dark navy background (
-
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-indexabove 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:
--tealcolor + 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;
- Orchestrates sidebar + topbar +
-
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.mjs—variant="primary|ghost|danger|icon",size="sm|md|lg",loadingattr shows spinner -
ui-spinner.mjs— CSS animated ring,sizeattr -
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:
--tealunderline, smooth slide transition
Phase 2 — Field Features
Goal: Everything a crew needs on their phone in the field.
2.1 Database Migrations
002_resources.sql—wo_steps,wo_resources,resource_people,resource_vehicles,resource_equipment,resource_materials003_attachments.sql—wo_attachments
2.2 Checklist / Steps
Backend:
GET /api/work-orders/{id}/steps— ordered listPOST /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, recordcompleted_by,completed_at, optionalnotesPOST /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 completewith 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 listPOST /api/registry/people— create person recordPUT /api/registry/people/{id}— updateDELETE /api/registry/people/{id}— soft delete (setactive = 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}/resourcesPOST /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}/attachmentsPUT /api/work-orders/{id}/attachments/{aid}— update caption/phaseDELETE /api/work-orders/{id}/attachments/{aid}- Extract EXIF GPS on server if present in JPEG
- Generate thumbnail on upload (use
imagingGo 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}
- iOS:
- "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.mjsvisible 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.sql—wo_accountingtable (wo_id, code_type, code_value, description)GET /api/work-orders/{id}/accountingPUT /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.sql—wo_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 emailPUT /api/users/{id}— update name, role, active statusPUT /api/users/{id}/password— admin resetDELETE /api/users/{id}— deactivate (soft delete)GET /api/users/me— current user profilePUT /api/users/me— update own display name, avatarPUT /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 buttonuser-form.mjs— dialog: display name, email, role select, active toggle, force password reset checkboxuser-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_notificationstable: (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 notificationsPUT /api/notifications/{id}/read— mark readPUT /api/notifications/read-all— mark all read- Email notification: Go
net/smtpwith 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=trueevery 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.printPluginor 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 rangeGET /api/reports/by-cost-center— WO count and open/closed by cost centerGET /api/reports/by-resource— WOs per person/vehicle over date rangeGET /api/reports/overdue— WOs past scheduled end still not closedGET /api/reports/export?format=csv&status=closed&from=&to=— CSV download
Frontend:
report-root.mjs— filter bar: date range, status, cost center, parent typereport-by-status.mjs— doughnut chart (Chart.js) + summary tablereport-by-cost.mjs— bar chart by cost center + table with WO listreport-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)
- Recent Activity feed (
-
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#0D2137sw.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
instructionscolumn (NVARCHAR(MAX)) - Display with sanitized
innerHTML(strip<script>tags server-side)
4.4 Full-Text Search
- SQL Server
CONTAINSorFREETEXTonwork_orders.title,description,instructions,site_name - Global search bar in topbar — results show WOs, people, equipment matching query
- Keyboard shortcut:
Ctrl+K/Cmd+Kopens 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 (
gzipmiddleware) - Input sanitization: strip HTML from all text fields server-side before saving
ETag/Last-Modifiedheaders 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 filterWHERE deleted_at IS NULL - Audit timestamps: every table has
created_at,updated_at(auto viaDEFAULT 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_nameor@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 Profiles
Overview
A Work Order Profile is a reusable template that defines the default structure and behavior for a category of work orders. When a profile is applied to a work order (at creation or post-creation), its steps, instructions, priority, and duration are loaded in — then customized as needed.
Key idea: Profile = preset + flexibility. The profile defines the standard; each WO can deviate from it.
Database
-- Profiles
CREATE TABLE wo_profiles (
id INT IDENTITY PRIMARY KEY,
name NVARCHAR(200) NOT NULL,
description NVARCHAR(MAX),
category NVARCHAR(100),
default_priority NVARCHAR(10) NOT NULL DEFAULT 'normal',
default_duration_hours INT,
default_instructions NVARCHAR(MAX),
active BIT NOT NULL DEFAULT 1,
created_at DATETIME2 NOT NULL DEFAULT GETUTCDATE(),
updated_at DATETIME2 NOT NULL DEFAULT GETUTCDATE()
);
CREATE TABLE wo_profile_steps (
id INT IDENTITY PRIMARY KEY,
profile_id INT NOT NULL REFERENCES wo_profiles(id) ON DELETE CASCADE,
step_order INT NOT NULL,
title NVARCHAR(200) NOT NULL,
description NVARCHAR(MAX),
required BIT NOT NULL DEFAULT 1
);
CREATE INDEX ix_profile_steps ON wo_profile_steps (profile_id, step_order);
API Endpoints
| Method | Path | Description |
|---|---|---|
| GET | /api/profiles |
List active profiles (search, category filter) |
| POST | /api/profiles |
Create profile |
| GET | /api/profiles/{id} |
Get profile with its steps |
| PUT | /api/profiles/{id} |
Update profile fields |
| DELETE | /api/profiles/{id} |
Soft delete (set active=0) |
| GET | /api/profiles/{id}/steps |
List profile steps |
| POST | /api/profiles/{id}/steps |
Add step to profile |
| PUT | /api/profiles/{id}/steps/{sid} |
Update profile step |
| DELETE | /api/profiles/{id}/steps/{sid} |
Remove step from profile |
| POST | /api/work-orders/{id}/apply-profile/{profileId} |
Apply profile to WO |
apply-profile behavior
Request body: { "mode": "append" | "replace" }
- append — inserts profile steps after existing steps (step_order continues from current max)
- replace — deletes all existing steps, then inserts profile steps starting at order 1
- Both modes update
instructionsif currently blank andpriorityif WO is stilldraft
Frontend Components
web/components/registry/profile-list.mjs
- List page at
/registry/profiles - Cards: name, category badge, step count, active status
- Search + category filter, edit/deactivate actions, "New Profile" button
web/components/registry/profile-form.mjs
- Modal dialog for create/edit
- Fields: name (required), description, category, default priority, default duration hours, default instructions
- Inline step editor: ordered step list with add / edit / reorder / delete
- Active toggle in edit mode
WO Form integration (wo-form.mjs)
- "Load Profile" ghost button at top of form (shown only on new WOs or draft status)
- Searchable profile picker dialog — on confirm pre-fills priority and instructions (if blank)
- Steps applied server-side via apply-profile after the WO saves; checklist tab then refreshes
WO Detail integration (wo-detail.mjs)
- "Apply Profile" button in header actions alongside Edit / Change Status
- Two-step dialog: (1) pick profile, (2) if WO has existing steps — prompt append vs replace
- Calls
POST /api/work-orders/{id}/apply-profile/{profileId}then refreshes Checklist tab
Sidebar Nav
Add under Resources section:
Profiles /registry/profiles (layout-template icon)
Go Models
type Profile struct {
ID int `db:"id" json:"id"`
Name string `db:"name" json:"name"`
Description string `db:"description" json:"description"`
Category string `db:"category" json:"category"`
DefaultPriority string `db:"default_priority" json:"default_priority"`
DefaultDurationHours *int `db:"default_duration_hours" json:"default_duration_hours"`
DefaultInstructions string `db:"default_instructions" json:"default_instructions"`
Active bool `db:"active" json:"active"`
Steps []ProfileStep `db:"-" json:"steps,omitempty"`
}
type ProfileStep struct {
ID int `db:"id" json:"id"`
ProfileID int `db:"profile_id" json:"profile_id"`
StepOrder int `db:"step_order" json:"step_order"`
Title string `db:"title" json:"title"`
Description string `db:"description" json:"description"`
Required bool `db:"required" json:"required"`
}
apply-profile Handler (pseudocode)
func (h *ProfileHandler) Apply(w http.ResponseWriter, r *http.Request) {
woID, profileID := intParam(r, "id"), intParam(r, "profileId")
var body struct { Mode string `json:"mode"` }
json.NewDecoder(r.Body).Decode(&body)
profile := // load profile + steps
if body.Mode == "replace" {
db.Exec(`DELETE FROM wo_steps WHERE wo_id = @p1`, woID)
}
var maxOrder int
db.Get(&maxOrder, `SELECT ISNULL(MAX(step_order), 0) FROM wo_steps WHERE wo_id = @p1`, woID)
for _, s := range profile.Steps {
db.Exec(`INSERT INTO wo_steps (wo_id, step_order, title, description, required)
VALUES (@p1, @p2, @p3, @p4, @p5)`,
woID, maxOrder+s.StepOrder, s.Title, s.Description, s.Required)
}
db.Exec(`
UPDATE work_orders SET
instructions = CASE WHEN (instructions IS NULL OR instructions = '') THEN @p1 ELSE instructions END,
priority = CASE WHEN status = 'draft' THEN @p2 ELSE priority END,
updated_at = GETUTCDATE()
WHERE id = @p3`,
profile.DefaultInstructions, profile.DefaultPriority, woID)
}