2558 lines
117 KiB
Markdown
2558 lines
117 KiB
Markdown
# Work Order System — Build TODO
|
|
> Hand this file to Claude Code. All frontend files use `.mjs` extensions.
|
|
> Stack: Go · Pure JS + Web Components · SQL Server · Docker
|
|
|
|
---
|
|
|
|
## Table of Contents
|
|
- [Design System](#design-system)
|
|
- [Project Structure](#project-structure)
|
|
- [Phase 1 — Foundation](#phase-1--foundation)
|
|
- [Phase 2 — Field Features](#phase-2--field-features)
|
|
- [Phase 3 — Integrations & Admin](#phase-3--integrations--admin)
|
|
- [Phase 4 — Polish & Optimization](#phase-4--polish--optimization)
|
|
- [Component Inventory](#component-inventory)
|
|
- [API Endpoints](#api-endpoints)
|
|
- [Database Schema](#database-schema)
|
|
- [Conventions](#conventions)
|
|
|
|
---
|
|
|
|
## Design System
|
|
|
|
### Color Palette
|
|
```css
|
|
: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:
|
|
```html
|
|
<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">
|
|
```
|
|
|
|
```css
|
|
:root {
|
|
--font-body: 'Inter', 'Segoe UI', system-ui, sans-serif;
|
|
--font-mono: 'JetBrains Mono', 'Cascadia Code', monospace;
|
|
|
|
--text-xs: 0.70rem; /* 11px — labels, caps */
|
|
--text-sm: 0.813rem; /* 13px — helper, meta */
|
|
--text-base: 0.938rem; /* 15px — body */
|
|
--text-md: 1.063rem; /* 17px — subheadings */
|
|
--text-lg: 1.25rem; /* 20px — section headers */
|
|
--text-xl: 1.5rem; /* 24px — page titles */
|
|
--text-2xl: 2rem; /* 32px — dashboard hero numbers */
|
|
|
|
--weight-normal: 400;
|
|
--weight-medium: 500;
|
|
--weight-semibold: 600;
|
|
--weight-bold: 700;
|
|
}
|
|
```
|
|
|
|
### Component Design Patterns
|
|
- Cards: white background, `var(--border)` border, `var(--radius)` corners, `var(--shadow-sm)`
|
|
- Buttons: filled primary = `--teal`, ghost = transparent + `--teal` border, danger = `--danger`
|
|
- Inputs: `--border` border, focus ring `--teal` 2px outline-offset, `--radius-sm` corners
|
|
- Tables: header row `--surface-2`, alternating rows, sticky header on scroll
|
|
- Status pills: colored dot + label, pill shape, light background tint of the status color
|
|
- Icon + label pattern everywhere — use Lucide icons loaded via CDN (no npm)
|
|
|
|
### Icons
|
|
```html
|
|
<!-- index.html — Lucide icon CDN, no API key, tree-shakeable -->
|
|
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
|
|
```
|
|
Call `lucide.createIcons()` after each render. Use named icons:
|
|
`clipboard-list`, `users`, `truck`, `wrench`, `map-pin`, `camera`, `file-invoice`,
|
|
`layout-dashboard`, `settings`, `bell`, `chevron-left`, `plus`, `search`, `filter`
|
|
|
|
---
|
|
|
|
## Project Structure
|
|
|
|
```
|
|
workorder/
|
|
├── CLAUDE.md ↠!! READ FIRST — conventions for Claude Code
|
|
├── cmd/server/main.go
|
|
├── internal/
|
|
│ ├── api/
|
|
│ │ ├── router.go
|
|
│ │ ├── middleware/
|
|
│ │ │ ├── auth.go
|
|
│ │ │ ├── cors.go
|
|
│ │ │ └── logger.go
|
|
│ │ └── handlers/
|
|
│ │ ├── auth.go
|
|
│ │ ├── workorder.go
|
|
│ │ ├── step.go
|
|
│ │ ├── resource.go
|
|
│ │ ├── attachment.go
|
|
│ │ ├── accounting.go
|
|
│ │ ├── user.go
|
|
│ │ ├── registry.go ↠people/vehicle/equipment/material master lists
|
|
│ │ ├── dashboard.go
|
|
│ │ └── report.go
|
|
│ ├── service/
|
|
│ │ ├── workorder.go
|
|
│ │ ├── notification.go
|
|
│ │ ├── spatial.go
|
|
│ │ └── export.go ↠CSV/Excel export
|
|
│ ├── repository/
|
|
│ │ ├── db.go
|
|
│ │ ├── workorder.go
|
|
│ │ ├── step.go
|
|
│ │ ├── resource.go
|
|
│ │ ├── attachment.go
|
|
│ │ ├── accounting.go
|
|
│ │ ├── user.go
|
|
│ │ └── registry.go
|
|
│ ├── model/
|
|
│ │ ├── workorder.go
|
|
│ │ ├── step.go
|
|
│ │ ├── resource.go
|
|
│ │ ├── attachment.go
|
|
│ │ ├── accounting.go
|
|
│ │ ├── user.go
|
|
│ │ └── dashboard.go
|
|
│ └── config/config.go
|
|
│
|
|
├── web/ ↠All frontend — served as static files by Go
|
|
│ ├── index.html ↠App shell, loads fonts, Lucide, Leaflet
|
|
│ ├── app.mjs ↠<app-root> custom element + client router
|
|
│ │
|
|
│ ├── components/
|
|
│ │ ├── layout/
|
|
│ │ │ ├── app-sidebar.mjs ↠Left nav — collapsible, mobile drawer
|
|
│ │ │ ├── app-topbar.mjs ↠Top bar — breadcrumb, notifications bell, user avatar
|
|
│ │ │ ├── app-root.mjs ↠Shell that wires sidebar + topbar + <main>
|
|
│ │ │ └── app-mobile-nav.mjs ↠Bottom tab bar for mobile (≤768px)
|
|
│ │ │
|
|
│ │ ├── work-orders/
|
|
│ │ │ ├── wo-list.mjs ↠Searchable, filterable WO list
|
|
│ │ │ ├── wo-kanban.mjs ↠Kanban board view (by status column)
|
|
│ │ │ ├── wo-form.mjs ↠Create / edit full form
|
|
│ │ │ ├── wo-detail.mjs ↠Read-only detail — tabs for each section
|
|
│ │ │ ├── wo-checklist.mjs ↠Step checklist with check-off + progress bar
|
|
│ │ │ ├── wo-resource-panel.mjs↠Assign people, vehicles, equipment, materials
|
|
│ │ │ ├── wo-photo-panel.mjs ↠Photo gallery, capture, before/during/after
|
|
│ │ │ ├── wo-map.mjs ↠Leaflet map + directions button
|
|
│ │ │ ├── wo-accounting.mjs ↠GL, cost center, WBS, billing ref fields
|
|
│ │ │ ├── wo-timeline.mjs ↠Audit/activity log feed
|
|
│ │ │ └── wo-print.mjs ↠Print-friendly layout for field packets
|
|
│ │ │
|
|
│ │ ├── dashboard/
|
|
│ │ │ ├── dash-root.mjs ↠Dashboard page shell
|
|
│ │ │ ├── dash-kpi-card.mjs ↠Reusable stat card (number + trend + icon)
|
|
│ │ │ ├── dash-status-chart.mjs↠Doughnut chart — WOs by status
|
|
│ │ │ ├── dash-priority-bar.mjs↠Bar chart — WOs by priority
|
|
│ │ │ └── dash-recent-feed.mjs ↠Recent activity feed
|
|
│ │ │
|
|
│ │ ├── registry/
|
|
│ │ │ ├── people-list.mjs ↠Manage crew / technician records
|
|
│ │ │ ├── people-form.mjs
|
|
│ │ │ ├── vehicle-list.mjs ↠Manage fleet / vehicles
|
|
│ │ │ ├── vehicle-form.mjs
|
|
│ │ │ ├── equipment-list.mjs ↠Manage tools & equipment
|
|
│ │ │ ├── equipment-form.mjs
|
|
│ │ │ ├── material-list.mjs ↠Manage materials / inventory
|
|
│ │ │ └── material-form.mjs
|
|
│ │ │
|
|
│ │ ├── users/
|
|
│ │ │ ├── user-list.mjs ↠User management (admin only)
|
|
│ │ │ ├── user-form.mjs ↠Create / edit user, assign role
|
|
│ │ │ └── user-profile.mjs ↠Current user profile + password change
|
|
│ │ │
|
|
│ │ ├── reports/
|
|
│ │ │ ├── report-root.mjs ↠Reports landing page
|
|
│ │ │ ├── report-by-status.mjs
|
|
│ │ │ ├── report-by-cost.mjs
|
|
│ │ │ └── report-export.mjs ↠CSV / Excel download triggers
|
|
│ │ │
|
|
│ │ └── shared/
|
|
│ │ ├── ui-badge.mjs ↠<ui-badge> — status + priority pills
|
|
│ │ ├── ui-button.mjs ↠<ui-button> — primary/ghost/danger variants
|
|
│ │ ├── ui-card.mjs ↠<ui-card> — surface container
|
|
│ │ ├── ui-dialog.mjs ↠<ui-dialog> — modal with backdrop
|
|
│ │ ├── ui-drawer.mjs ↠<ui-drawer> — slide-in panel (mobile forms)
|
|
│ │ ├── ui-toast.mjs ↠<ui-toast> — success/error notifications
|
|
│ │ ├── ui-spinner.mjs ↠<ui-spinner> — loading state
|
|
│ │ ├── ui-empty.mjs ↠<ui-empty> — empty state illustration + CTA
|
|
│ │ ├── ui-confirm.mjs ↠<ui-confirm> — "Are you sure?" dialog
|
|
│ │ ├── ui-search.mjs ↠<ui-search> — debounced search input
|
|
│ │ ├── ui-tabs.mjs ↠<ui-tabs> — tab bar + panels
|
|
│ │ ├── ui-avatar.mjs ↠<ui-avatar> — initials or photo avatar
|
|
│ │ └── ui-tooltip.mjs ↠<ui-tooltip> — hover tooltip
|
|
│ │
|
|
│ ├── lib/
|
|
│ │ ├── api.mjs ↠Fetch wrapper, auth header, error handling
|
|
│ │ ├── router.mjs ↠Hash/history client router
|
|
│ │ ├── store.mjs ↠Reactive state (lightweight signal pattern)
|
|
│ │ ├── auth.mjs ↠JWT storage, decode, role checks
|
|
│ │ ├── format.mjs ↠Date, currency, phone formatters
|
|
│ │ ├── validate.mjs ↠Form field validators
|
|
│ │ └── utils.mjs ↠Misc helpers, debounce, deepMerge
|
|
│ │
|
|
│ └── styles/
|
|
│ ├── global.css ↠CSS custom properties (design tokens above)
|
|
│ ├── reset.css ↠Modern CSS reset
|
|
│ ├── typography.css ↠Base font rules
|
|
│ ├── forms.css ↠Shared input/select/textarea styles
|
|
│ ├── tables.css ↠Shared table styles
|
|
│ └── print.css ↠Print overrides for wo-print.mjs
|
|
│
|
|
├── migrations/
|
|
│ ├── 001_initial.sql
|
|
│ ├── 002_resources.sql
|
|
│ ├── 003_attachments.sql
|
|
│ ├── 004_accounting.sql
|
|
│ ├── 005_users_roles.sql
|
|
│ └── 006_audit_log.sql
|
|
│
|
|
├── uploads/ ↠Bind-mounted in Docker
|
|
├── Dockerfile
|
|
├── docker-compose.yml
|
|
├── .env.example
|
|
└── go.mod
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 1 — Foundation
|
|
**Goal:** Running app with shell, sidebar, auth, and WO CRUD.
|
|
|
|
### 1.1 CLAUDE.md (create first)
|
|
- [ ] Document `.mjs` extension convention for all frontend JS modules
|
|
- [ ] Document Go conventions: package names, error wrapping, named DB params
|
|
- [ ] Document CSS convention: always use design token vars, never hardcoded hex
|
|
- [ ] Document component convention: Shadow DOM, `#private` fields, custom events
|
|
- [ ] List all CDN dependencies and their globals (`L` for Leaflet, `lucide`, `Chart`)
|
|
|
|
### 1.2 Go Project Bootstrap
|
|
- [ ] `go mod init github.com/yourorg/workorder`
|
|
- [ ] Add dependencies: `chi/v5`, `sqlx`, `go-mssqldb`, `golang-jwt/jwt/v5`, `google/uuid`, `joho/godotenv`
|
|
- [ ] `internal/config/config.go` — load from env: `ADDR`, `DB_DSN`, `JWT_SECRET`, `UPLOAD_PATH`, `BASE_URL`
|
|
- [ ] `internal/repository/db.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
|
|
```sql
|
|
-- work_orders table (full schema as planned)
|
|
-- Computed wo_number column: 'WO-' + zero-padded id
|
|
-- status CHECK constraint
|
|
-- priority CHECK constraint
|
|
-- Indexes on status, parent, scheduled_start
|
|
```
|
|
|
|
### 1.4 Auth
|
|
- [ ] `migrations/005_users_roles.sql`
|
|
```sql
|
|
CREATE TABLE users (
|
|
id INT IDENTITY PRIMARY KEY,
|
|
username NVARCHAR(100) NOT NULL UNIQUE,
|
|
email NVARCHAR(200) NOT NULL UNIQUE,
|
|
display_name NVARCHAR(200),
|
|
password_hash NVARCHAR(200) NOT NULL, -- bcrypt
|
|
role NVARCHAR(30) NOT NULL DEFAULT 'viewer',
|
|
-- admin | dispatcher | field_tech | viewer
|
|
avatar_url NVARCHAR(500),
|
|
active BIT NOT NULL DEFAULT 1,
|
|
last_login DATETIME2,
|
|
created_at DATETIME2 NOT NULL DEFAULT GETUTCDATE()
|
|
);
|
|
-- Seed one admin user
|
|
```
|
|
- [ ] `POST /api/auth/login` — bcrypt compare, return `{ token, user }` with 8hr JWT
|
|
- [ ] `POST /api/auth/refresh` — accepts valid token, returns new token
|
|
- [ ] `GET /api/auth/me` — current user from token claims
|
|
- [ ] JWT middleware: attach user to `context.Context`, return 401 JSON on failure
|
|
- [ ] Role helper: `RequireRole(roles ...string)` middleware
|
|
|
|
### 1.5 Work Order Backend (CRUD)
|
|
- [ ] `model/workorder.go` — `WorkOrder`, `WorkOrderListItem`, `WorkOrderDetail` structs with `db:` and `json:` tags
|
|
- [ ] `repository/workorder.go`
|
|
- `List(ctx, filters)` — status, search, priority, parentType, page/limit
|
|
- `GetByID(ctx, id)` — single WO
|
|
- `Create(ctx, wo)` — insert + return generated ID
|
|
- `Update(ctx, wo)` — partial update, always set `updated_at = GETUTCDATE()`
|
|
- `UpdateStatus(ctx, id, status, userID)` — transition + audit log insert
|
|
- `Delete(ctx, id)` — soft delete (add `deleted_at` column)
|
|
- `GetDetail(ctx, id)` — joins WO + steps + resources + attachments + accounting in one call
|
|
- [ ] Handlers: `List`, `Get`, `Create`, `Update`, `Delete`, `UpdateStatus`
|
|
- [ ] Paginated response: `{ data: [...], meta: { page, per_page, total } }`
|
|
|
|
### 1.6 Frontend Shell
|
|
- [ ] `web/index.html`
|
|
- Load Google Fonts (Inter + JetBrains Mono)
|
|
- Load Lucide CDN
|
|
- Load Leaflet CSS + JS CDN
|
|
- Load Chart.js CDN
|
|
- `<script type="module" src="app.mjs">`
|
|
- Viewport meta tag: `width=device-width, initial-scale=1`
|
|
|
|
- [ ] `web/styles/reset.css` — modern reset (box-sizing, margin 0, img max-width)
|
|
- [ ] `web/styles/global.css` — all design tokens, base body/font rules
|
|
- [ ] `web/styles/forms.css` — input, select, textarea, label, fieldset base styles
|
|
- [ ] `web/lib/api.mjs` — fetch wrapper, auto-inject `Authorization`, handle 401 → emit `auth:expired`
|
|
- [ ] `web/lib/auth.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 + `--teal` background
|
|
- Hover: `--sidebar-hover` background, smooth transition
|
|
- Bottom: current user avatar + name + logout button
|
|
- **Collapsible:** click toggle collapses to icon-only mode (`--sidebar-collapsed` width)
|
|
- Collapse state stored in `localStorage`
|
|
- Collapsed: show only icons with `<ui-tooltip>` on hover showing label
|
|
|
|
- [ ] **Nav items (in order):**
|
|
```
|
|
─── Main ────────────────
|
|
Dashboard / (layout-dashboard icon)
|
|
Work Orders /work-orders (clipboard-list icon)
|
|
─── Resources ───────────
|
|
People /registry/people (users icon)
|
|
Vehicles /registry/vehicles (truck icon)
|
|
Equipment /registry/equipment (wrench icon)
|
|
Materials /registry/materials (package icon)
|
|
─── Operations ──────────
|
|
Reports /reports (bar-chart-2 icon)
|
|
─── Admin ──────────────── (only shown if role = admin)
|
|
Users /users (user-cog icon)
|
|
Settings /settings (settings icon)
|
|
```
|
|
|
|
- [ ] **`web/components/layout/app-topbar.mjs`**
|
|
- Left: hamburger menu button (mobile) / breadcrumb (desktop)
|
|
- Right: notification bell with unread count badge, user avatar dropdown
|
|
- Dropdown: "My Profile", "Change Password", divider, "Sign Out"
|
|
- Topbar is fixed/sticky at top, `z-index` above content
|
|
|
|
- [ ] **`web/components/layout/app-mobile-nav.mjs`**
|
|
- Fixed bottom tab bar on screens ≤ 768px
|
|
- Show 5 main items: Dashboard, Work Orders, People, Reports, Menu (opens drawer)
|
|
- Active tab: `--teal` color + underline indicator
|
|
- Safe area padding for iOS notch: `padding-bottom: env(safe-area-inset-bottom)`
|
|
|
|
- [ ] **`web/components/layout/app-root.mjs`**
|
|
- Orchestrates sidebar + topbar + `<main>` content slot
|
|
- Desktop: sidebar left, content fills remaining width
|
|
- Mobile: sidebar hidden (drawer), topbar + bottom tab bar
|
|
- CSS Grid layout:
|
|
```css
|
|
/* Desktop */
|
|
display: grid;
|
|
grid-template-columns: var(--sidebar-w) 1fr;
|
|
grid-template-rows: 56px 1fr;
|
|
|
|
/* Mobile ≤768px */
|
|
grid-template-columns: 1fr;
|
|
grid-template-rows: 56px 1fr 56px;
|
|
```
|
|
|
|
- [ ] **`web/app.mjs`**
|
|
- Register all components
|
|
- Initialize router: map hash routes to `<main>` innerHTML swaps
|
|
- Listen for `auth:expired` → clear token → navigate to `/login`
|
|
|
|
### 1.8 Shared UI Components (needed in Phase 1)
|
|
- [ ] **`ui-badge.mjs`** — status & priority pills
|
|
```html
|
|
<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"`, `loading` attr shows spinner
|
|
- [ ] **`ui-spinner.mjs`** — CSS animated ring, `size` attr
|
|
- [ ] **`ui-toast.mjs`** — fixed top-right, slide-in animation, auto-dismiss after 4s, `type="success|error|info"`
|
|
- [ ] **`ui-empty.mjs`** — centered illustration + heading + body + optional CTA button
|
|
- [ ] **`ui-dialog.mjs`** — `<dialog>` element, backdrop, close on Escape + backdrop click, `size="sm|md|lg"`
|
|
|
|
### 1.9 WO List Page
|
|
- [ ] **`wo-list.mjs`**
|
|
- Page header: "Work Orders" title + "New Work Order" primary button
|
|
- Filter bar: search input, status dropdown, priority dropdown, date range pickers
|
|
- View toggle: List view | Kanban view (icon buttons, remember choice in localStorage)
|
|
- **List view:** table on desktop, card stack on mobile
|
|
- Columns: WO#, Title, Site, Status, Priority, Scheduled, Steps progress, Photos count, Actions
|
|
- Row click → navigate to detail
|
|
- Actions column: Edit (pencil), delete (trash, confirm dialog)
|
|
- Sticky header, sortable columns (client-side sort for Phase 1)
|
|
- Loading: skeleton rows (CSS animation, not a spinner)
|
|
- Empty state: `<ui-empty>` with clipboard illustration + "Create your first Work Order"
|
|
- Pagination: page controls at bottom
|
|
|
|
- [ ] **`wo-kanban.mjs`**
|
|
- Columns: Draft | Assigned | Scheduled | In Progress | Pending Review | Closed
|
|
- Each column header: status color + count badge
|
|
- Cards: WO#, title, site name, priority badge, assignee avatars, step progress bar
|
|
- Horizontal scroll on mobile (each column is scrollable vertically)
|
|
- Drag-and-drop to change status (HTML5 drag API, no lib needed)
|
|
|
|
### 1.10 WO Form Page
|
|
- [ ] **`wo-form.mjs`** — used for both Create and Edit
|
|
- Page header: "New Work Order" or "Edit WO-000123" + breadcrumb
|
|
- Layout: two-column on desktop (`grid-template-columns: 2fr 1fr`), single column mobile
|
|
- **Left column — main fields:**
|
|
- Title (required, full width)
|
|
- Description (textarea, 3 rows)
|
|
- Instructions (textarea, 5 rows — will become rich text in Phase 4)
|
|
- Site Name
|
|
- Address (with optional geocode button → fills Lat/Lng)
|
|
- Lat / Lng (read-only display, set by geocoder or map click)
|
|
- Access Notes (gate codes, road conditions)
|
|
- **Right column — metadata:**
|
|
- Status (select)
|
|
- Priority (select with colored indicator)
|
|
- Parent Type (select: None / Project / Trouble Ticket / Service Order)
|
|
- Parent ID (text field, shown only when parent type selected)
|
|
- Scheduled Start (datetime-local input)
|
|
- Scheduled End (datetime-local input)
|
|
- Footer: Cancel (ghost) | Save Draft | Save & Open (primary)
|
|
- Validation: highlight invalid fields, scroll to first error, toast on success
|
|
- Unsaved-changes guard: warn before navigating away if form is dirty
|
|
|
|
### 1.11 WO Detail Page
|
|
- [ ] **`wo-detail.mjs`** — tabbed layout
|
|
- Header section: WO number (mono font), title, status badge, priority badge
|
|
- Metadata strip: site, scheduled date, created by, parent link (clickable)
|
|
- Action buttons: Edit, Print, Change Status (dropdown), Close WO
|
|
- Tabs: **Overview | Checklist | Resources | Photos | Accounting | Activity**
|
|
- Each tab is its own component loaded lazily when tab is clicked
|
|
- Tab indicator: `--teal` underline, smooth slide transition
|
|
|
|
---
|
|
|
|
## Phase 2 — Field Features
|
|
**Goal:** Everything a crew needs on their phone in the field.
|
|
|
|
### 2.1 Database Migrations
|
|
- [ ] `002_resources.sql` — `wo_steps`, `wo_resources`, `resource_people`, `resource_vehicles`, `resource_equipment`, `resource_materials`
|
|
- [ ] `003_attachments.sql` — `wo_attachments`
|
|
|
|
### 2.2 Checklist / Steps
|
|
**Backend:**
|
|
- [ ] `GET /api/work-orders/{id}/steps` — ordered list
|
|
- [ ] `POST /api/work-orders/{id}/steps` — add step `{ title, description, step_order, required }`
|
|
- [ ] `PUT /api/work-orders/{id}/steps/{sid}` — edit step (title, description, reorder)
|
|
- [ ] `PUT /api/work-orders/{id}/steps/reorder` — bulk reorder `{ steps: [{id, order}] }`
|
|
- [ ] `POST /api/work-orders/{id}/steps/{sid}/complete` — check off, record `completed_by`, `completed_at`, optional `notes`
|
|
- [ ] `POST /api/work-orders/{id}/steps/{sid}/uncomplete` — undo check (dispatcher+ only)
|
|
- [ ] `DELETE /api/work-orders/{id}/steps/{sid}`
|
|
|
|
**Frontend `wo-checklist.mjs`:**
|
|
- [ ] Progress bar at top: `X of Y steps complete` with animated fill
|
|
- [ ] Each step row:
|
|
- Checkbox (large, touch-friendly — min 44px tap target)
|
|
- Step number + title (strikethrough when complete)
|
|
- Description (collapsed, expand on tap)
|
|
- "Add note" button → inline textarea for completion note
|
|
- Photo camera button → triggers photo capture attached to this step
|
|
- Completed by + timestamp (shown when done, muted)
|
|
- [ ] Add Step form at bottom: title field + optional description + Add button
|
|
- [ ] Drag handle for reordering (desktop; long-press on mobile)
|
|
- [ ] Steps auto-saved — no save button, immediate API call on check
|
|
- [ ] If all steps complete → confetti animation + prompt to move status to Pending Review
|
|
|
|
### 2.3 Resource Registry (Master Lists)
|
|
**Backend:**
|
|
- [ ] `GET /api/registry/people?search=&active=true` — paginated list
|
|
- [ ] `POST /api/registry/people` — create person record
|
|
- [ ] `PUT /api/registry/people/{id}` — update
|
|
- [ ] `DELETE /api/registry/people/{id}` — soft delete (set `active = 0`)
|
|
- [ ] Same CRUD for `/registry/vehicles`, `/registry/equipment`, `/registry/materials`
|
|
|
|
**Frontend — People (`people-list.mjs`, `people-form.mjs`):**
|
|
- [ ] List page: avatar + name + role + phone + email + active badge + edit/deactivate actions
|
|
- [ ] Form fields: display name, role/title, email, phone, notes, active toggle
|
|
- [ ] Avatar: show initials in colored circle (hash name to color), or upload photo
|
|
|
|
**Frontend — Vehicles (`vehicle-list.mjs`, `vehicle-form.mjs`):**
|
|
- [ ] List: unit number, description, type, license plate, active status
|
|
- [ ] Form fields: unit number, description, vehicle type (bucket truck / van / trailer / other), license plate, notes, active toggle
|
|
|
|
**Frontend — Equipment (`equipment-list.mjs`, `equipment-form.mjs`):**
|
|
- [ ] List: name, asset tag, category, last assigned WO, active status
|
|
- [ ] Form fields: name, asset tag, category (fusion splicer / OTDR / blower / reel / other), serial number, notes, active toggle
|
|
|
|
**Frontend — Materials (`material-list.mjs`, `material-form.mjs`):**
|
|
- [ ] List: name, part number, unit of measure, active status
|
|
- [ ] Form fields: name, part number, unit (ft / ea / box / roll), notes, active toggle
|
|
|
|
### 2.4 Resource Assignment Panel
|
|
**Backend:**
|
|
- [ ] `GET /api/work-orders/{id}/resources`
|
|
- [ ] `POST /api/work-orders/{id}/resources` — `{ resource_type, resource_id, quantity?, notes? }`
|
|
- [ ] `DELETE /api/work-orders/{id}/resources/{rid}`
|
|
- [ ] `GET /api/registry/people?search=` — for typeahead pickers
|
|
|
|
**Frontend `wo-resource-panel.mjs`:**
|
|
- [ ] Four sections: People · Vehicles · Equipment · Materials
|
|
- [ ] Each section:
|
|
- Assigned list: avatar/icon + name + remove (×) button
|
|
- "Add" button → opens `<ui-dialog>` with searchable picker
|
|
- Picker: search input + scrollable list with checkboxes → "Add Selected" button
|
|
- Materials: quantity field next to each assigned item
|
|
- [ ] Empty section shows `<ui-empty>` inline: "No people assigned yet"
|
|
- [ ] Changes reflect immediately, API call on each add/remove
|
|
- [ ] Total counts shown in section header: "People (3)"
|
|
|
|
### 2.5 Photo Panel
|
|
**Backend:**
|
|
- [ ] `POST /api/work-orders/{id}/attachments` — multipart, fields: `file`, `phase`, `caption`, `step_id?`
|
|
- [ ] `GET /api/work-orders/{id}/attachments`
|
|
- [ ] `PUT /api/work-orders/{id}/attachments/{aid}` — update caption/phase
|
|
- [ ] `DELETE /api/work-orders/{id}/attachments/{aid}`
|
|
- [ ] Extract EXIF GPS on server if present in JPEG
|
|
- [ ] Generate thumbnail on upload (use `imaging` Go package, 400px wide JPEG)
|
|
- [ ] Serve originals at `/uploads/{path}`, thumbs at `/uploads/thumbs/{path}`
|
|
|
|
**Frontend `wo-photo-panel.mjs`:**
|
|
- [ ] Phase tabs at top: **All · Before · During · After** — filter photos by phase
|
|
- [ ] Photo grid: 3 columns desktop, 2 columns tablet, 2 columns mobile
|
|
- [ ] Each photo tile: thumbnail, hover overlay with caption + phase badge
|
|
- [ ] Click → lightbox modal with full-size image, prev/next arrows, swipe on mobile
|
|
- [ ] Upload button:
|
|
- Desktop: file picker (multi-select, drag-and-drop zone)
|
|
- Mobile: `<input type="file" accept="image/*" capture="environment">` for camera
|
|
- [ ] Upload flow: show thumbnail preview + phase selector + caption field before confirm
|
|
- [ ] Upload progress: per-file progress bar, queue multiple uploads
|
|
- [ ] Geo-tagged indicator: small pin icon if lat/lng present
|
|
- [ ] Empty state per phase: "No 'before' photos yet — capture site condition before starting"
|
|
|
|
### 2.6 Map & Location
|
|
**Backend:**
|
|
- [ ] `GET /api/work-orders/{id}/location` — return `{ lat, lng, address, site_name, access_notes }`
|
|
- [ ] `PUT /api/work-orders/{id}/location` — update coordinates + address
|
|
|
|
**Frontend `wo-map.mjs`:**
|
|
- [ ] Leaflet map, 300px height (expandable to full-screen on mobile)
|
|
- [ ] OpenStreetMap tile layer (no API key)
|
|
- [ ] Custom pin: teal SVG marker with WO number label
|
|
- [ ] "Get Directions" button → deeplink:
|
|
- iOS: `maps://maps.apple.com/?daddr={lat},{lng}`
|
|
- Android/other: `https://maps.google.com/maps?daddr={lat},{lng}`
|
|
- [ ] "Set Location" mode: click map to drop pin → updates lat/lng fields in WO form
|
|
- [ ] Geocode button in WO form: type address → calls OpenStreetMap Nominatim → fills lat/lng (free, no key)
|
|
- [ ] Access notes shown below map in an info box with lock icon
|
|
|
|
### 2.7 Mobile Responsiveness Audit
|
|
- [ ] All tap targets ≥ 44×44px
|
|
- [ ] No horizontal scroll on any page at 375px width
|
|
- [ ] Forms: single column, full-width inputs
|
|
- [ ] Tables: horizontal scroll container or card-based list on mobile
|
|
- [ ] Sidebar: hidden, accessible via hamburger → slides in as drawer with backdrop
|
|
- [ ] Bottom nav `app-mobile-nav.mjs` visible on ≤ 768px, hidden on desktop
|
|
- [ ] Photo capture uses `capture="environment"` on mobile
|
|
- [ ] Map "Get Directions" opens native maps app
|
|
- [ ] Dialogs: full-screen on mobile (`position: fixed; inset: 0`)
|
|
- [ ] Test breakpoints: 375px (iPhone SE), 390px (iPhone 14), 768px (iPad), 1024px, 1440px
|
|
|
|
---
|
|
|
|
## Phase 3 — Integrations & Admin
|
|
|
|
### 3.1 Accounting Codes
|
|
**Backend:**
|
|
- [ ] `004_accounting.sql` — `wo_accounting` table (wo_id, code_type, code_value, description)
|
|
- [ ] `GET /api/work-orders/{id}/accounting`
|
|
- [ ] `PUT /api/work-orders/{id}/accounting` — upsert all codes in one request `[{ code_type, code_value, description }]`
|
|
|
|
**Frontend `wo-accounting.mjs`:**
|
|
- [ ] Four fields in a clean two-column grid:
|
|
- GL Account Code (text + description)
|
|
- Cost Center / Department (text + description)
|
|
- Project WBS / Phase (text + description)
|
|
- Billing Reference (text + description)
|
|
- [ ] Each field: code input + description input on same row
|
|
- [ ] Auto-save on blur (no save button)
|
|
- [ ] Show "auto-populated from parent" chip if code came from parent record
|
|
- [ ] Future: lookup/typeahead from your chart of accounts
|
|
|
|
### 3.2 Polymorphic Parent Linking
|
|
- [ ] WO form: parent type select (None / Project / Trouble Ticket / Service Order)
|
|
- [ ] When type selected: show search/ID input for the parent record
|
|
- [ ] In WO detail header: linked parent shown as a chip with icon
|
|
```
|
|
📋 Project: Elm Street FTTH Build →
|
|
```
|
|
- [ ] Clicking the chip opens a drawer with parent record summary (Phase 4: deep link)
|
|
- [ ] Backend: `GET /api/work-orders?parent_type=project&parent_id=42` — list WOs for a given parent
|
|
|
|
### 3.3 Audit Log / Activity Timeline
|
|
**Backend:**
|
|
- [ ] `006_audit_log.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 email
|
|
- [ ] `PUT /api/users/{id}` — update name, role, active status
|
|
- [ ] `PUT /api/users/{id}/password` — admin reset
|
|
- [ ] `DELETE /api/users/{id}` — deactivate (soft delete)
|
|
- [ ] `GET /api/users/me` — current user profile
|
|
- [ ] `PUT /api/users/me` — update own display name, avatar
|
|
- [ ] `PUT /api/users/me/password` — change own password (requires current password)
|
|
|
|
**Frontend:**
|
|
- [ ] **`user-list.mjs`** — table: avatar, name, email, role badge, last login, active toggle, edit button
|
|
- [ ] **`user-form.mjs`** — dialog: display name, email, role select, active toggle, force password reset checkbox
|
|
- [ ] **`user-profile.mjs`** — own profile: avatar upload, display name, current password + new password fields
|
|
- [ ] Role badges with colors: Admin=danger, Dispatcher=teal, Field Tech=warning, Viewer=muted
|
|
|
|
### 3.5 Notifications
|
|
**Backend:**
|
|
- [ ] `wo_notifications` table: (id, user_id, wo_id, type, message, read, created_at)
|
|
- [ ] Create notifications when:
|
|
- WO assigned to you
|
|
- WO status changes (for creator and assignees)
|
|
- All steps completed (for dispatcher/admin)
|
|
- WO approaching scheduled time (30 min warning, cron job)
|
|
- [ ] `GET /api/notifications?unread=true` — list notifications
|
|
- [ ] `PUT /api/notifications/{id}/read` — mark read
|
|
- [ ] `PUT /api/notifications/read-all` — mark all read
|
|
- [ ] Email notification: Go `net/smtp` with HTML template, send on assignment
|
|
|
|
**Frontend:**
|
|
- [ ] Bell icon in topbar: unread count badge (red dot with number)
|
|
- [ ] Click bell → dropdown panel with notification list
|
|
- [ ] Each notification: icon, message, WO link, relative time, unread dot
|
|
- [ ] "Mark all read" button at top
|
|
- [ ] Click notification → navigate to WO detail + mark read
|
|
- [ ] Poll `/api/notifications?unread=true` every 60s for new count
|
|
|
|
### 3.6 Print / Field Packet
|
|
**Frontend `wo-print.mjs`:**
|
|
- [ ] Triggered by "Print" button in WO detail header
|
|
- [ ] Opens print-preview page (new tab or print dialog)
|
|
- [ ] Printed layout includes:
|
|
- WO number, title, site info, scheduled date
|
|
- All resource assignments (people, vehicles, equipment, materials)
|
|
- Full instructions + access notes
|
|
- Map static image (use Leaflet's `map.printPlugin` or OpenStreetMap static tiles)
|
|
- Numbered step checklist (checkbox squares for pen check-off)
|
|
- Accounting codes
|
|
- Photo thumbnails (before photos only, 2×3 grid)
|
|
- Signature / completion line
|
|
- [ ] `print.css`: hide sidebar, topbar, action buttons; show print-only elements; break pages sensibly
|
|
|
|
### 3.7 Reports
|
|
**Backend:**
|
|
- [ ] `GET /api/reports/summary` — total WOs by status, by priority, by date range
|
|
- [ ] `GET /api/reports/by-cost-center` — WO count and open/closed by cost center
|
|
- [ ] `GET /api/reports/by-resource` — WOs per person/vehicle over date range
|
|
- [ ] `GET /api/reports/overdue` — WOs past scheduled end still not closed
|
|
- [ ] `GET /api/reports/export?format=csv&status=closed&from=&to=` — CSV download
|
|
|
|
**Frontend:**
|
|
- [ ] **`report-root.mjs`** — filter bar: date range, status, cost center, parent type
|
|
- [ ] **`report-by-status.mjs`** — doughnut chart (Chart.js) + summary table
|
|
- [ ] **`report-by-cost.mjs`** — bar chart by cost center + table with WO list
|
|
- [ ] **`report-export.mjs`** — "Export to CSV" button + date range picker + format select
|
|
|
|
---
|
|
|
|
## Phase 4 — Polish & Optimization
|
|
|
|
### 4.1 Dashboard
|
|
**Backend:**
|
|
- [ ] `GET /api/dashboard` — single endpoint returns all KPIs:
|
|
```json
|
|
{
|
|
"open_count": 42,
|
|
"in_progress_count": 12,
|
|
"overdue_count": 5,
|
|
"closed_today": 8,
|
|
"by_status": [...],
|
|
"by_priority": [...],
|
|
"recent_activity": [...],
|
|
"upcoming": [...]
|
|
}
|
|
```
|
|
|
|
**Frontend `dash-root.mjs`:**
|
|
- [ ] **KPI row** — four `<dash-kpi-card>` components:
|
|
- Open Work Orders (blue, clipboard icon)
|
|
- In Progress (orange, activity icon)
|
|
- Overdue (red, alert-circle icon)
|
|
- Closed Today (green, check-circle icon)
|
|
- Each card: large number, trend indicator (↑↓ vs yesterday), icon, click → filtered WO list
|
|
|
|
- [ ] **Charts row** (2 columns):
|
|
- Doughnut: WOs by Status (Chart.js, use status colors from design system)
|
|
- Bar: WOs by Priority (horizontal bars)
|
|
|
|
- [ ] **Bottom row** (2 columns):
|
|
- Recent Activity feed (`<dash-recent-feed>`)
|
|
- Upcoming WOs this week (compact list, sorted by scheduled_start)
|
|
|
|
- [ ] Dashboard auto-refreshes every 2 minutes
|
|
- [ ] Skeleton loading state while data loads
|
|
|
|
### 4.2 Offline / PWA Basics
|
|
- [ ] `manifest.json` — app name, icons, `display: standalone`, theme color `#0D2137`
|
|
- [ ] `sw.js` — Service Worker:
|
|
- Cache shell (index.html, CSS, .mjs files) on install
|
|
- Cache API GET responses in IndexedDB for offline read
|
|
- Queue photo uploads when offline, sync on reconnect (Background Sync API)
|
|
- [ ] Offline indicator banner: "You're offline — viewing cached data"
|
|
- [ ] "Add to Home Screen" prompt on mobile
|
|
|
|
### 4.3 Rich Text Instructions
|
|
- [ ] Swap `<textarea>` for [Trix editor](https://trix-editor.org/) (no build needed, CDN available)
|
|
- [ ] Store as HTML in `instructions` column (`NVARCHAR(MAX)`)
|
|
- [ ] Display with sanitized `innerHTML` (strip `<script>` tags server-side)
|
|
|
|
### 4.4 Full-Text Search
|
|
- [ ] SQL Server `CONTAINS` or `FREETEXT` on `work_orders.title`, `description`, `instructions`, `site_name`
|
|
- [ ] Global search bar in topbar — results show WOs, people, equipment matching query
|
|
- [ ] Keyboard shortcut: `Ctrl+K` / `Cmd+K` opens search spotlight dialog
|
|
|
|
### 4.5 Scheduling Calendar
|
|
- [ ] **`wo-calendar.mjs`** — monthly/weekly view
|
|
- [ ] Each WO block: title, priority color stripe, assignee avatars
|
|
- [ ] Click WO block → detail drawer slides in without leaving calendar
|
|
- [ ] Drag WO block to reschedule (updates `scheduled_start`, `scheduled_end`)
|
|
|
|
### 4.6 Crew Scheduling View
|
|
- [ ] Gantt-style view: rows = people, columns = days
|
|
- [ ] Each WO shown as a bar on the assigned person's row
|
|
- [ ] Spot conflicts: overlapping WOs on same person highlighted in red
|
|
|
|
### 4.7 Additional Polish
|
|
- [ ] Dark mode: add `[data-theme="dark"]` overrides for all CSS vars, toggle stored in localStorage
|
|
- [ ] Keyboard navigation: all interactive elements focusable, visible focus rings, tab order logical
|
|
- [ ] Error boundaries: if a component throws, show error card instead of crashing whole page
|
|
- [ ] Rate limiting on API (chi middleware: 100 req/min per IP)
|
|
- [ ] API response compression (`gzip` middleware)
|
|
- [ ] Input sanitization: strip HTML from all text fields server-side before saving
|
|
- [ ] `ETag` / `Last-Modified` headers on GET responses for browser caching
|
|
|
|
---
|
|
|
|
## Component Inventory
|
|
|
|
| Component | File | Status | Phase |
|
|
|-----------|------|--------|-------|
|
|
| App shell | `app-root.mjs` | ⬜ | 1 |
|
|
| Sidebar | `app-sidebar.mjs` | ⬜ | 1 |
|
|
| Topbar | `app-topbar.mjs` | ⬜ | 1 |
|
|
| Mobile nav | `app-mobile-nav.mjs` | ⬜ | 1 |
|
|
| WO List | `wo-list.mjs` | ⬜ | 1 |
|
|
| WO Kanban | `wo-kanban.mjs` | ⬜ | 1 |
|
|
| WO Form | `wo-form.mjs` | ⬜ | 1 |
|
|
| WO Detail | `wo-detail.mjs` | ⬜ | 1 |
|
|
| Status Badge | `ui-badge.mjs` | ⬜ | 1 |
|
|
| Button | `ui-button.mjs` | ⬜ | 1 |
|
|
| Dialog | `ui-dialog.mjs` | ⬜ | 1 |
|
|
| Toast | `ui-toast.mjs` | ⬜ | 1 |
|
|
| Spinner | `ui-spinner.mjs` | ⬜ | 1 |
|
|
| Empty state | `ui-empty.mjs` | ⬜ | 1 |
|
|
| Checklist | `wo-checklist.mjs` | ⬜ | 2 |
|
|
| Resource panel | `wo-resource-panel.mjs` | ⬜ | 2 |
|
|
| Photo panel | `wo-photo-panel.mjs` | ⬜ | 2 |
|
|
| Map | `wo-map.mjs` | ⬜ | 2 |
|
|
| People list/form | `people-list.mjs` | ⬜ | 2 |
|
|
| Vehicle list/form | `vehicle-list.mjs` | ⬜ | 2 |
|
|
| Equipment list/form | `equipment-list.mjs` | ⬜ | 2 |
|
|
| Material list/form | `material-list.mjs` | ⬜ | 2 |
|
|
| Accounting | `wo-accounting.mjs` | ⬜ | 3 |
|
|
| Timeline | `wo-timeline.mjs` | ⬜ | 3 |
|
|
| Print packet | `wo-print.mjs` | ⬜ | 3 |
|
|
| User list/form | `user-list.mjs` | ⬜ | 3 |
|
|
| User profile | `user-profile.mjs` | ⬜ | 3 |
|
|
| Reports | `report-root.mjs` | ⬜ | 3 |
|
|
| Dashboard | `dash-root.mjs` | ⬜ | 4 |
|
|
| KPI card | `dash-kpi-card.mjs` | ⬜ | 4 |
|
|
| Calendar | `wo-calendar.mjs` | ⬜ | 4 |
|
|
| Search spotlight | (in `app-topbar.mjs`) | ⬜ | 4 |
|
|
|
|
---
|
|
|
|
## API Endpoints
|
|
|
|
### Auth
|
|
| Method | Path | Auth | Description |
|
|
|--------|------|------|-------------|
|
|
| POST | `/api/auth/login` | ✗ | Login, returns JWT + user |
|
|
| POST | `/api/auth/refresh` | ✓ | Refresh token |
|
|
| GET | `/api/auth/me` | ✓ | Current user |
|
|
|
|
### Work Orders
|
|
| Method | Path | Role | Description |
|
|
|--------|------|------|-------------|
|
|
| GET | `/api/work-orders` | viewer+ | List (filter: status, priority, search, parent, date) |
|
|
| POST | `/api/work-orders` | dispatcher+ | Create |
|
|
| GET | `/api/work-orders/{id}` | viewer+ | Full detail |
|
|
| PUT | `/api/work-orders/{id}` | dispatcher+ | Update |
|
|
| DELETE | `/api/work-orders/{id}` | admin | Soft delete |
|
|
| PUT | `/api/work-orders/{id}/status` | field_tech+ | Status transition |
|
|
| GET | `/api/work-orders/{id}/steps` | viewer+ | List steps |
|
|
| POST | `/api/work-orders/{id}/steps` | dispatcher+ | Add step |
|
|
| PUT | `/api/work-orders/{id}/steps/{sid}` | dispatcher+ | Edit step |
|
|
| PUT | `/api/work-orders/{id}/steps/reorder` | dispatcher+ | Bulk reorder |
|
|
| POST | `/api/work-orders/{id}/steps/{sid}/complete` | field_tech+ | Check off |
|
|
| POST | `/api/work-orders/{id}/steps/{sid}/uncomplete` | dispatcher+ | Undo check |
|
|
| DELETE | `/api/work-orders/{id}/steps/{sid}` | dispatcher+ | Delete step |
|
|
| GET | `/api/work-orders/{id}/resources` | viewer+ | List assignments |
|
|
| POST | `/api/work-orders/{id}/resources` | dispatcher+ | Assign resource |
|
|
| DELETE | `/api/work-orders/{id}/resources/{rid}` | dispatcher+ | Remove assignment |
|
|
| GET | `/api/work-orders/{id}/attachments` | viewer+ | List photos/files |
|
|
| POST | `/api/work-orders/{id}/attachments` | field_tech+ | Upload photo/file |
|
|
| PUT | `/api/work-orders/{id}/attachments/{aid}` | field_tech+ | Update caption/phase |
|
|
| DELETE | `/api/work-orders/{id}/attachments/{aid}` | dispatcher+ | Delete attachment |
|
|
| GET | `/api/work-orders/{id}/accounting` | viewer+ | Get accounting codes |
|
|
| PUT | `/api/work-orders/{id}/accounting` | dispatcher+ | Upsert accounting codes |
|
|
| GET | `/api/work-orders/{id}/activity` | viewer+ | Audit log feed |
|
|
| GET | `/api/work-orders/{id}/location` | viewer+ | Location data |
|
|
| PUT | `/api/work-orders/{id}/location` | dispatcher+ | Update location |
|
|
|
|
### Registries (Master Lists)
|
|
| Method | Path | Description |
|
|
|--------|------|-------------|
|
|
| GET | `/api/registry/people` | List people (search, active filter) |
|
|
| POST | `/api/registry/people` | Create person |
|
|
| PUT | `/api/registry/people/{id}` | Update person |
|
|
| DELETE | `/api/registry/people/{id}` | Deactivate person |
|
|
| GET/POST/PUT/DELETE | `/api/registry/vehicles` | Same CRUD for vehicles |
|
|
| GET/POST/PUT/DELETE | `/api/registry/equipment` | Same CRUD for equipment |
|
|
| GET/POST/PUT/DELETE | `/api/registry/materials` | Same CRUD for materials |
|
|
|
|
### Users & Notifications
|
|
| Method | Path | Role | Description |
|
|
|--------|------|------|-------------|
|
|
| GET | `/api/users` | admin | List all users |
|
|
| POST | `/api/users` | admin | Create user |
|
|
| PUT | `/api/users/{id}` | admin | Update user |
|
|
| DELETE | `/api/users/{id}` | admin | Deactivate |
|
|
| GET | `/api/users/me` | any | Own profile |
|
|
| PUT | `/api/users/me` | any | Update own profile |
|
|
| PUT | `/api/users/me/password` | any | Change password |
|
|
| GET | `/api/notifications` | any | List notifications |
|
|
| PUT | `/api/notifications/{id}/read` | any | Mark read |
|
|
| PUT | `/api/notifications/read-all` | any | Mark all read |
|
|
|
|
### Reports & Dashboard
|
|
| Method | Path | Role | Description |
|
|
|--------|------|------|-------------|
|
|
| GET | `/api/dashboard` | viewer+ | All KPIs in one call |
|
|
| GET | `/api/reports/summary` | viewer+ | WOs by status/priority |
|
|
| GET | `/api/reports/by-cost-center` | dispatcher+ | Cost center breakdown |
|
|
| GET | `/api/reports/by-resource` | dispatcher+ | Resource utilization |
|
|
| GET | `/api/reports/overdue` | dispatcher+ | Overdue WOs |
|
|
| GET | `/api/reports/export` | dispatcher+ | CSV download |
|
|
|
|
---
|
|
|
|
## Database Schema
|
|
|
|
### Full Table List
|
|
| Table | Purpose |
|
|
|-------|---------|
|
|
| `work_orders` | Core WO records |
|
|
| `wo_steps` | Ordered checklist steps |
|
|
| `wo_resources` | Resource assignments (polymorphic) |
|
|
| `wo_attachments` | Photos and file uploads |
|
|
| `wo_accounting` | GL/cost center/WBS codes |
|
|
| `wo_audit_log` | All change events |
|
|
| `wo_notifications` | User notification records |
|
|
| `resource_people` | People master list |
|
|
| `resource_vehicles` | Vehicle master list |
|
|
| `resource_equipment` | Equipment master list |
|
|
| `resource_materials` | Material/inventory master list |
|
|
| `users` | App users with roles |
|
|
|
|
### Key Patterns
|
|
- Soft deletes: `deleted_at DATETIME2 NULL` — never hard delete, always filter `WHERE deleted_at IS NULL`
|
|
- Audit timestamps: every table has `created_at`, `updated_at` (auto via `DEFAULT GETUTCDATE()`)
|
|
- Polymorphic parent: `parent_type NVARCHAR(50)`, `parent_id INT` — handles any parent record type
|
|
- Named params only: never string-concatenate SQL — always use `@param_name` or `@p1`
|
|
|
|
---
|
|
|
|
## Conventions
|
|
|
|
### CLAUDE.md Content (create this file in project root)
|
|
|
|
```markdown
|
|
# 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
|
|
// 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`)
|
|
```html
|
|
<!-- Fonts -->
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
|
|
<!-- Icons — Lucide (tree-shakeable, no API key) -->
|
|
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
|
|
|
|
<!-- Maps — Leaflet (no API key) -->
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9/dist/leaflet.css">
|
|
<script src="https://unpkg.com/leaflet@1.9/dist/leaflet.js"></script>
|
|
|
|
<!-- Charts — Chart.js -->
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
|
|
```
|
|
|
|
---
|
|
|
|
*Start with Phase 1 in order. Each checkbox is one commit. Don't skip CLAUDE.md — it must exist before any code is written.*
|
|
|
|
---
|
|
|
|
# Work Order System — Technical Build Plan
|
|
**Stack:** Go (backend API) · Pure JavaScript + Web Components (frontend) · SQL Server (MSSQL) · Docker
|
|
|
|
---
|
|
|
|
## Table of Contents
|
|
|
|
1. [Architecture Overview](#1-architecture-overview)
|
|
2. [Project Structure](#2-project-structure)
|
|
3. [Database Schema](#3-database-schema)
|
|
4. [Go Backend](#4-go-backend)
|
|
5. [REST API Design](#5-rest-api-design)
|
|
6. [Frontend — Web Components](#6-frontend--web-components)
|
|
7. [File & Photo Handling](#7-file--photo-handling)
|
|
8. [Maps & Location](#8-maps--location)
|
|
9. [Authentication & Authorization](#9-authentication--authorization)
|
|
10. [Docker Deployment](#10-docker-deployment)
|
|
11. [Phased Build Plan](#11-phased-build-plan)
|
|
12. [Development Conventions](#12-development-conventions)
|
|
|
|
---
|
|
|
|
## 1. Architecture Overview
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────â”
|
|
│ Browser Client │
|
|
│ ┌────────────────────────────────────────────────────┠│
|
|
│ │ Pure JS + Web Components SPA │ │
|
|
│ │ <wo-list> <wo-form> <wo-map> <wo-checklist> │ │
|
|
│ └───────────────────┬────────────────────────────────┘ │
|
|
└──────────────────────│──────────────────────────────────┘
|
|
│ REST/JSON over HTTPS
|
|
┌──────────────────────▼──────────────────────────────────â”
|
|
│ Go API Server │
|
|
│ ┌──────────┠┌──────────┠┌──────────┠│
|
|
│ │ Router │ │ Handlers │ │ Service │ │
|
|
│ │ (chi) │ │ │ │ Layer │ │
|
|
│ └──────────┘ └──────────┘ └──────────┘ │
|
|
│ ┌──────────┠┌──────────┠┌──────────┠│
|
|
│ │ Auth │ │ Upload │ │ Spatial │ │
|
|
│ │ (JWT) │ │ Handler │ │ Utils │ │
|
|
│ └──────────┘ └──────────┘ └──────────┘ │
|
|
└──────────────────────┬──────────────────────────────────┘
|
|
│ sqlx / database/sql
|
|
┌──────────────────────▼──────────────────────────────────â”
|
|
│ SQL Server (MSSQL) │
|
|
│ work_orders · wo_resources · wo_steps · wo_attachments │
|
|
│ wo_accounting · wo_parents · users · resource_registry │
|
|
└─────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
**Key decisions:**
|
|
- **No framework on the frontend** — Custom Elements v1, Shadow DOM, ES Modules. Works in all modern browsers with zero build step.
|
|
- **chi router** for Go — lightweight, idiomatic, stdlib-compatible middleware.
|
|
- **sqlx** for DB access — thin wrapper over `database/sql`, named params, struct scanning.
|
|
- **SQL Server** — matches your existing infrastructure; geography type for spatial data.
|
|
- **Multipart uploads** stored to local volume (or swap for Azure Blob / S3 later).
|
|
- **JWT** stateless auth — fits well with a SPA + API split.
|
|
|
|
---
|
|
|
|
## 2. Project Structure
|
|
|
|
```
|
|
workorder/
|
|
├── cmd/
|
|
│ └── server/
|
|
│ └── main.go # Entry point, wires everything together
|
|
├── internal/
|
|
│ ├── api/
|
|
│ │ ├── router.go # chi router setup, middleware chain
|
|
│ │ ├── middleware.go # JWT, CORS, logging, recovery
|
|
│ │ └── handlers/
|
|
│ │ ├── workorder.go # WO CRUD handlers
|
|
│ │ ├── resource.go # People / vehicle / equipment assignment
|
|
│ │ ├── step.go # Checklist steps
|
|
│ │ ├── attachment.go # Photo & file upload
|
|
│ │ ├── accounting.go # Accounting code endpoints
|
|
│ │ ├── parent.go # Parent record linking (polymorphic)
|
|
│ │ └── auth.go # Login / token refresh
|
|
│ ├── service/
|
|
│ │ ├── workorder.go # Business logic layer
|
|
│ │ ├── resource.go
|
|
│ │ ├── notification.go # Email/push when WO assigned
|
|
│ │ └── spatial.go # Geocoding, distance calc
|
|
│ ├── repository/
|
|
│ │ ├── workorder.go # SQL queries — work orders
|
|
│ │ ├── resource.go # SQL queries — resources
|
|
│ │ ├── step.go
|
|
│ │ ├── attachment.go
|
|
│ │ └── db.go # sqlx connection pool setup
|
|
│ ├── model/
|
|
│ │ ├── workorder.go # Structs: WorkOrder, WorkOrderSummary
|
|
│ │ ├── resource.go # Person, Vehicle, Equipment, Material
|
|
│ │ ├── step.go # Step, StepCompletion
|
|
│ │ ├── attachment.go # Attachment
|
|
│ │ ├── accounting.go # AccountingCode
|
|
│ │ └── auth.go # User, Claims
|
|
│ └── config/
|
|
│ └── config.go # Env-based config (DB DSN, JWT secret, upload path)
|
|
├── web/ # Frontend — served as static files by Go
|
|
│ ├── index.html # Shell — loads the app component
|
|
│ ├── app.mjs # <app-root> component, client-side router
|
|
│ ├── components/
|
|
│ │ ├── wo-list.mjs # Work order list / search
|
|
│ │ ├── wo-form.mjs # Create / edit work order
|
|
│ │ ├── wo-detail.mjs # Read-only detail view
|
|
│ │ ├── wo-checklist.mjs # Step checklist with check-off
|
|
│ │ ├── wo-resource-panel.mjs # Assign people, vehicles, equipment
|
|
│ │ ├── wo-photo-panel.mjs # Photo capture and gallery
|
|
│ │ ├── wo-map.mjs # Embedded map + directions
|
|
│ │ ├── wo-accounting.mjs # Accounting code fields
|
|
│ │ ├── wo-status-badge.mjs # Status pill (reusable)
|
|
│ │ └── wo-timeline.mjs # Activity / audit log
|
|
│ ├── lib/
|
|
│ │ ├── api.mjs # Fetch wrapper, auth header injection
|
|
│ │ ├── router.mjs # Tiny hash/history router
|
|
│ │ ├── store.mjs # Lightweight reactive state (no Redux)
|
|
│ │ └── utils.mjs # Formatters, validators
|
|
│ └── styles/
|
|
│ ├── global.css # CSS custom properties (design tokens)
|
|
│ └── reset.css
|
|
├── uploads/ # Stored attachments (bind-mounted in Docker)
|
|
├── migrations/
|
|
│ ├── 001_initial_schema.sql
|
|
│ ├── 002_resource_registry.sql
|
|
│ └── 003_spatial_fields.sql
|
|
├── docker-compose.yml
|
|
├── Dockerfile
|
|
└── .env.example
|
|
```
|
|
|
|
---
|
|
|
|
## 3. Database Schema
|
|
|
|
### Core Tables
|
|
|
|
```sql
|
|
-- ── Work Orders ───────────────────────────────────────────────────────────────
|
|
CREATE TABLE work_orders (
|
|
id INT IDENTITY PRIMARY KEY,
|
|
wo_number AS ('WO-' + RIGHT('000000' + CAST(id AS VARCHAR), 6)) PERSISTED,
|
|
title NVARCHAR(200) NOT NULL,
|
|
description NVARCHAR(MAX),
|
|
instructions NVARCHAR(MAX), -- Rich text / markdown
|
|
status NVARCHAR(30) NOT NULL DEFAULT 'draft',
|
|
-- draft | assigned | scheduled | in_progress | pending_review | closed
|
|
priority NVARCHAR(10) NOT NULL DEFAULT 'normal',
|
|
-- low | normal | high | urgent
|
|
scheduled_start DATETIME2,
|
|
scheduled_end DATETIME2,
|
|
actual_start DATETIME2,
|
|
actual_end DATETIME2,
|
|
-- Location
|
|
site_name NVARCHAR(200),
|
|
address NVARCHAR(400),
|
|
lat DECIMAL(10,7),
|
|
lng DECIMAL(10,7),
|
|
access_notes NVARCHAR(MAX), -- Gate codes, access roads
|
|
-- Polymorphic parent
|
|
parent_type NVARCHAR(50), -- 'project' | 'ticket' | 'service_order' | NULL
|
|
parent_id INT,
|
|
-- Audit
|
|
created_by INT NOT NULL,
|
|
created_at DATETIME2 NOT NULL DEFAULT GETUTCDATE(),
|
|
updated_at DATETIME2 NOT NULL DEFAULT GETUTCDATE(),
|
|
closed_at DATETIME2,
|
|
closed_by INT,
|
|
CONSTRAINT chk_wo_status CHECK (status IN ('draft','assigned','scheduled','in_progress','pending_review','closed')),
|
|
CONSTRAINT chk_wo_priority CHECK (priority IN ('low','normal','high','urgent'))
|
|
);
|
|
|
|
-- ── Resource Registry (master lists) ─────────────────────────────────────────
|
|
CREATE TABLE resource_people (
|
|
id INT IDENTITY PRIMARY KEY,
|
|
name NVARCHAR(100) NOT NULL,
|
|
role NVARCHAR(100),
|
|
email NVARCHAR(200),
|
|
phone NVARCHAR(30),
|
|
active BIT NOT NULL DEFAULT 1
|
|
);
|
|
|
|
CREATE TABLE resource_vehicles (
|
|
id INT IDENTITY PRIMARY KEY,
|
|
unit_number NVARCHAR(50) NOT NULL,
|
|
description NVARCHAR(200),
|
|
vehicle_type NVARCHAR(100),
|
|
active BIT NOT NULL DEFAULT 1
|
|
);
|
|
|
|
CREATE TABLE resource_equipment (
|
|
id INT IDENTITY PRIMARY KEY,
|
|
name NVARCHAR(200) NOT NULL,
|
|
asset_tag NVARCHAR(100),
|
|
category NVARCHAR(100),
|
|
active BIT NOT NULL DEFAULT 1
|
|
);
|
|
|
|
CREATE TABLE resource_materials (
|
|
id INT IDENTITY PRIMARY KEY,
|
|
name NVARCHAR(200) NOT NULL,
|
|
unit NVARCHAR(30), -- 'ft', 'each', 'box'
|
|
part_number NVARCHAR(100),
|
|
active BIT NOT NULL DEFAULT 1
|
|
);
|
|
|
|
-- ── Work Order Resource Assignments ───────────────────────────────────────────
|
|
CREATE TABLE wo_resources (
|
|
id INT IDENTITY PRIMARY KEY,
|
|
wo_id INT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE,
|
|
resource_type NVARCHAR(20) NOT NULL, -- 'person' | 'vehicle' | 'equipment' | 'material'
|
|
resource_id INT NOT NULL,
|
|
quantity DECIMAL(10,2), -- For materials
|
|
notes NVARCHAR(500),
|
|
assigned_at DATETIME2 NOT NULL DEFAULT GETUTCDATE(),
|
|
CONSTRAINT chk_resource_type CHECK (resource_type IN ('person','vehicle','equipment','material'))
|
|
);
|
|
|
|
-- ── Checklist Steps ───────────────────────────────────────────────────────────
|
|
CREATE TABLE wo_steps (
|
|
id INT IDENTITY PRIMARY KEY,
|
|
wo_id INT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE,
|
|
step_order INT NOT NULL,
|
|
title NVARCHAR(200) NOT NULL,
|
|
description NVARCHAR(MAX),
|
|
required BIT NOT NULL DEFAULT 1,
|
|
completed BIT NOT NULL DEFAULT 0,
|
|
completed_by INT,
|
|
completed_at DATETIME2,
|
|
notes NVARCHAR(MAX) -- Field notes added at completion
|
|
);
|
|
|
|
-- ── Attachments (photos, docs) ────────────────────────────────────────────────
|
|
CREATE TABLE wo_attachments (
|
|
id INT IDENTITY PRIMARY KEY,
|
|
wo_id INT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE,
|
|
step_id INT REFERENCES wo_steps(id), -- Optional: attach to a specific step
|
|
file_name NVARCHAR(500) NOT NULL,
|
|
file_path NVARCHAR(1000) NOT NULL, -- Relative path under /uploads
|
|
file_type NVARCHAR(100), -- MIME type
|
|
file_size BIGINT,
|
|
caption NVARCHAR(500),
|
|
phase NVARCHAR(20), -- 'before' | 'during' | 'after'
|
|
lat DECIMAL(10,7), -- Geo-tag from EXIF or device
|
|
lng DECIMAL(10,7),
|
|
uploaded_by INT NOT NULL,
|
|
uploaded_at DATETIME2 NOT NULL DEFAULT GETUTCDATE()
|
|
);
|
|
|
|
-- ── Accounting Codes ──────────────────────────────────────────────────────────
|
|
CREATE TABLE wo_accounting (
|
|
id INT IDENTITY PRIMARY KEY,
|
|
wo_id INT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE,
|
|
code_type NVARCHAR(50) NOT NULL, -- 'gl_account' | 'cost_center' | 'wbs' | 'billing_ref'
|
|
code_value NVARCHAR(200) NOT NULL,
|
|
description NVARCHAR(500),
|
|
CONSTRAINT uq_wo_accounting UNIQUE (wo_id, code_type)
|
|
);
|
|
|
|
-- ── Audit Log ─────────────────────────────────────────────────────────────────
|
|
CREATE TABLE wo_audit_log (
|
|
id INT IDENTITY PRIMARY KEY,
|
|
wo_id INT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE,
|
|
action NVARCHAR(100) NOT NULL,
|
|
old_value NVARCHAR(MAX),
|
|
new_value NVARCHAR(MAX),
|
|
performed_by INT NOT NULL,
|
|
performed_at DATETIME2 NOT NULL DEFAULT GETUTCDATE()
|
|
);
|
|
|
|
-- ── Indexes ───────────────────────────────────────────────────────────────────
|
|
CREATE INDEX ix_wo_status ON work_orders (status);
|
|
CREATE INDEX ix_wo_parent ON work_orders (parent_type, parent_id);
|
|
CREATE INDEX ix_wo_scheduled ON work_orders (scheduled_start);
|
|
CREATE INDEX ix_wo_resources ON wo_resources (wo_id, resource_type);
|
|
CREATE INDEX ix_wo_attachments ON wo_attachments (wo_id);
|
|
```
|
|
|
|
---
|
|
|
|
## 4. Go Backend
|
|
|
|
### `cmd/server/main.go`
|
|
|
|
```go
|
|
package main
|
|
|
|
import (
|
|
"log"
|
|
"net/http"
|
|
|
|
"github.com/yourorg/workorder/internal/api"
|
|
"github.com/yourorg/workorder/internal/config"
|
|
"github.com/yourorg/workorder/internal/repository"
|
|
)
|
|
|
|
func main() {
|
|
cfg := config.Load() // reads .env / environment variables
|
|
|
|
db, err := repository.Connect(cfg.DBDSN)
|
|
if err != nil {
|
|
log.Fatalf("db connect: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
r := api.NewRouter(cfg, db)
|
|
|
|
log.Printf("listening on %s", cfg.Addr)
|
|
if err := http.ListenAndServe(cfg.Addr, r); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
}
|
|
```
|
|
|
|
### `internal/config/config.go`
|
|
|
|
```go
|
|
package config
|
|
|
|
import "os"
|
|
|
|
type Config struct {
|
|
Addr string
|
|
DBDSN string
|
|
JWTSecret string
|
|
UploadPath string
|
|
BaseURL string
|
|
}
|
|
|
|
func Load() *Config {
|
|
return &Config{
|
|
Addr: env("ADDR", ":8080"),
|
|
DBDSN: env("DB_DSN", ""),
|
|
JWTSecret: env("JWT_SECRET", "change-me"),
|
|
UploadPath: env("UPLOAD_PATH", "./uploads"),
|
|
BaseURL: env("BASE_URL", "http://localhost:8080"),
|
|
}
|
|
}
|
|
|
|
func env(key, fallback string) string {
|
|
if v := os.Getenv(key); v != "" {
|
|
return v
|
|
}
|
|
return fallback
|
|
}
|
|
```
|
|
|
|
### `internal/api/router.go`
|
|
|
|
```go
|
|
package api
|
|
|
|
import (
|
|
"net/http"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/go-chi/chi/v5/middleware"
|
|
"github.com/yourorg/workorder/internal/api/handlers"
|
|
mw "github.com/yourorg/workorder/internal/api/middleware"
|
|
"github.com/yourorg/workorder/internal/config"
|
|
"github.com/jmoiron/sqlx"
|
|
)
|
|
|
|
func NewRouter(cfg *config.Config, db *sqlx.DB) http.Handler {
|
|
r := chi.NewRouter()
|
|
|
|
// Global middleware
|
|
r.Use(middleware.Logger)
|
|
r.Use(middleware.Recoverer)
|
|
r.Use(middleware.RealIP)
|
|
r.Use(mw.CORS)
|
|
|
|
// Serve frontend static files
|
|
r.Handle("/*", http.FileServer(http.Dir("./web")))
|
|
|
|
// Auth routes (no JWT required)
|
|
r.Post("/api/auth/login", handlers.NewAuthHandler(cfg, db).Login)
|
|
r.Post("/api/auth/refresh", handlers.NewAuthHandler(cfg, db).Refresh)
|
|
|
|
// Protected API routes
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(mw.JWT(cfg.JWTSecret))
|
|
|
|
wo := handlers.NewWorkOrderHandler(db)
|
|
r.Get("/api/work-orders", wo.List)
|
|
r.Post("/api/work-orders", wo.Create)
|
|
r.Get("/api/work-orders/{id}", wo.Get)
|
|
r.Put("/api/work-orders/{id}", wo.Update)
|
|
r.Delete("/api/work-orders/{id}", wo.Delete)
|
|
r.Put("/api/work-orders/{id}/status", wo.UpdateStatus)
|
|
|
|
res := handlers.NewResourceHandler(db)
|
|
r.Get("/api/work-orders/{id}/resources", res.List)
|
|
r.Post("/api/work-orders/{id}/resources", res.Assign)
|
|
r.Delete("/api/work-orders/{id}/resources/{rid}", res.Remove)
|
|
|
|
step := handlers.NewStepHandler(db)
|
|
r.Get("/api/work-orders/{id}/steps", step.List)
|
|
r.Post("/api/work-orders/{id}/steps", step.Create)
|
|
r.Put("/api/work-orders/{id}/steps/{sid}", step.Update)
|
|
r.Post("/api/work-orders/{id}/steps/{sid}/complete", step.Complete)
|
|
r.Delete("/api/work-orders/{id}/steps/{sid}", step.Delete)
|
|
|
|
att := handlers.NewAttachmentHandler(cfg, db)
|
|
r.Get("/api/work-orders/{id}/attachments", att.List)
|
|
r.Post("/api/work-orders/{id}/attachments", att.Upload)
|
|
r.Delete("/api/work-orders/{id}/attachments/{aid}", att.Delete)
|
|
r.Handle("/uploads/*", http.StripPrefix("/uploads/", http.FileServer(http.Dir(cfg.UploadPath))))
|
|
|
|
acc := handlers.NewAccountingHandler(db)
|
|
r.Get("/api/work-orders/{id}/accounting", acc.Get)
|
|
r.Put("/api/work-orders/{id}/accounting", acc.Upsert)
|
|
|
|
// Resource registries (master lists for assignment pickers)
|
|
r.Get("/api/registry/people", handlers.RegistryPeople(db))
|
|
r.Get("/api/registry/vehicles", handlers.RegistryVehicles(db))
|
|
r.Get("/api/registry/equipment", handlers.RegistryEquipment(db))
|
|
r.Get("/api/registry/materials", handlers.RegistryMaterials(db))
|
|
})
|
|
|
|
return r
|
|
}
|
|
```
|
|
|
|
### `internal/model/workorder.go`
|
|
|
|
```go
|
|
package model
|
|
|
|
import "time"
|
|
|
|
type WorkOrder struct {
|
|
ID int `db:"id" json:"id"`
|
|
WONumber string `db:"wo_number" json:"wo_number"`
|
|
Title string `db:"title" json:"title"`
|
|
Description string `db:"description" json:"description"`
|
|
Instructions string `db:"instructions" json:"instructions"`
|
|
Status string `db:"status" json:"status"`
|
|
Priority string `db:"priority" json:"priority"`
|
|
ScheduledStart *time.Time `db:"scheduled_start" json:"scheduled_start"`
|
|
ScheduledEnd *time.Time `db:"scheduled_end" json:"scheduled_end"`
|
|
ActualStart *time.Time `db:"actual_start" json:"actual_start"`
|
|
ActualEnd *time.Time `db:"actual_end" json:"actual_end"`
|
|
SiteName string `db:"site_name" json:"site_name"`
|
|
Address string `db:"address" json:"address"`
|
|
Lat *float64 `db:"lat" json:"lat"`
|
|
Lng *float64 `db:"lng" json:"lng"`
|
|
AccessNotes string `db:"access_notes" json:"access_notes"`
|
|
ParentType *string `db:"parent_type" json:"parent_type"`
|
|
ParentID *int `db:"parent_id" json:"parent_id"`
|
|
CreatedBy int `db:"created_by" json:"created_by"`
|
|
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
|
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
|
}
|
|
|
|
// Enriched view returned by GET /work-orders/{id}
|
|
type WorkOrderDetail struct {
|
|
WorkOrder
|
|
Resources []AssignedResource `json:"resources"`
|
|
Steps []Step `json:"steps"`
|
|
Attachments []Attachment `json:"attachments"`
|
|
Accounting []AccountingCode `json:"accounting"`
|
|
}
|
|
|
|
type WorkOrderListItem struct {
|
|
ID int `db:"id" json:"id"`
|
|
WONumber string `db:"wo_number" json:"wo_number"`
|
|
Title string `db:"title" json:"title"`
|
|
Status string `db:"status" json:"status"`
|
|
Priority string `db:"priority" json:"priority"`
|
|
SiteName string `db:"site_name" json:"site_name"`
|
|
ScheduledStart *time.Time `db:"scheduled_start" json:"scheduled_start"`
|
|
StepCount int `db:"step_count" json:"step_count"`
|
|
StepsDone int `db:"steps_done" json:"steps_done"`
|
|
PhotoCount int `db:"photo_count" json:"photo_count"`
|
|
}
|
|
```
|
|
|
|
### `internal/repository/workorder.go`
|
|
|
|
```go
|
|
package repository
|
|
|
|
import (
|
|
"context"
|
|
"github.com/jmoiron/sqlx"
|
|
"github.com/yourorg/workorder/internal/model"
|
|
)
|
|
|
|
type WorkOrderRepo struct{ db *sqlx.DB }
|
|
|
|
func NewWorkOrderRepo(db *sqlx.DB) *WorkOrderRepo {
|
|
return &WorkOrderRepo{db: db}
|
|
}
|
|
|
|
func (r *WorkOrderRepo) List(ctx context.Context, status, search string) ([]model.WorkOrderListItem, error) {
|
|
query := `
|
|
SELECT
|
|
wo.id, wo.wo_number, wo.title, wo.status, wo.priority,
|
|
wo.site_name, wo.scheduled_start,
|
|
COUNT(s.id) AS step_count,
|
|
SUM(CAST(s.completed AS INT)) AS steps_done,
|
|
COUNT(a.id) AS photo_count
|
|
FROM work_orders wo
|
|
LEFT JOIN wo_steps s ON s.wo_id = wo.id
|
|
LEFT JOIN wo_attachments a ON a.wo_id = wo.id
|
|
WHERE (@status = '' OR wo.status = @status)
|
|
AND (@search = '' OR wo.title LIKE '%' + @search + '%'
|
|
OR wo.wo_number LIKE '%' + @search + '%'
|
|
OR wo.site_name LIKE '%' + @search + '%')
|
|
GROUP BY wo.id, wo.wo_number, wo.title, wo.status,
|
|
wo.priority, wo.site_name, wo.scheduled_start
|
|
ORDER BY wo.scheduled_start DESC, wo.id DESC`
|
|
|
|
rows := []model.WorkOrderListItem{}
|
|
err := r.db.SelectContext(ctx, &rows, query,
|
|
sqlx.Named("status", status),
|
|
sqlx.Named("search", search),
|
|
)
|
|
return rows, err
|
|
}
|
|
|
|
func (r *WorkOrderRepo) GetByID(ctx context.Context, id int) (*model.WorkOrder, error) {
|
|
var wo model.WorkOrder
|
|
err := r.db.GetContext(ctx, &wo,
|
|
`SELECT * FROM work_orders WHERE id = @p1`, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &wo, nil
|
|
}
|
|
|
|
func (r *WorkOrderRepo) Create(ctx context.Context, wo *model.WorkOrder) (int, error) {
|
|
var id int
|
|
err := r.db.QueryRowContext(ctx, `
|
|
INSERT INTO work_orders
|
|
(title, description, instructions, status, priority,
|
|
scheduled_start, scheduled_end, site_name, address, lat, lng,
|
|
access_notes, parent_type, parent_id, created_by, updated_at)
|
|
OUTPUT INSERTED.id
|
|
VALUES
|
|
(@title, @description, @instructions, @status, @priority,
|
|
@scheduled_start, @scheduled_end, @site_name, @address, @lat, @lng,
|
|
@access_notes, @parent_type, @parent_id, @created_by, GETUTCDATE())`,
|
|
sqlx.Named("title", wo.Title),
|
|
// ... remaining named params
|
|
).Scan(&id)
|
|
return id, err
|
|
}
|
|
|
|
func (r *WorkOrderRepo) UpdateStatus(ctx context.Context, id int, status string, userID int) error {
|
|
_, err := r.db.ExecContext(ctx, `
|
|
UPDATE work_orders
|
|
SET status = @p1, updated_at = GETUTCDATE(),
|
|
actual_start = CASE WHEN @p1 = 'in_progress' AND actual_start IS NULL
|
|
THEN GETUTCDATE() ELSE actual_start END,
|
|
actual_end = CASE WHEN @p1 = 'closed' THEN GETUTCDATE() ELSE actual_end END,
|
|
closed_at = CASE WHEN @p1 = 'closed' THEN GETUTCDATE() ELSE closed_at END,
|
|
closed_by = CASE WHEN @p1 = 'closed' THEN @p2 ELSE closed_by END
|
|
WHERE id = @p3`, status, userID, id)
|
|
return err
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 5. REST API Design
|
|
|
|
| Method | Path | Description |
|
|
|--------|------|-------------|
|
|
| POST | `/api/auth/login` | Login, returns JWT |
|
|
| GET | `/api/work-orders?status=&search=&page=` | Paginated list |
|
|
| POST | `/api/work-orders` | Create work order |
|
|
| GET | `/api/work-orders/{id}` | Full detail (WO + resources + steps + photos + accounting) |
|
|
| PUT | `/api/work-orders/{id}` | Update work order fields |
|
|
| PUT | `/api/work-orders/{id}/status` | Status transition with audit |
|
|
| GET | `/api/work-orders/{id}/resources` | List assigned resources |
|
|
| POST | `/api/work-orders/{id}/resources` | Assign a resource |
|
|
| DELETE | `/api/work-orders/{id}/resources/{rid}` | Remove assignment |
|
|
| GET | `/api/work-orders/{id}/steps` | List checklist steps |
|
|
| POST | `/api/work-orders/{id}/steps` | Add a step |
|
|
| PUT | `/api/work-orders/{id}/steps/{sid}` | Edit step |
|
|
| POST | `/api/work-orders/{id}/steps/{sid}/complete` | Check off step |
|
|
| POST | `/api/work-orders/{id}/attachments` | Upload photo/file (multipart) |
|
|
| GET | `/api/work-orders/{id}/attachments` | List attachments |
|
|
| PUT | `/api/work-orders/{id}/accounting` | Upsert accounting codes |
|
|
| GET | `/api/registry/people` | Lookup: people for assignment picker |
|
|
| GET | `/api/registry/vehicles` | Lookup: vehicles |
|
|
| GET | `/api/registry/equipment` | Lookup: equipment |
|
|
| GET | `/api/registry/materials` | Lookup: materials |
|
|
|
|
### Standard Response Envelope
|
|
|
|
```json
|
|
{
|
|
"data": { ... },
|
|
"meta": { "page": 1, "per_page": 25, "total": 142 },
|
|
"error": null
|
|
}
|
|
```
|
|
|
|
### Status Transition Rules (enforced server-side)
|
|
|
|
```
|
|
draft → assigned → scheduled → in_progress → pending_review → closed
|
|
↘ (re-open) ↗
|
|
```
|
|
|
|
---
|
|
|
|
## 6. Frontend — Web Components
|
|
|
|
### Design Principles
|
|
|
|
- **No build step** — plain `<script type="module">` imports. Works directly in browser.
|
|
- **Shadow DOM** per component — style isolation, no CSS leakage.
|
|
- **Custom events** for cross-component communication — no shared global state pollution.
|
|
- **`api.mjs`** is the single HTTP layer — all fetch calls go through it.
|
|
|
|
### `web/lib/api.mjs`
|
|
|
|
```javascript
|
|
const BASE = '/api';
|
|
|
|
let _token = localStorage.getItem('wo_token') || '';
|
|
|
|
export function setToken(t) {
|
|
_token = t;
|
|
localStorage.setItem('wo_token', t);
|
|
}
|
|
|
|
async function request(method, path, body, isFormData = false) {
|
|
const headers = { Authorization: `Bearer ${_token}` };
|
|
if (!isFormData) headers['Content-Type'] = 'application/json';
|
|
|
|
const res = await fetch(BASE + path, {
|
|
method,
|
|
headers,
|
|
body: body
|
|
? (isFormData ? body : JSON.stringify(body))
|
|
: undefined,
|
|
});
|
|
|
|
if (res.status === 401) {
|
|
// Token expired — redirect to login
|
|
window.dispatchEvent(new CustomEvent('auth:expired'));
|
|
return;
|
|
}
|
|
|
|
const json = await res.mjson();
|
|
if (!res.ok) throw new Error(json.error || 'Request failed');
|
|
return json.data;
|
|
}
|
|
|
|
export const api = {
|
|
get: (path) => request('GET', path),
|
|
post: (path, body) => request('POST', path, body),
|
|
put: (path, body) => request('PUT', path, body),
|
|
delete: (path) => request('DELETE', path),
|
|
upload: (path, form) => request('POST', path, form, true),
|
|
};
|
|
```
|
|
|
|
### `web/lib/router.mjs`
|
|
|
|
```javascript
|
|
// Hash-based router — no server config needed
|
|
class Router {
|
|
#routes = [];
|
|
|
|
on(pattern, handler) {
|
|
this.#routes.push({ pattern: new URLPattern({ pathname: pattern }), handler });
|
|
return this;
|
|
}
|
|
|
|
start() {
|
|
const dispatch = () => {
|
|
const path = location.hash.slice(1) || '/';
|
|
for (const { pattern, handler } of this.#routes) {
|
|
const m = pattern.exec({ pathname: path });
|
|
if (m) { handler(m.pathname.groups); return; }
|
|
}
|
|
};
|
|
window.addEventListener('hashchange', dispatch);
|
|
dispatch();
|
|
}
|
|
}
|
|
|
|
export const router = new Router();
|
|
```
|
|
|
|
### `web/components/wo-list.mjs`
|
|
|
|
```javascript
|
|
import { api } from '../lib/api.mjs';
|
|
|
|
class WoList extends HTMLElement {
|
|
#data = [];
|
|
#status = '';
|
|
|
|
connectedCallback() {
|
|
this.attachShadow({ mode: 'open' });
|
|
this.#render();
|
|
this.#load();
|
|
}
|
|
|
|
async #load() {
|
|
const status = this.getAttribute('filter-status') || '';
|
|
this.#data = await api.get(`/work-orders?status=${status}`);
|
|
this.#render();
|
|
}
|
|
|
|
#render() {
|
|
this.shadowRoot.innerHTML = `
|
|
<style>
|
|
:host { display: block; }
|
|
.grid { display: grid; gap: 0.75rem; }
|
|
.card {
|
|
background: var(--surface, #fff);
|
|
border: 1px solid var(--border, #e2e8f0);
|
|
border-radius: 8px;
|
|
padding: 1rem 1.25rem;
|
|
cursor: pointer;
|
|
display: grid;
|
|
grid-template-columns: 1fr auto;
|
|
gap: 0.25rem 1rem;
|
|
transition: box-shadow .15s;
|
|
}
|
|
.card:hover { box-shadow: 0 4px 12px rgba(0,0,0,.08); }
|
|
.wo-number { font-size: .75rem; color: var(--muted); }
|
|
.title { font-weight: 600; color: var(--text); }
|
|
.progress { font-size: .8rem; color: var(--muted); }
|
|
</style>
|
|
<div class="grid">
|
|
${this.#data.map(wo => `
|
|
<div class="card" data-id="${wo.id}">
|
|
<div>
|
|
<div class="wo-number">${wo.wo_number}</div>
|
|
<div class="title">${wo.title}</div>
|
|
<div class="progress">
|
|
${wo.site_name} · ${wo.steps_done}/${wo.step_count} steps
|
|
</div>
|
|
</div>
|
|
<wo-status-badge status="${wo.status}"></wo-status-badge>
|
|
</div>
|
|
`).join('')}
|
|
</div>`;
|
|
|
|
this.shadowRoot.querySelectorAll('.card').forEach(card => {
|
|
card.addEventListener('click', () => {
|
|
this.dispatchEvent(new CustomEvent('wo:select',
|
|
{ detail: { id: card.dataset.id }, bubbles: true, composed: true }));
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
customElements.define('wo-list', WoList);
|
|
```
|
|
|
|
### `web/components/wo-resource-panel.mjs`
|
|
|
|
```javascript
|
|
import { api } from '../lib/api.mjs';
|
|
|
|
class WoResourcePanel extends HTMLElement {
|
|
#woId = null;
|
|
#resources = [];
|
|
#registry = { people: [], vehicles: [], equipment: [], materials: [] };
|
|
|
|
static get observedAttributes() { return ['wo-id']; }
|
|
|
|
attributeChangedCallback(name, _, val) {
|
|
if (name === 'wo-id') { this.#woId = val; this.#load(); }
|
|
}
|
|
|
|
connectedCallback() {
|
|
this.attachShadow({ mode: 'open' });
|
|
if (this.#woId) this.#load();
|
|
}
|
|
|
|
async #load() {
|
|
const [resources, people, vehicles, equipment, materials] = await Promise.all([
|
|
api.get(`/work-orders/${this.#woId}/resources`),
|
|
api.get('/registry/people'),
|
|
api.get('/registry/vehicles'),
|
|
api.get('/registry/equipment'),
|
|
api.get('/registry/materials'),
|
|
]);
|
|
this.#resources = resources;
|
|
this.#registry = { people, vehicles, equipment, materials };
|
|
this.#render();
|
|
}
|
|
|
|
async #assign(type, resourceId) {
|
|
await api.post(`/work-orders/${this.#woId}/resources`, {
|
|
resource_type: type, resource_id: Number(resourceId)
|
|
});
|
|
await this.#load();
|
|
}
|
|
|
|
async #remove(assignmentId) {
|
|
await api.delete(`/work-orders/${this.#woId}/resources/${assignmentId}`);
|
|
await this.#load();
|
|
}
|
|
|
|
#sectionHTML(label, type, list) {
|
|
const assigned = this.#resources.filter(r => r.resource_type === type);
|
|
const available = list.filter(item =>
|
|
!assigned.some(a => a.resource_id === item.id));
|
|
|
|
return `
|
|
<section>
|
|
<h3>${label}</h3>
|
|
<ul>
|
|
${assigned.map(a => `
|
|
<li>${a.name}
|
|
<button class="remove-btn" data-id="${a.id}">×</button>
|
|
</li>`).join('')}
|
|
</ul>
|
|
<select class="add-select" data-type="${type}">
|
|
<option value="">Add ${label.slice(0,-1)}...</option>
|
|
${available.map(item =>
|
|
`<option value="${item.id}">${item.name ?? item.unit_number}</option>`
|
|
).join('')}
|
|
</select>
|
|
</section>`;
|
|
}
|
|
|
|
#render() {
|
|
const { people, vehicles, equipment, materials } = this.#registry;
|
|
this.shadowRoot.innerHTML = `
|
|
<style>
|
|
:host { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
|
|
section { background: var(--surface); border-radius: 8px; padding: 1rem; }
|
|
h3 { margin: 0 0 .5rem; font-size: .9rem; text-transform: uppercase;
|
|
letter-spacing: .05em; color: var(--muted); }
|
|
ul { list-style: none; padding: 0; margin: 0 0 .5rem; }
|
|
li { display: flex; justify-content: space-between; padding: .25rem 0;
|
|
border-bottom: 1px solid var(--border); font-size: .9rem; }
|
|
select { width: 100%; padding: .4rem; border-radius: 6px;
|
|
border: 1px solid var(--border); }
|
|
.remove-btn { background: none; border: none; cursor: pointer;
|
|
color: var(--danger, #c0392b); font-size: 1.1rem; }
|
|
</style>
|
|
${this.#sectionHTML('People', 'person', people)}
|
|
${this.#sectionHTML('Vehicles', 'vehicle', vehicles)}
|
|
${this.#sectionHTML('Equipment', 'equipment', equipment)}
|
|
${this.#sectionHTML('Materials', 'material', materials)}`;
|
|
|
|
this.shadowRoot.querySelectorAll('.add-select').forEach(sel => {
|
|
sel.addEventListener('change', e => {
|
|
if (e.target.value) this.#assign(e.target.dataset.type, e.target.value);
|
|
});
|
|
});
|
|
this.shadowRoot.querySelectorAll('.remove-btn').forEach(btn => {
|
|
btn.addEventListener('click', () => this.#remove(btn.dataset.id));
|
|
});
|
|
}
|
|
}
|
|
|
|
customElements.define('wo-resource-panel', WoResourcePanel);
|
|
```
|
|
|
|
### `web/components/wo-checklist.mjs`
|
|
|
|
```javascript
|
|
import { api } from '../lib/api.mjs';
|
|
|
|
class WoChecklist extends HTMLElement {
|
|
#woId = null;
|
|
#steps = [];
|
|
|
|
static get observedAttributes() { return ['wo-id']; }
|
|
attributeChangedCallback(_, __, val) { this.#woId = val; this.#load(); }
|
|
connectedCallback() { this.attachShadow({ mode: 'open' }); }
|
|
|
|
async #load() {
|
|
this.#steps = await api.get(`/work-orders/${this.#woId}/steps`);
|
|
this.#render();
|
|
}
|
|
|
|
async #complete(stepId) {
|
|
await api.post(`/work-orders/${this.#woId}/steps/${stepId}/complete`, {});
|
|
await this.#load();
|
|
this.dispatchEvent(new CustomEvent('steps:updated', { bubbles: true, composed: true }));
|
|
}
|
|
|
|
#render() {
|
|
const done = this.#steps.filter(s => s.completed).length;
|
|
const total = this.#steps.length;
|
|
const pct = total ? Math.round(done / total * 100) : 0;
|
|
|
|
this.shadowRoot.innerHTML = `
|
|
<style>
|
|
.progress-bar { height: 6px; background: var(--border); border-radius: 3px; margin-bottom: 1rem; }
|
|
.progress-fill { height: 100%; border-radius: 3px;
|
|
background: var(--accent, #0a7ea4); width: ${pct}%; transition: width .3s; }
|
|
.step { display: flex; gap: .75rem; align-items: flex-start;
|
|
padding: .75rem 0; border-bottom: 1px solid var(--border); }
|
|
.step.done .step-title { text-decoration: line-through; color: var(--muted); }
|
|
input[type=checkbox] { width: 1.2rem; height: 1.2rem; accent-color: var(--accent); margin-top: .15rem; }
|
|
.step-title { font-weight: 500; }
|
|
.step-desc { font-size: .85rem; color: var(--muted); margin-top: .2rem; }
|
|
.summary { font-size: .85rem; color: var(--muted); margin-bottom: .5rem; }
|
|
</style>
|
|
<div class="progress-bar"><div class="progress-fill"></div></div>
|
|
<div class="summary">${done} of ${total} steps complete</div>
|
|
${this.#steps.map(s => `
|
|
<div class="step ${s.completed ? 'done' : ''}">
|
|
<input type="checkbox" data-id="${s.id}" ${s.completed ? 'checked disabled' : ''}>
|
|
<div>
|
|
<div class="step-title">${s.step_order}. ${s.title}</div>
|
|
${s.description ? `<div class="step-desc">${s.description}</div>` : ''}
|
|
</div>
|
|
</div>`).join('')}`;
|
|
|
|
this.shadowRoot.querySelectorAll('input[type=checkbox]:not([disabled])').forEach(cb => {
|
|
cb.addEventListener('change', () => this.#complete(cb.dataset.id));
|
|
});
|
|
}
|
|
}
|
|
|
|
customElements.define('wo-checklist', WoChecklist);
|
|
```
|
|
|
|
### `web/styles/global.css` — Design Tokens
|
|
|
|
```css
|
|
:root {
|
|
--navy: #0d2137;
|
|
--accent: #0a7ea4;
|
|
--accent-lt: #14b8d4;
|
|
--surface: #ffffff;
|
|
--bg: #f0f6fa;
|
|
--border: #e2ebf0;
|
|
--text: #1a2e3b;
|
|
--muted: #64748b;
|
|
--danger: #c0392b;
|
|
--success: #1d9d6c;
|
|
--warning: #e07b39;
|
|
|
|
--radius: 8px;
|
|
--shadow: 0 2px 8px rgba(0,0,0,.08);
|
|
|
|
font-family: Calibri, 'Segoe UI', system-ui, sans-serif;
|
|
font-size: 16px;
|
|
color: var(--text);
|
|
background: var(--bg);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 7. File & Photo Handling
|
|
|
|
### Go Upload Handler
|
|
|
|
```go
|
|
func (h *AttachmentHandler) Upload(w http.ResponseWriter, r *http.Request) {
|
|
woID := chi.URLParam(r, "id")
|
|
r.ParseMultipartForm(32 << 20) // 32 MB max
|
|
|
|
file, header, err := r.FormFile("file")
|
|
if err != nil { respondError(w, 400, "missing file"); return }
|
|
defer file.Close()
|
|
|
|
// Build storage path: uploads/{wo_id}/{uuid}{ext}
|
|
ext := filepath.Ext(header.Filename)
|
|
fname := uuid.New().String() + ext
|
|
relPath := filepath.Join(woID, fname)
|
|
absPath := filepath.Join(h.cfg.UploadPath, relPath)
|
|
|
|
os.MkdirAll(filepath.Dir(absPath), 0755)
|
|
dst, _ := os.Create(absPath)
|
|
defer dst.Close()
|
|
io.Copy(dst, file)
|
|
|
|
// Extract EXIF GPS if it's a JPEG
|
|
lat, lng := extractGPS(absPath)
|
|
|
|
// Save record
|
|
att := &model.Attachment{
|
|
WOID: mustInt(woID),
|
|
FileName: header.Filename,
|
|
FilePath: relPath,
|
|
FileType: header.Header.Get("Content-Type"),
|
|
FileSize: header.Size,
|
|
Phase: r.FormValue("phase"),
|
|
Caption: r.FormValue("caption"),
|
|
Lat: lat,
|
|
Lng: lng,
|
|
UploadedBy: userIDFromCtx(r.Context()),
|
|
}
|
|
id, _ := h.repo.Create(r.Context(), att)
|
|
respond(w, 201, map[string]any{"id": id, "url": "/uploads/" + relPath})
|
|
}
|
|
```
|
|
|
|
### Frontend Photo Capture (`wo-photo-panel.mjs` key logic)
|
|
|
|
```javascript
|
|
// Use camera on mobile devices
|
|
async #capturePhoto() {
|
|
const input = document.createElement('input');
|
|
input.type = 'file';
|
|
input.accept = 'image/*';
|
|
input.capture = 'environment'; // rear camera
|
|
input.onchange = async (e) => {
|
|
const file = e.target.files[0];
|
|
const form = new FormData();
|
|
form.append('file', file);
|
|
form.append('phase', this.#phase); // 'before' | 'during' | 'after'
|
|
|
|
// Attach GPS from browser if available
|
|
if (navigator.geolocation) {
|
|
await new Promise(res => navigator.geolocation.getCurrentPosition(pos => {
|
|
form.append('lat', pos.coords.latitude);
|
|
form.append('lng', pos.coords.longitude);
|
|
res();
|
|
}, res));
|
|
}
|
|
|
|
await api.upload(`/work-orders/${this.#woId}/attachments`, form);
|
|
await this.#load();
|
|
};
|
|
input.click();
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 8. Maps & Location
|
|
|
|
- **Leaflet.mjs** (open source, no API key required) loaded via CDN for the embedded map.
|
|
- **Google Maps Directions API** (or OpenRouteService if you want fully open source) for turn-by-turn routing.
|
|
- Coordinates stored as `lat`/`lng` decimals in SQL Server. No geography type required unless you add spatial radius queries later.
|
|
|
|
### `web/components/wo-map.mjs` skeleton
|
|
|
|
```javascript
|
|
// Leaflet loaded via <script> in index.html
|
|
class WoMap extends HTMLElement {
|
|
connectedCallback() {
|
|
this.style.display = 'block';
|
|
this.style.height = '300px';
|
|
const map = L.map(this).setView([0, 0], 13);
|
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png').addTo(map);
|
|
|
|
const lat = parseFloat(this.getAttribute('lat'));
|
|
const lng = parseFloat(this.getAttribute('lng'));
|
|
if (lat && lng) {
|
|
map.setView([lat, lng], 15);
|
|
L.marker([lat, lng]).addTo(map)
|
|
.bindPopup(this.getAttribute('site-name') || 'Work Site').openPopup();
|
|
}
|
|
}
|
|
}
|
|
customElements.define('wo-map', WoMap);
|
|
```
|
|
|
|
### Directions button
|
|
|
|
```javascript
|
|
// Opens Google Maps / Apple Maps turn-by-turn in native app on mobile
|
|
function openDirections(lat, lng, label) {
|
|
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
|
|
const url = isIOS
|
|
? `maps://maps.apple.com/?daddr=${lat},${lng}&dirflg=d`
|
|
: `https://maps.google.com/maps?daddr=${lat},${lng}`;
|
|
window.open(url, '_blank');
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 9. Authentication & Authorization
|
|
|
|
### JWT Middleware (Go)
|
|
|
|
```go
|
|
func JWT(secret string) func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
auth := r.Header.Get("Authorization")
|
|
if !strings.HasPrefix(auth, "Bearer ") {
|
|
http.Error(w, `{"error":"unauthorized"}`, 401); return
|
|
}
|
|
token, err := jwt.ParseWithClaims(strings.TrimPrefix(auth, "Bearer "),
|
|
&Claims{}, func(t *jwt.Token) (any, error) {
|
|
return []byte(secret), nil
|
|
})
|
|
if err != nil || !token.Valid {
|
|
http.Error(w, `{"error":"invalid token"}`, 401); return
|
|
}
|
|
claims := token.Claims.(*Claims)
|
|
ctx := context.WithValue(r.Context(), ctxKeyUser, claims)
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
})
|
|
}
|
|
}
|
|
```
|
|
|
|
### Roles to implement
|
|
|
|
| Role | Can Do |
|
|
|------|--------|
|
|
| `admin` | Full CRUD, close any WO, manage registries |
|
|
| `dispatcher` | Create & assign WOs, edit all fields |
|
|
| `field_tech` | View assigned WOs, complete steps, upload photos |
|
|
| `viewer` | Read-only access |
|
|
|
|
---
|
|
|
|
## 10. Docker Deployment
|
|
|
|
### `Dockerfile`
|
|
|
|
```dockerfile
|
|
# ── Build stage ───────────────────────────────────────────
|
|
FROM golang:1.23-alpine AS builder
|
|
WORKDIR /app
|
|
COPY go.mod go.sum ./
|
|
RUN go mod download
|
|
COPY . .
|
|
RUN CGO_ENABLED=0 go build -o workorder ./cmd/server
|
|
|
|
# ── Runtime stage ─────────────────────────────────────────
|
|
FROM alpine:3.20
|
|
WORKDIR /app
|
|
RUN apk add --no-cache tzdata ca-certificates
|
|
COPY --from=builder /app/workorder .
|
|
COPY web/ ./web/
|
|
|
|
EXPOSE 8080
|
|
CMD ["./workorder"]
|
|
```
|
|
|
|
### `docker-compose.yml`
|
|
|
|
```yaml
|
|
services:
|
|
api:
|
|
build: .
|
|
ports:
|
|
- "8080:8080"
|
|
environment:
|
|
ADDR: ":8080"
|
|
DB_DSN: "sqlserver://sa:${SA_PASSWORD}@mssql:1433?database=workorders"
|
|
JWT_SECRET: "${JWT_SECRET}"
|
|
UPLOAD_PATH: "/uploads"
|
|
volumes:
|
|
- uploads:/uploads
|
|
depends_on:
|
|
- mssql
|
|
restart: unless-stopped
|
|
|
|
mssql:
|
|
image: mcr.microsoft.com/mssql/server:2022-latest
|
|
environment:
|
|
SA_PASSWORD: "${SA_PASSWORD}"
|
|
ACCEPT_EULA: "Y"
|
|
MSSQL_PID: "Developer"
|
|
volumes:
|
|
- mssql_data:/var/opt/mssql
|
|
ports:
|
|
- "1433:1433" # Remove in production
|
|
|
|
volumes:
|
|
uploads:
|
|
mssql_data:
|
|
```
|
|
|
|
If you're connecting to your **existing SQL Server** instance, just point `DB_DSN` at it and drop the `mssql` service.
|
|
|
|
---
|
|
|
|
## 11. Phased Build Plan
|
|
|
|
### Phase 1 — Foundation (Weeks 1–4)
|
|
|
|
**Goal:** Working CRUD with project linking and status flow.
|
|
|
|
- [ ] Repo setup: Go module, chi, sqlx, godotenv
|
|
- [ ] DB migration: `work_orders` table + indexes
|
|
- [ ] Auth: login endpoint, JWT middleware, user table
|
|
- [ ] Work Order CRUD endpoints (list, get, create, update, status)
|
|
- [ ] Frontend shell: `index.html`, router, `api.mjs`, global CSS
|
|
- [ ] `<wo-list>` — list with search + filter by status
|
|
- [ ] `<wo-form>` — create / edit form with all base fields
|
|
- [ ] `<wo-status-badge>` — reusable status pill
|
|
- [ ] Docker Compose working locally
|
|
- [ ] Basic role checking (admin vs viewer)
|
|
|
|
### Phase 2 — Field Features (Weeks 5–8)
|
|
|
|
**Goal:** Everything a field crew needs on their phone.
|
|
|
|
- [ ] DB: `wo_steps`, `wo_resources`, resource registry tables
|
|
- [ ] Step endpoints + `<wo-checklist>` with check-off
|
|
- [ ] Resource registry endpoints + `<wo-resource-panel>` picker
|
|
- [ ] DB: `wo_attachments` table
|
|
- [ ] Photo upload endpoint with GPS extraction
|
|
- [ ] `<wo-photo-panel>` — capture, gallery, before/during/after phases
|
|
- [ ] `<wo-map>` — Leaflet map + site pin
|
|
- [ ] Directions button (native maps deeplink)
|
|
- [ ] Mobile-responsive layout (CSS Grid, touch-friendly targets)
|
|
|
|
### Phase 3 — Integrations (Weeks 9–12)
|
|
|
|
**Goal:** Connects to the rest of the system.
|
|
|
|
- [ ] DB: `wo_accounting` table
|
|
- [ ] `<wo-accounting>` — GL, cost center, WBS, billing ref fields
|
|
- [ ] Parent linking: polymorphic `parent_type`/`parent_id`
|
|
- [ ] Link WOs to existing Projects (query existing project IDs)
|
|
- [ ] Link to Trouble Tickets / Service Orders (when those APIs exist)
|
|
- [ ] Audit log table + `<wo-timeline>` component
|
|
- [ ] Email notification on assignment (Go `net/smtp` or SendGrid)
|
|
- [ ] Reporting endpoint: WOs by status, by cost center, by date range
|
|
|
|
### Phase 4 — Optimization (Week 13+)
|
|
|
|
- [ ] Dashboard `<wo-dashboard>` — KPI cards + charts (Chart.mjs via CDN)
|
|
- [ ] Crew scheduling calendar view
|
|
- [ ] Offline support (Service Worker + IndexedDB cache for field use)
|
|
- [ ] CSV / Excel export for accounting reconciliation
|
|
- [ ] Full-text search across WO title, site, instructions
|
|
- [ ] Webhook / API for third-party integrations
|
|
|
|
---
|
|
|
|
## 12. Development Conventions
|
|
|
|
### Go
|
|
|
|
```
|
|
Packages: lowercase, single word (handlers, repository, model)
|
|
Errors: always wrapped (fmt.Errorf("repo.Create: %w", err))
|
|
HTTP errors: JSON envelope {"error": "message"} with correct status code
|
|
DB queries: named params only @param_name — never string concat
|
|
Logging: log/slog structured key-value pairs
|
|
Tests: _test.go files table-driven, httptest.NewRecorder for handlers
|
|
```
|
|
|
|
### JavaScript
|
|
|
|
```
|
|
Components: kebab-case custom elements <wo-form>, <wo-map>
|
|
Private: # prefix for class fields #data, #load(), #render()
|
|
Events: colon namespaced 'wo:select', 'steps:updated', 'auth:expired'
|
|
API calls: always in the component no inline fetch(), always via api.mjs
|
|
Render: full innerHTML redraw simple, no virtual DOM needed at this scale
|
|
State: component-local lift to app-root only when truly shared
|
|
```
|
|
|
|
### Git Branch Strategy
|
|
|
|
```
|
|
main — production-ready, tagged releases
|
|
develop — integration branch
|
|
feature/* — individual features (feature/wo-checklist)
|
|
fix/* — bug fixes
|
|
```
|
|
|
|
---
|
|
|
|
## Go Dependencies
|
|
|
|
```go
|
|
// go.mod
|
|
require (
|
|
github.com/go-chi/chi/v5 v5.1.0
|
|
github.com/jmoiron/sqlx v1.3.5
|
|
github.com/microsoft/go-mssqldb v1.7.1
|
|
github.com/golang-jwt/jwt/v5 v5.2.1
|
|
github.com/google/uuid v1.6.0
|
|
github.com/joho/godotenv v1.5.1
|
|
)
|
|
```
|
|
|
|
## Frontend Dependencies (CDN, no npm)
|
|
|
|
```html
|
|
<!-- in index.html -->
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9/dist/leaflet.css">
|
|
<script src="https://unpkg.com/leaflet@1.9/dist/leaflet.mjs"></script>
|
|
<!-- Chart.mjs added in Phase 4 only -->
|
|
<script src="https://cdn.mjsdelivr.net/npm/chart.mjs@4/dist/chart.umd.min.mjs"></script>
|
|
```
|
|
|
|
Zero build tooling. Zero npm for the frontend. Open in browser and go.
|
|
|
|
## 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.
|
|
|
|
---
|
|
|
|
### Profile Step Types
|
|
|
|
Profile steps have a **type** that controls how they are displayed and completed in the field. Types are a fixed code-level constant — not a database table. Each type has optional **type_config** (stored as JSON) that gets set when the step is added to a profile.
|
|
|
|
#### Built-in Step Types
|
|
|
|
| Type | Value | Field behavior |
|
|
|------|-------|----------------|
|
|
| Work Step | `work_step` | Standard checkbox — mark done, optional note |
|
|
| Photo | `photo` | Camera capture required; config defines phase and prompt |
|
|
| Inspection | `inspection` | Pass / Fail / N/A buttons; config defines criteria text |
|
|
| Note | `note` | Free-text entry field; config defines the prompt label |
|
|
|
|
#### type_config JSON by Type
|
|
|
|
```json
|
|
// work_step — no config needed
|
|
{}
|
|
|
|
// photo
|
|
{
|
|
"phase": "before", // "before" | "during" | "after"
|
|
"caption_prompt": "Photo of existing equipment before any work begins"
|
|
}
|
|
|
|
// inspection
|
|
{
|
|
"criteria": "All cable slack coiled and secured per spec"
|
|
}
|
|
|
|
// note
|
|
{
|
|
"prompt": "Record any site hazards or access issues observed"
|
|
}
|
|
```
|
|
|
|
#### Example profile: Fiber Splice Job
|
|
|
|
```
|
|
1. [photo] Before — site overview { phase: "before", caption_prompt: "Wide shot of work area" }
|
|
2. [work_step] Power down splice enclosure
|
|
3. [work_step] Prep and strip fiber ends
|
|
4. [inspection] Check splice loss < 0.1 dB { criteria: "OTDR reading below 0.1 dB loss" }
|
|
5. [photo] Splice tray loaded and sealed { phase: "during", caption_prompt: "Tray with labeled splices" }
|
|
6. [work_step] Seal enclosure and torque to spec
|
|
7. [note] Record any deviations or notes { prompt: "Deviations from standard procedure?" }
|
|
8. [photo] After — completed enclosure { phase: "after", caption_prompt: "Sealed enclosure, final install" }
|
|
```
|
|
|
|
---
|
|
|
|
### Database
|
|
|
|
```sql
|
|
-- Profiles table
|
|
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = 'wo_profiles')
|
|
BEGIN
|
|
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()
|
|
);
|
|
END
|
|
|
|
-- Profile steps table (with step_type and type_config)
|
|
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = 'wo_profile_steps')
|
|
BEGIN
|
|
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,
|
|
step_type NVARCHAR(30) NOT NULL DEFAULT 'work_step',
|
|
-- 'work_step' | 'photo' | 'inspection' | 'note'
|
|
type_config NVARCHAR(MAX) -- JSON; shape depends on step_type
|
|
);
|
|
CREATE INDEX ix_profile_steps ON wo_profile_steps (profile_id, step_order);
|
|
END
|
|
|
|
-- WO steps also carry step_type and type_config (copied from profile on apply)
|
|
-- Migration: add columns if they do not exist
|
|
IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID('wo_steps') AND name = 'step_type')
|
|
ALTER TABLE wo_steps ADD step_type NVARCHAR(30) NOT NULL DEFAULT 'work_step';
|
|
IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID('wo_steps') AND name = 'type_config')
|
|
ALTER TABLE wo_steps ADD type_config NVARCHAR(MAX) NULL;
|
|
```
|
|
|
|
### 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 copy `step_type` and `type_config` from the profile step into `wo_steps`
|
|
- Both modes update `instructions` if currently blank and `priority` if WO is still `draft`
|
|
|
|
### Go Models
|
|
|
|
```go
|
|
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"`
|
|
StepCount int `db:"step_count" json:"step_count"`
|
|
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
|
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
|
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"`
|
|
StepType string `db:"step_type" json:"step_type"` // "work_step"|"photo"|"inspection"|"note"
|
|
TypeConfig string `db:"type_config" json:"type_config"` // JSON string
|
|
}
|
|
|
|
// Step also gains these two fields (in model/models.go)
|
|
// StepType string `db:"step_type" json:"step_type"`
|
|
// TypeConfig string `db:"type_config" json:"type_config"`
|
|
```
|
|
|
|
### 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
|
|
- When adding a step: type picker first (work_step / photo / inspection / note icon buttons)
|
|
- After type selected: type-specific config fields appear inline:
|
|
- `photo` → phase select (before/during/after) + caption prompt input
|
|
- `inspection` → criteria text input
|
|
- `note` → prompt label input
|
|
- `work_step` → no extra fields
|
|
- Active toggle in edit mode
|
|
|
|
**`web/components/work-orders/wo-checklist.mjs`**
|
|
- Each step renders differently based on `step_type`:
|
|
- `work_step` — large checkbox + title + optional note textarea
|
|
- `photo` — camera icon button labeled with `type_config.caption_prompt`; tapping opens photo panel pre-set to the configured phase; completed when a photo is attached to this step
|
|
- `inspection` — Pass / Fail / N/A button group; completed when any selection is made; result stored in completion notes
|
|
- `note` — text input labeled with `type_config.prompt`; completed when non-empty text is saved
|
|
- Progress bar counts all step types equally
|
|
|
|
**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
|
|
|
|
Under Resources section:
|
|
|
|
```
|
|
Profiles /registry/profiles (layout-template icon)
|
|
```
|
|
|
|
### apply-profile Handler (pseudocode)
|
|
|
|
```go
|
|
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, steps := // 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 steps {
|
|
db.Exec(`INSERT INTO wo_steps (wo_id, step_order, title, description, required, step_type, type_config)
|
|
VALUES (@p1, @p2, @p3, @p4, @p5, @p6, @p7)`,
|
|
woID, maxOrder+s.StepOrder, s.Title, s.Description, s.Required, s.StepType, s.TypeConfig)
|
|
}
|
|
|
|
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)
|
|
}
|
|
```
|