diff --git a/CLAUDE.md b/CLAUDE.md
index 933b20b..5daf83b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -3331,4 +3331,155 @@ require (
---
-*Start with Phase 1 in order. Each checkbox is one commit. Don't skip CLAUDE.md — it must exist before any code is written.*
\ No newline at end of file
+*Start with Phase 1 in order. Each checkbox is one commit. Don't skip CLAUDE.md — it must exist before any code is written.*
+
+---
+
+## Work Order Profiles
+
+### Overview
+
+A **Work Order Profile** is a reusable template that defines the default structure and behavior for a category of work orders. When a profile is applied to a work order (at creation or post-creation), its steps, instructions, priority, and duration are loaded in — then customized as needed.
+
+> **Key idea:** Profile = preset + flexibility. The profile defines the standard; each WO can deviate from it.
+
+### Database
+
+```sql
+-- Profiles
+CREATE TABLE wo_profiles (
+ id INT IDENTITY PRIMARY KEY,
+ name NVARCHAR(200) NOT NULL,
+ description NVARCHAR(MAX),
+ category NVARCHAR(100),
+ default_priority NVARCHAR(10) NOT NULL DEFAULT 'normal',
+ default_duration_hours INT,
+ default_instructions NVARCHAR(MAX),
+ active BIT NOT NULL DEFAULT 1,
+ created_at DATETIME2 NOT NULL DEFAULT GETUTCDATE(),
+ updated_at DATETIME2 NOT NULL DEFAULT GETUTCDATE()
+);
+
+CREATE TABLE wo_profile_steps (
+ id INT IDENTITY PRIMARY KEY,
+ profile_id INT NOT NULL REFERENCES wo_profiles(id) ON DELETE CASCADE,
+ step_order INT NOT NULL,
+ title NVARCHAR(200) NOT NULL,
+ description NVARCHAR(MAX),
+ required BIT NOT NULL DEFAULT 1
+);
+
+CREATE INDEX ix_profile_steps ON wo_profile_steps (profile_id, step_order);
+```
+
+### API Endpoints
+
+| Method | Path | Description |
+|--------|------|-------------|
+| GET | `/api/profiles` | List active profiles (search, category filter) |
+| POST | `/api/profiles` | Create profile |
+| GET | `/api/profiles/{id}` | Get profile with its steps |
+| PUT | `/api/profiles/{id}` | Update profile fields |
+| DELETE | `/api/profiles/{id}` | Soft delete (set active=0) |
+| GET | `/api/profiles/{id}/steps` | List profile steps |
+| POST | `/api/profiles/{id}/steps` | Add step to profile |
+| PUT | `/api/profiles/{id}/steps/{sid}` | Update profile step |
+| DELETE | `/api/profiles/{id}/steps/{sid}` | Remove step from profile |
+| POST | `/api/work-orders/{id}/apply-profile/{profileId}` | Apply profile to WO |
+
+#### apply-profile behavior
+
+Request body: `{ "mode": "append" | "replace" }`
+
+- **append** — inserts profile steps after existing steps (step_order continues from current max)
+- **replace** — deletes all existing steps, then inserts profile steps starting at order 1
+- Both modes update `instructions` if currently blank and `priority` if WO is still `draft`
+
+### Frontend Components
+
+**`web/components/registry/profile-list.mjs`**
+- List page at `/registry/profiles`
+- Cards: name, category badge, step count, active status
+- Search + category filter, edit/deactivate actions, "New Profile" button
+
+**`web/components/registry/profile-form.mjs`**
+- Modal dialog for create/edit
+- Fields: name (required), description, category, default priority, default duration hours, default instructions
+- Inline step editor: ordered step list with add / edit / reorder / delete
+- Active toggle in edit mode
+
+**WO Form integration (`wo-form.mjs`)**
+- "Load Profile" ghost button at top of form (shown only on new WOs or draft status)
+- Searchable profile picker dialog — on confirm pre-fills priority and instructions (if blank)
+- Steps applied server-side via apply-profile after the WO saves; checklist tab then refreshes
+
+**WO Detail integration (`wo-detail.mjs`)**
+- "Apply Profile" button in header actions alongside Edit / Change Status
+- Two-step dialog: (1) pick profile, (2) if WO has existing steps — prompt append vs replace
+- Calls `POST /api/work-orders/{id}/apply-profile/{profileId}` then refreshes Checklist tab
+
+### Sidebar Nav
+
+Add under Resources section:
+
+```
+Profiles /registry/profiles (layout-template icon)
+```
+
+### Go Models
+
+```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"`
+ Steps []ProfileStep `db:"-" json:"steps,omitempty"`
+}
+
+type ProfileStep struct {
+ ID int `db:"id" json:"id"`
+ ProfileID int `db:"profile_id" json:"profile_id"`
+ StepOrder int `db:"step_order" json:"step_order"`
+ Title string `db:"title" json:"title"`
+ Description string `db:"description" json:"description"`
+ Required bool `db:"required" json:"required"`
+}
+```
+
+### apply-profile Handler (pseudocode)
+
+```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 := // load profile + steps
+
+ if body.Mode == "replace" {
+ db.Exec(`DELETE FROM wo_steps WHERE wo_id = @p1`, woID)
+ }
+
+ var maxOrder int
+ db.Get(&maxOrder, `SELECT ISNULL(MAX(step_order), 0) FROM wo_steps WHERE wo_id = @p1`, woID)
+
+ for _, s := range profile.Steps {
+ db.Exec(`INSERT INTO wo_steps (wo_id, step_order, title, description, required)
+ VALUES (@p1, @p2, @p3, @p4, @p5)`,
+ woID, maxOrder+s.StepOrder, s.Title, s.Description, s.Required)
+ }
+
+ db.Exec(`
+ UPDATE work_orders SET
+ instructions = CASE WHEN (instructions IS NULL OR instructions = '') THEN @p1 ELSE instructions END,
+ priority = CASE WHEN status = 'draft' THEN @p2 ELSE priority END,
+ updated_at = GETUTCDATE()
+ WHERE id = @p3`,
+ profile.DefaultInstructions, profile.DefaultPriority, woID)
+}
+```
diff --git a/internal/api/handlers/activity.go b/internal/api/handlers/activity.go
new file mode 100644
index 0000000..c581137
--- /dev/null
+++ b/internal/api/handlers/activity.go
@@ -0,0 +1,42 @@
+package handlers
+
+import (
+ "net/http"
+
+ "github.com/jmoiron/sqlx"
+ "workorders/internal/model"
+)
+
+type ActivityHandler struct{ db *sqlx.DB }
+
+func NewActivityHandler(db *sqlx.DB) *ActivityHandler { return &ActivityHandler{db: db} }
+
+func (h *ActivityHandler) List(w http.ResponseWriter, r *http.Request) {
+ woID, err := intParam(r, "id")
+ if err != nil {
+ respondError(w, http.StatusBadRequest, "invalid id")
+ return
+ }
+ var rows []model.AuditEntry
+ err = h.db.Select(&rows, `
+ SELECT
+ a.id,
+ a.action,
+ ISNULL(a.old_value,'') AS old_value,
+ ISNULL(a.new_value,'') AS new_value,
+ ISNULL(u.display_name, ISNULL(u.username, CAST(a.performed_by AS NVARCHAR(100)))) AS performed_by,
+ a.performed_at
+ FROM wo_audit_log a
+ LEFT JOIN users u ON u.id = a.performed_by
+ WHERE a.wo_id = @p1
+ ORDER BY a.performed_at DESC`,
+ woID)
+ if err != nil {
+ respondError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ if rows == nil {
+ rows = []model.AuditEntry{}
+ }
+ respond(w, http.StatusOK, rows)
+}
diff --git a/internal/api/handlers/profile.go b/internal/api/handlers/profile.go
new file mode 100644
index 0000000..c2af61c
--- /dev/null
+++ b/internal/api/handlers/profile.go
@@ -0,0 +1,285 @@
+package handlers
+
+import (
+ "encoding/json"
+ "net/http"
+
+ "github.com/jmoiron/sqlx"
+ "workorders/internal/model"
+)
+
+type ProfileHandler struct{ db *sqlx.DB }
+
+func NewProfileHandler(db *sqlx.DB) *ProfileHandler { return &ProfileHandler{db: db} }
+
+const profileSelectCols = `
+ p.id, p.name, p.description, p.category, p.default_priority,
+ p.default_duration_hours, p.default_instructions, p.active,
+ p.created_at, p.updated_at,
+ COUNT(s.id) AS step_count`
+
+const profileGroupBy = `
+ GROUP BY p.id, p.name, p.description, p.category, p.default_priority,
+ p.default_duration_hours, p.default_instructions, p.active,
+ p.created_at, p.updated_at`
+
+// ── Profile CRUD ──────────────────────────────────────────────────────────────
+
+func (h *ProfileHandler) List(w http.ResponseWriter, r *http.Request) {
+ search := r.URL.Query().Get("search")
+ all := r.URL.Query().Get("all") == "1"
+ pat := "%" + search + "%"
+
+ base := `SELECT ` + profileSelectCols + `
+ FROM wo_profiles p
+ LEFT JOIN wo_profile_steps s ON s.profile_id = p.id`
+
+ var rows []model.Profile
+ var err error
+
+ switch {
+ case all && search != "":
+ err = h.db.Select(&rows, base+`
+ WHERE (p.name LIKE @p1 OR p.category LIKE @p1)`+profileGroupBy+` ORDER BY p.name`, pat)
+ case all:
+ err = h.db.Select(&rows, base+profileGroupBy+` ORDER BY p.name`)
+ case search != "":
+ err = h.db.Select(&rows, base+`
+ WHERE p.active=1 AND (p.name LIKE @p1 OR p.category LIKE @p1)`+profileGroupBy+` ORDER BY p.name`, pat)
+ default:
+ err = h.db.Select(&rows, base+`
+ WHERE p.active=1`+profileGroupBy+` ORDER BY p.name`)
+ }
+
+ if err != nil {
+ respondError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ if rows == nil {
+ rows = []model.Profile{}
+ }
+ respond(w, http.StatusOK, rows)
+}
+
+func (h *ProfileHandler) Get(w http.ResponseWriter, r *http.Request) {
+ id, err := intParam(r, "id")
+ if err != nil {
+ respondError(w, http.StatusBadRequest, "invalid id")
+ return
+ }
+ h.writeProfile(w, id)
+}
+
+func (h *ProfileHandler) Create(w http.ResponseWriter, r *http.Request) {
+ var body model.Profile
+ if err := decode(r, &body); err != nil || body.Name == "" {
+ respondError(w, http.StatusBadRequest, "name required")
+ return
+ }
+ if body.DefaultPriority == "" {
+ body.DefaultPriority = "normal"
+ }
+ var id int
+ err := h.db.QueryRow(`
+ INSERT INTO wo_profiles (name, description, category, default_priority, default_duration_hours, default_instructions)
+ OUTPUT INSERTED.id
+ VALUES (@p1, @p2, @p3, @p4, @p5, @p6)`,
+ body.Name, body.Description, body.Category, body.DefaultPriority,
+ body.DefaultDurationHours, body.DefaultInstructions,
+ ).Scan(&id)
+ if err != nil {
+ respondError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ h.writeProfile(w, id)
+}
+
+func (h *ProfileHandler) Update(w http.ResponseWriter, r *http.Request) {
+ id, err := intParam(r, "id")
+ if err != nil {
+ respondError(w, http.StatusBadRequest, "invalid id")
+ return
+ }
+ var body model.Profile
+ if err := decode(r, &body); err != nil || body.Name == "" {
+ respondError(w, http.StatusBadRequest, "name required")
+ return
+ }
+ if body.DefaultPriority == "" {
+ body.DefaultPriority = "normal"
+ }
+ _, err = h.db.Exec(`
+ UPDATE wo_profiles SET
+ name=@p1, description=@p2, category=@p3, default_priority=@p4,
+ default_duration_hours=@p5, default_instructions=@p6, active=@p7,
+ updated_at=GETUTCDATE()
+ WHERE id=@p8`,
+ body.Name, body.Description, body.Category, body.DefaultPriority,
+ body.DefaultDurationHours, body.DefaultInstructions, body.Active, id)
+ if err != nil {
+ respondError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ h.writeProfile(w, id)
+}
+
+func (h *ProfileHandler) Delete(w http.ResponseWriter, r *http.Request) {
+ id, err := intParam(r, "id")
+ if err != nil {
+ respondError(w, http.StatusBadRequest, "invalid id")
+ return
+ }
+ h.db.Exec(`UPDATE wo_profiles SET active=0, updated_at=GETUTCDATE() WHERE id=@p1`, id)
+ respond(w, http.StatusOK, map[string]any{"deactivated": id})
+}
+
+// writeProfile fetches a single profile with its steps and writes it to w.
+func (h *ProfileHandler) writeProfile(w http.ResponseWriter, id int) {
+ var p model.Profile
+ err := h.db.Get(&p, `SELECT `+profileSelectCols+`
+ FROM wo_profiles p
+ LEFT JOIN wo_profile_steps s ON s.profile_id = p.id
+ WHERE p.id = @p1`+profileGroupBy, id)
+ if err != nil {
+ respondError(w, http.StatusNotFound, "profile not found")
+ return
+ }
+ if err := h.db.Select(&p.Steps,
+ `SELECT * FROM wo_profile_steps WHERE profile_id=@p1 ORDER BY step_order`, id); err != nil {
+ p.Steps = []model.ProfileStep{}
+ }
+ respond(w, http.StatusOK, p)
+}
+
+// ── Profile Steps ─────────────────────────────────────────────────────────────
+
+func (h *ProfileHandler) ListSteps(w http.ResponseWriter, r *http.Request) {
+ id, err := intParam(r, "id")
+ if err != nil {
+ respondError(w, http.StatusBadRequest, "invalid id")
+ return
+ }
+ var steps []model.ProfileStep
+ if err := h.db.Select(&steps,
+ `SELECT * FROM wo_profile_steps WHERE profile_id=@p1 ORDER BY step_order`, id); err != nil {
+ steps = []model.ProfileStep{}
+ }
+ respond(w, http.StatusOK, steps)
+}
+
+func (h *ProfileHandler) CreateStep(w http.ResponseWriter, r *http.Request) {
+ profileID, err := intParam(r, "id")
+ if err != nil {
+ respondError(w, http.StatusBadRequest, "invalid id")
+ return
+ }
+ var body model.ProfileStep
+ if err := decode(r, &body); err != nil || body.Title == "" {
+ respondError(w, http.StatusBadRequest, "title required")
+ return
+ }
+ var maxOrder int
+ h.db.QueryRow(`SELECT ISNULL(MAX(step_order),0) FROM wo_profile_steps WHERE profile_id=@p1`, profileID).Scan(&maxOrder)
+
+ var sid int
+ err = h.db.QueryRow(`
+ INSERT INTO wo_profile_steps (profile_id, step_order, title, description, required)
+ OUTPUT INSERTED.id VALUES (@p1, @p2, @p3, @p4, @p5)`,
+ profileID, maxOrder+1, body.Title, body.Description, body.Required,
+ ).Scan(&sid)
+ if err != nil {
+ respondError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ var step model.ProfileStep
+ h.db.Get(&step, `SELECT * FROM wo_profile_steps WHERE id=@p1`, sid)
+ respond(w, http.StatusCreated, step)
+}
+
+func (h *ProfileHandler) UpdateStep(w http.ResponseWriter, r *http.Request) {
+ sid, err := intParam(r, "sid")
+ if err != nil {
+ respondError(w, http.StatusBadRequest, "invalid sid")
+ return
+ }
+ var body model.ProfileStep
+ if err := decode(r, &body); err != nil || body.Title == "" {
+ respondError(w, http.StatusBadRequest, "title required")
+ return
+ }
+ h.db.Exec(`UPDATE wo_profile_steps SET title=@p1, description=@p2, required=@p3, step_order=@p4 WHERE id=@p5`,
+ body.Title, body.Description, body.Required, body.StepOrder, sid)
+ var step model.ProfileStep
+ h.db.Get(&step, `SELECT * FROM wo_profile_steps WHERE id=@p1`, sid)
+ respond(w, http.StatusOK, step)
+}
+
+func (h *ProfileHandler) DeleteStep(w http.ResponseWriter, r *http.Request) {
+ sid, err := intParam(r, "sid")
+ if err != nil {
+ respondError(w, http.StatusBadRequest, "invalid sid")
+ return
+ }
+ h.db.Exec(`DELETE FROM wo_profile_steps WHERE id=@p1`, sid)
+ respond(w, http.StatusOK, map[string]any{"deleted": sid})
+}
+
+// ── Apply Profile to Work Order ───────────────────────────────────────────────
+
+func (h *ProfileHandler) Apply(w http.ResponseWriter, r *http.Request) {
+ woID, err := intParam(r, "id")
+ if err != nil {
+ respondError(w, http.StatusBadRequest, "invalid wo id")
+ return
+ }
+ profileID, err := intParam(r, "profileId")
+ if err != nil {
+ respondError(w, http.StatusBadRequest, "invalid profile id")
+ return
+ }
+
+ var body struct {
+ Mode string `json:"mode"` // "append" | "replace"
+ }
+ json.NewDecoder(r.Body).Decode(&body)
+ if body.Mode != "replace" {
+ body.Mode = "append"
+ }
+
+ var p model.Profile
+ if err := h.db.Get(&p, `SELECT * FROM wo_profiles WHERE id=@p1 AND active=1`, profileID); err != nil {
+ respondError(w, http.StatusNotFound, "profile not found")
+ return
+ }
+ var steps []model.ProfileStep
+ h.db.Select(&steps, `SELECT * FROM wo_profile_steps WHERE profile_id=@p1 ORDER BY step_order`, profileID)
+
+ if body.Mode == "replace" {
+ h.db.Exec(`DELETE FROM wo_steps WHERE wo_id=@p1`, woID)
+ }
+
+ var maxOrder int
+ h.db.QueryRow(`SELECT ISNULL(MAX(step_order),0) FROM wo_steps WHERE wo_id=@p1`, woID).Scan(&maxOrder)
+
+ for _, s := range steps {
+ h.db.Exec(`
+ INSERT INTO wo_steps (wo_id, step_order, title, description, required)
+ VALUES (@p1, @p2, @p3, @p4, @p5)`,
+ woID, maxOrder+s.StepOrder, s.Title, s.Description, s.Required)
+ }
+
+ // Fill instructions if blank; update priority only on draft WOs
+ h.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`,
+ p.DefaultInstructions, p.DefaultPriority, woID)
+
+ respond(w, http.StatusOK, map[string]any{
+ "applied": profileID,
+ "steps_added": len(steps),
+ "mode": body.Mode,
+ })
+}
diff --git a/internal/api/handlers/registry.go b/internal/api/handlers/registry.go
index 7d53c7b..aeeea6b 100644
--- a/internal/api/handlers/registry.go
+++ b/internal/api/handlers/registry.go
@@ -11,26 +11,298 @@ type RegistryHandler struct{ db *sqlx.DB }
func NewRegistryHandler(db *sqlx.DB) *RegistryHandler { return &RegistryHandler{db: db} }
-func (h *RegistryHandler) People(w http.ResponseWriter, r *http.Request) {
+// ── People ────────────────────────────────────────────────────────────────────
+
+func (h *RegistryHandler) ListPeople(w http.ResponseWriter, r *http.Request) {
+ search := r.URL.Query().Get("search")
+ all := r.URL.Query().Get("all") == "1"
+
var rows []model.RegistryPerson
- h.db.Select(&rows, `SELECT id,name,role,email,phone FROM resource_people WHERE active=1 ORDER BY name`)
+ var err error
+ pat := "%" + search + "%"
+ if all && search != "" {
+ err = h.db.Select(&rows, `SELECT id,name,role,email,phone,active FROM resource_people WHERE (name LIKE @p1 OR email LIKE @p1 OR role LIKE @p1) ORDER BY name`, pat)
+ } else if all {
+ err = h.db.Select(&rows, `SELECT id,name,role,email,phone,active FROM resource_people ORDER BY name`)
+ } else if search != "" {
+ err = h.db.Select(&rows, `SELECT id,name,role,email,phone,active FROM resource_people WHERE active=1 AND (name LIKE @p1 OR email LIKE @p1 OR role LIKE @p1) ORDER BY name`, pat)
+ } else {
+ err = h.db.Select(&rows, `SELECT id,name,role,email,phone,active FROM resource_people WHERE active=1 ORDER BY name`)
+ }
+ if err != nil {
+ respondError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ if rows == nil {
+ rows = []model.RegistryPerson{}
+ }
respond(w, http.StatusOK, rows)
}
-func (h *RegistryHandler) Vehicles(w http.ResponseWriter, r *http.Request) {
+func (h *RegistryHandler) CreatePerson(w http.ResponseWriter, r *http.Request) {
+ var body model.RegistryPerson
+ if err := decode(r, &body); err != nil || body.Name == "" {
+ respondError(w, http.StatusBadRequest, "name required")
+ return
+ }
+ var id int
+ err := h.db.QueryRow(`INSERT INTO resource_people (name,role,email,phone) OUTPUT INSERTED.id VALUES (@p1,@p2,@p3,@p4)`,
+ body.Name, body.Role, body.Email, body.Phone).Scan(&id)
+ if err != nil {
+ respondError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ var person model.RegistryPerson
+ h.db.Get(&person, `SELECT id,name,role,email,phone,active FROM resource_people WHERE id=@p1`, id)
+ respond(w, http.StatusCreated, person)
+}
+
+func (h *RegistryHandler) UpdatePerson(w http.ResponseWriter, r *http.Request) {
+ id, err := intParam(r, "id")
+ if err != nil {
+ respondError(w, http.StatusBadRequest, "invalid id")
+ return
+ }
+ var body model.RegistryPerson
+ if err := decode(r, &body); err != nil || body.Name == "" {
+ respondError(w, http.StatusBadRequest, "name required")
+ return
+ }
+ h.db.Exec(`UPDATE resource_people SET name=@p1,role=@p2,email=@p3,phone=@p4,active=@p5 WHERE id=@p6`,
+ body.Name, body.Role, body.Email, body.Phone, body.Active, id)
+ var person model.RegistryPerson
+ h.db.Get(&person, `SELECT id,name,role,email,phone,active FROM resource_people WHERE id=@p1`, id)
+ respond(w, http.StatusOK, person)
+}
+
+func (h *RegistryHandler) DeletePerson(w http.ResponseWriter, r *http.Request) {
+ id, err := intParam(r, "id")
+ if err != nil {
+ respondError(w, http.StatusBadRequest, "invalid id")
+ return
+ }
+ h.db.Exec(`UPDATE resource_people SET active=0 WHERE id=@p1`, id)
+ respond(w, http.StatusOK, map[string]any{"deactivated": id})
+}
+
+// ── Vehicles ──────────────────────────────────────────────────────────────────
+
+func (h *RegistryHandler) ListVehicles(w http.ResponseWriter, r *http.Request) {
+ search := r.URL.Query().Get("search")
+ all := r.URL.Query().Get("all") == "1"
+
var rows []model.RegistryVehicle
- h.db.Select(&rows, `SELECT id,unit_number,description,vehicle_type FROM resource_vehicles WHERE active=1 ORDER BY unit_number`)
+ var err error
+ pat := "%" + search + "%"
+ if all && search != "" {
+ err = h.db.Select(&rows, `SELECT id,unit_number,description,vehicle_type,active FROM resource_vehicles WHERE (unit_number LIKE @p1 OR description LIKE @p1 OR vehicle_type LIKE @p1) ORDER BY unit_number`, pat)
+ } else if all {
+ err = h.db.Select(&rows, `SELECT id,unit_number,description,vehicle_type,active FROM resource_vehicles ORDER BY unit_number`)
+ } else if search != "" {
+ err = h.db.Select(&rows, `SELECT id,unit_number,description,vehicle_type,active FROM resource_vehicles WHERE active=1 AND (unit_number LIKE @p1 OR description LIKE @p1) ORDER BY unit_number`, pat)
+ } else {
+ err = h.db.Select(&rows, `SELECT id,unit_number,description,vehicle_type,active FROM resource_vehicles WHERE active=1 ORDER BY unit_number`)
+ }
+ if err != nil {
+ respondError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ if rows == nil {
+ rows = []model.RegistryVehicle{}
+ }
respond(w, http.StatusOK, rows)
}
-func (h *RegistryHandler) Equipment(w http.ResponseWriter, r *http.Request) {
+func (h *RegistryHandler) CreateVehicle(w http.ResponseWriter, r *http.Request) {
+ var body model.RegistryVehicle
+ if err := decode(r, &body); err != nil || body.UnitNumber == "" {
+ respondError(w, http.StatusBadRequest, "unit_number required")
+ return
+ }
+ var id int
+ err := h.db.QueryRow(`INSERT INTO resource_vehicles (unit_number,description,vehicle_type) OUTPUT INSERTED.id VALUES (@p1,@p2,@p3)`,
+ body.UnitNumber, body.Description, body.VehicleType).Scan(&id)
+ if err != nil {
+ respondError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ var v model.RegistryVehicle
+ h.db.Get(&v, `SELECT id,unit_number,description,vehicle_type,active FROM resource_vehicles WHERE id=@p1`, id)
+ respond(w, http.StatusCreated, v)
+}
+
+func (h *RegistryHandler) UpdateVehicle(w http.ResponseWriter, r *http.Request) {
+ id, err := intParam(r, "id")
+ if err != nil {
+ respondError(w, http.StatusBadRequest, "invalid id")
+ return
+ }
+ var body model.RegistryVehicle
+ if err := decode(r, &body); err != nil || body.UnitNumber == "" {
+ respondError(w, http.StatusBadRequest, "unit_number required")
+ return
+ }
+ h.db.Exec(`UPDATE resource_vehicles SET unit_number=@p1,description=@p2,vehicle_type=@p3,active=@p4 WHERE id=@p5`,
+ body.UnitNumber, body.Description, body.VehicleType, body.Active, id)
+ var v model.RegistryVehicle
+ h.db.Get(&v, `SELECT id,unit_number,description,vehicle_type,active FROM resource_vehicles WHERE id=@p1`, id)
+ respond(w, http.StatusOK, v)
+}
+
+func (h *RegistryHandler) DeleteVehicle(w http.ResponseWriter, r *http.Request) {
+ id, err := intParam(r, "id")
+ if err != nil {
+ respondError(w, http.StatusBadRequest, "invalid id")
+ return
+ }
+ h.db.Exec(`UPDATE resource_vehicles SET active=0 WHERE id=@p1`, id)
+ respond(w, http.StatusOK, map[string]any{"deactivated": id})
+}
+
+// ── Equipment ─────────────────────────────────────────────────────────────────
+
+func (h *RegistryHandler) ListEquipment(w http.ResponseWriter, r *http.Request) {
+ search := r.URL.Query().Get("search")
+ all := r.URL.Query().Get("all") == "1"
+
var rows []model.RegistryEquipment
- h.db.Select(&rows, `SELECT id,name,asset_tag,category FROM resource_equipment WHERE active=1 ORDER BY name`)
+ var err error
+ pat := "%" + search + "%"
+ if all && search != "" {
+ err = h.db.Select(&rows, `SELECT id,name,asset_tag,category,active FROM resource_equipment WHERE (name LIKE @p1 OR asset_tag LIKE @p1 OR category LIKE @p1) ORDER BY name`, pat)
+ } else if all {
+ err = h.db.Select(&rows, `SELECT id,name,asset_tag,category,active FROM resource_equipment ORDER BY name`)
+ } else if search != "" {
+ err = h.db.Select(&rows, `SELECT id,name,asset_tag,category,active FROM resource_equipment WHERE active=1 AND (name LIKE @p1 OR asset_tag LIKE @p1) ORDER BY name`, pat)
+ } else {
+ err = h.db.Select(&rows, `SELECT id,name,asset_tag,category,active FROM resource_equipment WHERE active=1 ORDER BY name`)
+ }
+ if err != nil {
+ respondError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ if rows == nil {
+ rows = []model.RegistryEquipment{}
+ }
respond(w, http.StatusOK, rows)
}
-func (h *RegistryHandler) Materials(w http.ResponseWriter, r *http.Request) {
+func (h *RegistryHandler) CreateEquipment(w http.ResponseWriter, r *http.Request) {
+ var body model.RegistryEquipment
+ if err := decode(r, &body); err != nil || body.Name == "" {
+ respondError(w, http.StatusBadRequest, "name required")
+ return
+ }
+ var id int
+ err := h.db.QueryRow(`INSERT INTO resource_equipment (name,asset_tag,category) OUTPUT INSERTED.id VALUES (@p1,@p2,@p3)`,
+ body.Name, body.AssetTag, body.Category).Scan(&id)
+ if err != nil {
+ respondError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ var eq model.RegistryEquipment
+ h.db.Get(&eq, `SELECT id,name,asset_tag,category,active FROM resource_equipment WHERE id=@p1`, id)
+ respond(w, http.StatusCreated, eq)
+}
+
+func (h *RegistryHandler) UpdateEquipment(w http.ResponseWriter, r *http.Request) {
+ id, err := intParam(r, "id")
+ if err != nil {
+ respondError(w, http.StatusBadRequest, "invalid id")
+ return
+ }
+ var body model.RegistryEquipment
+ if err := decode(r, &body); err != nil || body.Name == "" {
+ respondError(w, http.StatusBadRequest, "name required")
+ return
+ }
+ h.db.Exec(`UPDATE resource_equipment SET name=@p1,asset_tag=@p2,category=@p3,active=@p4 WHERE id=@p5`,
+ body.Name, body.AssetTag, body.Category, body.Active, id)
+ var eq model.RegistryEquipment
+ h.db.Get(&eq, `SELECT id,name,asset_tag,category,active FROM resource_equipment WHERE id=@p1`, id)
+ respond(w, http.StatusOK, eq)
+}
+
+func (h *RegistryHandler) DeleteEquipment(w http.ResponseWriter, r *http.Request) {
+ id, err := intParam(r, "id")
+ if err != nil {
+ respondError(w, http.StatusBadRequest, "invalid id")
+ return
+ }
+ h.db.Exec(`UPDATE resource_equipment SET active=0 WHERE id=@p1`, id)
+ respond(w, http.StatusOK, map[string]any{"deactivated": id})
+}
+
+// ── Materials ─────────────────────────────────────────────────────────────────
+
+func (h *RegistryHandler) ListMaterials(w http.ResponseWriter, r *http.Request) {
+ search := r.URL.Query().Get("search")
+ all := r.URL.Query().Get("all") == "1"
+
var rows []model.RegistryMaterial
- h.db.Select(&rows, `SELECT id,name,unit,part_number FROM resource_materials WHERE active=1 ORDER BY name`)
+ var err error
+ pat := "%" + search + "%"
+ if all && search != "" {
+ err = h.db.Select(&rows, `SELECT id,name,unit,part_number,active FROM resource_materials WHERE (name LIKE @p1 OR part_number LIKE @p1) ORDER BY name`, pat)
+ } else if all {
+ err = h.db.Select(&rows, `SELECT id,name,unit,part_number,active FROM resource_materials ORDER BY name`)
+ } else if search != "" {
+ err = h.db.Select(&rows, `SELECT id,name,unit,part_number,active FROM resource_materials WHERE active=1 AND (name LIKE @p1 OR part_number LIKE @p1) ORDER BY name`, pat)
+ } else {
+ err = h.db.Select(&rows, `SELECT id,name,unit,part_number,active FROM resource_materials WHERE active=1 ORDER BY name`)
+ }
+ if err != nil {
+ respondError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ if rows == nil {
+ rows = []model.RegistryMaterial{}
+ }
respond(w, http.StatusOK, rows)
}
+
+func (h *RegistryHandler) CreateMaterial(w http.ResponseWriter, r *http.Request) {
+ var body model.RegistryMaterial
+ if err := decode(r, &body); err != nil || body.Name == "" {
+ respondError(w, http.StatusBadRequest, "name required")
+ return
+ }
+ var id int
+ err := h.db.QueryRow(`INSERT INTO resource_materials (name,unit,part_number) OUTPUT INSERTED.id VALUES (@p1,@p2,@p3)`,
+ body.Name, body.Unit, body.PartNumber).Scan(&id)
+ if err != nil {
+ respondError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ var m model.RegistryMaterial
+ h.db.Get(&m, `SELECT id,name,unit,part_number,active FROM resource_materials WHERE id=@p1`, id)
+ respond(w, http.StatusCreated, m)
+}
+
+func (h *RegistryHandler) UpdateMaterial(w http.ResponseWriter, r *http.Request) {
+ id, err := intParam(r, "id")
+ if err != nil {
+ respondError(w, http.StatusBadRequest, "invalid id")
+ return
+ }
+ var body model.RegistryMaterial
+ if err := decode(r, &body); err != nil || body.Name == "" {
+ respondError(w, http.StatusBadRequest, "name required")
+ return
+ }
+ h.db.Exec(`UPDATE resource_materials SET name=@p1,unit=@p2,part_number=@p3,active=@p4 WHERE id=@p5`,
+ body.Name, body.Unit, body.PartNumber, body.Active, id)
+ var m model.RegistryMaterial
+ h.db.Get(&m, `SELECT id,name,unit,part_number,active FROM resource_materials WHERE id=@p1`, id)
+ respond(w, http.StatusOK, m)
+}
+
+func (h *RegistryHandler) DeleteMaterial(w http.ResponseWriter, r *http.Request) {
+ id, err := intParam(r, "id")
+ if err != nil {
+ respondError(w, http.StatusBadRequest, "invalid id")
+ return
+ }
+ h.db.Exec(`UPDATE resource_materials SET active=0 WHERE id=@p1`, id)
+ respond(w, http.StatusOK, map[string]any{"deactivated": id})
+}
diff --git a/internal/api/handlers/step.go b/internal/api/handlers/step.go
index e5b84b4..8913813 100644
--- a/internal/api/handlers/step.go
+++ b/internal/api/handlers/step.go
@@ -93,6 +93,18 @@ func (h *StepHandler) Complete(w http.ResponseWriter, r *http.Request) {
respond(w, http.StatusOK, step)
}
+func (h *StepHandler) Uncomplete(w http.ResponseWriter, r *http.Request) {
+ sid, err := intParam(r, "sid")
+ if err != nil {
+ respondError(w, http.StatusBadRequest, "invalid sid")
+ return
+ }
+ h.db.Exec(`UPDATE wo_steps SET completed=0,completed_by=NULL,completed_at=NULL,notes='' WHERE id=@p1`, sid)
+ var step model.Step
+ h.db.Get(&step, `SELECT * FROM wo_steps WHERE id=@p1`, sid)
+ respond(w, http.StatusOK, step)
+}
+
func (h *StepHandler) Delete(w http.ResponseWriter, r *http.Request) {
sid, err := intParam(r, "sid")
if err != nil {
diff --git a/internal/api/router.go b/internal/api/router.go
index 4f4ecf3..bd399ab 100644
--- a/internal/api/router.go
+++ b/internal/api/router.go
@@ -20,6 +20,9 @@ func NewRouter(cfg *config.Config, db *sqlx.DB) http.Handler {
// Serve frontend static files
r.Handle("/*", http.FileServer(http.Dir("./web")))
+ // Public routes — uploads served without auth (paths are UUIDs, not guessable)
+ r.Handle("/uploads/*", http.StripPrefix("/uploads/", http.FileServer(http.Dir(cfg.UploadPath))))
+
// Public auth routes
auth := handlers.NewAuthHandler(db, cfg)
r.Post("/api/auth/login", auth.Login)
@@ -44,6 +47,7 @@ func NewRouter(cfg *config.Config, db *sqlx.DB) http.Handler {
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.Post("/api/work-orders/{id}/steps/{sid}/uncomplete", step.Uncomplete)
r.Delete("/api/work-orders/{id}/steps/{sid}", step.Delete)
res := handlers.NewResourceHandler(db)
@@ -55,17 +59,46 @@ func NewRouter(cfg *config.Config, db *sqlx.DB) http.Handler {
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)
+ act := handlers.NewActivityHandler(db)
+ r.Get("/api/work-orders/{id}/activity", act.List)
+
+ prof := handlers.NewProfileHandler(db)
+ r.Get("/api/profiles", prof.List)
+ r.Post("/api/profiles", prof.Create)
+ r.Get("/api/profiles/{id}", prof.Get)
+ r.Put("/api/profiles/{id}", prof.Update)
+ r.Delete("/api/profiles/{id}", prof.Delete)
+ r.Get("/api/profiles/{id}/steps", prof.ListSteps)
+ r.Post("/api/profiles/{id}/steps", prof.CreateStep)
+ r.Put("/api/profiles/{id}/steps/{sid}", prof.UpdateStep)
+ r.Delete("/api/profiles/{id}/steps/{sid}", prof.DeleteStep)
+ r.Post("/api/work-orders/{id}/apply-profile/{profileId}", prof.Apply)
+
reg := handlers.NewRegistryHandler(db)
- r.Get("/api/registry/people", reg.People)
- r.Get("/api/registry/vehicles", reg.Vehicles)
- r.Get("/api/registry/equipment", reg.Equipment)
- r.Get("/api/registry/materials", reg.Materials)
+ r.Get("/api/registry/people", reg.ListPeople)
+ r.Post("/api/registry/people", reg.CreatePerson)
+ r.Put("/api/registry/people/{id}", reg.UpdatePerson)
+ r.Delete("/api/registry/people/{id}", reg.DeletePerson)
+
+ r.Get("/api/registry/vehicles", reg.ListVehicles)
+ r.Post("/api/registry/vehicles", reg.CreateVehicle)
+ r.Put("/api/registry/vehicles/{id}", reg.UpdateVehicle)
+ r.Delete("/api/registry/vehicles/{id}", reg.DeleteVehicle)
+
+ r.Get("/api/registry/equipment", reg.ListEquipment)
+ r.Post("/api/registry/equipment", reg.CreateEquipment)
+ r.Put("/api/registry/equipment/{id}", reg.UpdateEquipment)
+ r.Delete("/api/registry/equipment/{id}", reg.DeleteEquipment)
+
+ r.Get("/api/registry/materials", reg.ListMaterials)
+ r.Post("/api/registry/materials", reg.CreateMaterial)
+ r.Put("/api/registry/materials/{id}", reg.UpdateMaterial)
+ r.Delete("/api/registry/materials/{id}", reg.DeleteMaterial)
})
return r
diff --git a/internal/model/models.go b/internal/model/models.go
index f75e0c2..af6e68e 100644
--- a/internal/model/models.go
+++ b/internal/model/models.go
@@ -101,11 +101,12 @@ type AccountingCode struct {
// ── Resource Registry ─────────────────────────────────────────────────────────
type RegistryPerson struct {
- ID int `db:"id" json:"id"`
- Name string `db:"name" json:"name"`
- Role string `db:"role" json:"role"`
- Email string `db:"email" json:"email"`
- Phone string `db:"phone" json:"phone"`
+ ID int `db:"id" json:"id"`
+ Name string `db:"name" json:"name"`
+ Role string `db:"role" json:"role"`
+ Email string `db:"email" json:"email"`
+ Phone string `db:"phone" json:"phone"`
+ Active bool `db:"active" json:"active"`
}
type RegistryVehicle struct {
@@ -113,6 +114,7 @@ type RegistryVehicle struct {
UnitNumber string `db:"unit_number" json:"unit_number"`
Description string `db:"description" json:"description"`
VehicleType string `db:"vehicle_type" json:"vehicle_type"`
+ Active bool `db:"active" json:"active"`
}
type RegistryEquipment struct {
@@ -120,6 +122,7 @@ type RegistryEquipment struct {
Name string `db:"name" json:"name"`
AssetTag string `db:"asset_tag" json:"asset_tag"`
Category string `db:"category" json:"category"`
+ Active bool `db:"active" json:"active"`
}
type RegistryMaterial struct {
@@ -127,6 +130,42 @@ type RegistryMaterial struct {
Name string `db:"name" json:"name"`
Unit string `db:"unit" json:"unit"`
PartNumber string `db:"part_number" json:"part_number"`
+ Active bool `db:"active" json:"active"`
+}
+
+type AuditEntry struct {
+ ID int `db:"id" json:"id"`
+ Action string `db:"action" json:"action"`
+ OldValue string `db:"old_value" json:"old_value"`
+ NewValue string `db:"new_value" json:"new_value"`
+ PerformedBy string `db:"performed_by" json:"performed_by"`
+ PerformedAt time.Time `db:"performed_at" json:"performed_at"`
+}
+
+// ── Work Order Profiles ───────────────────────────────────────────────────────
+
+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"`
}
// ── Users & Auth ──────────────────────────────────────────────────────────────
diff --git a/migrations/007_profiles.sql b/migrations/007_profiles.sql
new file mode 100644
index 0000000..1d562f8
--- /dev/null
+++ b/migrations/007_profiles.sql
@@ -0,0 +1,31 @@
+-- Work Order Profiles
+
+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
+
+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
+ );
+
+ CREATE INDEX ix_profile_steps ON wo_profile_steps (profile_id, step_order);
+END
diff --git a/web/app.mjs b/web/app.mjs
index 459e00e..d45df5d 100644
--- a/web/app.mjs
+++ b/web/app.mjs
@@ -13,6 +13,17 @@ import './components/work-orders/wo-list.mjs';
import './components/work-orders/wo-kanban.mjs';
import './components/work-orders/wo-form.mjs';
import './components/work-orders/wo-detail.mjs';
+import './components/work-orders/wo-checklist.mjs';
+import './components/work-orders/wo-resource-panel.mjs';
+import './components/work-orders/wo-photo-panel.mjs';
+import './components/work-orders/wo-map.mjs';
+import './components/work-orders/wo-accounting.mjs';
+import './components/work-orders/wo-timeline.mjs';
+import './components/registry/people-list.mjs';
+import './components/registry/vehicle-list.mjs';
+import './components/registry/equipment-list.mjs';
+import './components/registry/material-list.mjs';
+import './components/registry/profile-list.mjs';
import { getUser, setToken, clearToken } from './lib/auth.mjs';
import { api } from './lib/api.mjs';
@@ -121,10 +132,11 @@ function startApp() {
.on('/work-orders/new', () => appRoot.setPage(''))
.on('/work-orders/:id/edit', ({ id }) => appRoot.setPage(``))
.on('/work-orders/:id', ({ id }) => appRoot.setPage(``))
- .on('/registry/people', () => appRoot.setPage('
People registry — Phase 2
'))
- .on('/registry/vehicles', () => appRoot.setPage('Vehicles registry — Phase 2
'))
- .on('/registry/equipment', () => appRoot.setPage('Equipment registry — Phase 2
'))
- .on('/registry/materials', () => appRoot.setPage('Materials registry — Phase 2
'))
+ .on('/registry/people', () => appRoot.setPage(''))
+ .on('/registry/vehicles', () => appRoot.setPage(''))
+ .on('/registry/equipment', () => appRoot.setPage(''))
+ .on('/registry/materials', () => appRoot.setPage(''))
+ .on('/registry/profiles', () => appRoot.setPage(''))
.on('/reports', () => appRoot.setPage('Reports — Phase 3
'))
.on('/users', () => appRoot.setPage('User management — Phase 3
'))
.on('/settings', () => appRoot.setPage('Settings — Phase 4
'))
diff --git a/web/components/layout/app-sidebar.mjs b/web/components/layout/app-sidebar.mjs
index af58ad7..50435ba 100644
--- a/web/components/layout/app-sidebar.mjs
+++ b/web/components/layout/app-sidebar.mjs
@@ -8,6 +8,7 @@ const NAV = [
{ label: 'Vehicles', href: '/registry/vehicles', icon: 'truck', section: 'resources' },
{ label: 'Equipment', href: '/registry/equipment', icon: 'wrench', section: 'resources' },
{ label: 'Materials', href: '/registry/materials', icon: 'package', section: 'resources' },
+ { label: 'Profiles', href: '/registry/profiles', icon: 'layout-template', section: 'resources' },
{ label: 'Reports', href: '/reports', icon: 'bar-chart-2', section: 'operations' },
];
diff --git a/web/components/registry/equipment-form.mjs b/web/components/registry/equipment-form.mjs
new file mode 100644
index 0000000..b019bf1
--- /dev/null
+++ b/web/components/registry/equipment-form.mjs
@@ -0,0 +1,115 @@
+import { api } from '../../lib/api.mjs';
+
+const CATEGORIES = ['Fusion Splicer', 'OTDR', 'Blower', 'Reel', 'Generator', 'Power Meter', 'Cable Locator', 'Other'];
+
+class EquipmentForm extends HTMLElement {
+ #onSave = null;
+
+ connectedCallback() {
+ if (!this.shadowRoot) {
+ this.attachShadow({ mode: 'open' });
+ this.shadowRoot.innerHTML = ``;
+ }
+ }
+
+ open(equipment, onSave) {
+ this.#onSave = onSave;
+ const isEdit = !!equipment;
+ const d = this.shadowRoot.querySelector('dialog');
+ d.innerHTML = `
+
+
+
+ `;
+
+ if (window.lucide) lucide.createIcons({ nodes: [d] });
+ d.querySelector('#dlg-close').addEventListener('click', () => d.close());
+ d.querySelector('#dlg-cancel').addEventListener('click', () => d.close());
+
+ d.querySelector('#dlg-save').addEventListener('click', async () => {
+ const name = d.querySelector('#eq-name').value.trim();
+ const errEl = d.querySelector('#form-error');
+ if (!name) { errEl.textContent = 'Name is required'; d.querySelector('#eq-name').focus(); return; }
+ errEl.textContent = '';
+
+ const saveBtn = d.querySelector('#dlg-save');
+ saveBtn.disabled = true;
+ saveBtn.textContent = 'Saving…';
+
+ const payload = {
+ name,
+ asset_tag: d.querySelector('#eq-tag').value.trim(),
+ category: d.querySelector('#eq-cat').value,
+ active: isEdit ? d.querySelector('#eq-active').checked : true,
+ };
+
+ try {
+ if (isEdit) await api.put(`/registry/equipment/${equipment.id}`, payload);
+ else await api.post('/registry/equipment', payload);
+ d.close();
+ window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: isEdit ? 'Equipment updated' : 'Equipment added', type: 'success' } }));
+ this.#onSave?.();
+ } catch (err) {
+ errEl.textContent = err.message;
+ saveBtn.disabled = false;
+ saveBtn.textContent = isEdit ? 'Save Changes' : 'Add Equipment';
+ }
+ });
+
+ d.showModal();
+ d.querySelector('#eq-name').focus();
+ }
+
+ #esc(s) { return (s || '').replace(/&/g,'&').replace(//g,'>'); }
+}
+
+customElements.define('equipment-form', EquipmentForm);
diff --git a/web/components/registry/equipment-list.mjs b/web/components/registry/equipment-list.mjs
new file mode 100644
index 0000000..6d703c8
--- /dev/null
+++ b/web/components/registry/equipment-list.mjs
@@ -0,0 +1,129 @@
+import { api } from '../../lib/api.mjs';
+import './equipment-form.mjs';
+
+class EquipmentList extends HTMLElement {
+ #items = [];
+ #loading = true;
+ #search = '';
+
+ connectedCallback() {
+ if (!this.shadowRoot) this.attachShadow({ mode: 'open' });
+ this.#load();
+ }
+
+ async #load() {
+ this.#loading = true;
+ this.#render();
+ try {
+ this.#items = await api.get(`/registry/equipment?all=1${this.#search ? '&search=' + encodeURIComponent(this.#search) : ''}`) || [];
+ } catch { this.#items = []; }
+ this.#loading = false;
+ this.#render();
+ }
+
+ async #deactivate(id) {
+ try {
+ await api.delete(`/registry/equipment/${id}`);
+ window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: 'Equipment deactivated', type: 'success' } }));
+ await this.#load();
+ } catch (err) {
+ window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: err.message, type: 'error' } }));
+ }
+ }
+
+ #render() {
+ const s = this.shadowRoot;
+ s.innerHTML = `
+
+
+
+
+ ${this.#loading ? '' :
+ this.#items.length === 0
+ ? `
+
+ No equipment found
+
`
+ : `
+ ${this.#items.map(eq => `
+
+
+
+
${this.#esc(eq.name)}
+
+ ${eq.asset_tag ? `${this.#esc(eq.asset_tag)}` : ''}
+ ${eq.category ? `${this.#esc(eq.category)}` : ''}
+
+
+
${eq.active ? 'Active' : 'Inactive'}
+
+
+ ${eq.active ? `` : ''}
+
+
`).join('')}
+
`}
+
+ `;
+
+ if (window.lucide) lucide.createIcons({ nodes: [s] });
+
+ const searchEl = s.querySelector('#search');
+ let debounce;
+ searchEl.addEventListener('input', () => {
+ clearTimeout(debounce);
+ debounce = setTimeout(() => { this.#search = searchEl.value; this.#load(); }, 350);
+ });
+
+ s.querySelector('#new-btn').addEventListener('click', () =>
+ s.querySelector('#equipment-form').open(null, () => this.#load()));
+
+ s.querySelectorAll('[data-edit]').forEach(btn => {
+ const eq = this.#items.find(x => x.id === +btn.dataset.edit);
+ btn.addEventListener('click', () => s.querySelector('#equipment-form').open(eq, () => this.#load()));
+ });
+
+ s.querySelectorAll('[data-deactivate]').forEach(btn =>
+ btn.addEventListener('click', () => this.#deactivate(+btn.dataset.deactivate)));
+ }
+
+ #esc(s) { return (s || '').replace(/&/g,'&').replace(//g,'>'); }
+}
+
+customElements.define('equipment-list', EquipmentList);
diff --git a/web/components/registry/material-form.mjs b/web/components/registry/material-form.mjs
new file mode 100644
index 0000000..32461ea
--- /dev/null
+++ b/web/components/registry/material-form.mjs
@@ -0,0 +1,115 @@
+import { api } from '../../lib/api.mjs';
+
+const UNITS = ['ft', 'ea', 'box', 'roll', 'pair', 'bag', 'lb', 'gal', 'pkg'];
+
+class MaterialForm extends HTMLElement {
+ #onSave = null;
+
+ connectedCallback() {
+ if (!this.shadowRoot) {
+ this.attachShadow({ mode: 'open' });
+ this.shadowRoot.innerHTML = ``;
+ }
+ }
+
+ open(material, onSave) {
+ this.#onSave = onSave;
+ const isEdit = !!material;
+ const d = this.shadowRoot.querySelector('dialog');
+ d.innerHTML = `
+
+
+
+ `;
+
+ if (window.lucide) lucide.createIcons({ nodes: [d] });
+ d.querySelector('#dlg-close').addEventListener('click', () => d.close());
+ d.querySelector('#dlg-cancel').addEventListener('click', () => d.close());
+
+ d.querySelector('#dlg-save').addEventListener('click', async () => {
+ const name = d.querySelector('#m-name').value.trim();
+ const errEl = d.querySelector('#form-error');
+ if (!name) { errEl.textContent = 'Name is required'; d.querySelector('#m-name').focus(); return; }
+ errEl.textContent = '';
+
+ const saveBtn = d.querySelector('#dlg-save');
+ saveBtn.disabled = true;
+ saveBtn.textContent = 'Saving…';
+
+ const payload = {
+ name,
+ unit: d.querySelector('#m-unit').value,
+ part_number: d.querySelector('#m-part').value.trim(),
+ active: isEdit ? d.querySelector('#m-active').checked : true,
+ };
+
+ try {
+ if (isEdit) await api.put(`/registry/materials/${material.id}`, payload);
+ else await api.post('/registry/materials', payload);
+ d.close();
+ window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: isEdit ? 'Material updated' : 'Material added', type: 'success' } }));
+ this.#onSave?.();
+ } catch (err) {
+ errEl.textContent = err.message;
+ saveBtn.disabled = false;
+ saveBtn.textContent = isEdit ? 'Save Changes' : 'Add Material';
+ }
+ });
+
+ d.showModal();
+ d.querySelector('#m-name').focus();
+ }
+
+ #esc(s) { return (s || '').replace(/&/g,'&').replace(//g,'>'); }
+}
+
+customElements.define('material-form', MaterialForm);
diff --git a/web/components/registry/material-list.mjs b/web/components/registry/material-list.mjs
new file mode 100644
index 0000000..9ad8a2d
--- /dev/null
+++ b/web/components/registry/material-list.mjs
@@ -0,0 +1,130 @@
+import { api } from '../../lib/api.mjs';
+import './material-form.mjs';
+
+class MaterialList extends HTMLElement {
+ #items = [];
+ #loading = true;
+ #search = '';
+
+ connectedCallback() {
+ if (!this.shadowRoot) this.attachShadow({ mode: 'open' });
+ this.#load();
+ }
+
+ async #load() {
+ this.#loading = true;
+ this.#render();
+ try {
+ this.#items = await api.get(`/registry/materials?all=1${this.#search ? '&search=' + encodeURIComponent(this.#search) : ''}`) || [];
+ } catch { this.#items = []; }
+ this.#loading = false;
+ this.#render();
+ }
+
+ async #deactivate(id) {
+ try {
+ await api.delete(`/registry/materials/${id}`);
+ window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: 'Material deactivated', type: 'success' } }));
+ await this.#load();
+ } catch (err) {
+ window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: err.message, type: 'error' } }));
+ }
+ }
+
+ #render() {
+ const s = this.shadowRoot;
+ s.innerHTML = `
+
+
+
+
+ ${this.#loading ? '' :
+ this.#items.length === 0
+ ? `
+
+ No materials found
+
`
+ : `
+ ${this.#items.map(m => `
+
+
+
+
${this.#esc(m.name)}
+
+ ${m.unit ? `${this.#esc(m.unit)}` : ''}
+ ${m.part_number ? `#${this.#esc(m.part_number)}` : ''}
+
+
+
${m.active ? 'Active' : 'Inactive'}
+
+
+ ${m.active ? `` : ''}
+
+
`).join('')}
+
`}
+
+ `;
+
+ if (window.lucide) lucide.createIcons({ nodes: [s] });
+
+ const searchEl = s.querySelector('#search');
+ let debounce;
+ searchEl.addEventListener('input', () => {
+ clearTimeout(debounce);
+ debounce = setTimeout(() => { this.#search = searchEl.value; this.#load(); }, 350);
+ });
+
+ s.querySelector('#new-btn').addEventListener('click', () =>
+ s.querySelector('#material-form').open(null, () => this.#load()));
+
+ s.querySelectorAll('[data-edit]').forEach(btn => {
+ const m = this.#items.find(x => x.id === +btn.dataset.edit);
+ btn.addEventListener('click', () => s.querySelector('#material-form').open(m, () => this.#load()));
+ });
+
+ s.querySelectorAll('[data-deactivate]').forEach(btn =>
+ btn.addEventListener('click', () => this.#deactivate(+btn.dataset.deactivate)));
+ }
+
+ #esc(s) { return (s || '').replace(/&/g,'&').replace(//g,'>'); }
+}
+
+customElements.define('material-list', MaterialList);
diff --git a/web/components/registry/people-form.mjs b/web/components/registry/people-form.mjs
new file mode 100644
index 0000000..a2fabe3
--- /dev/null
+++ b/web/components/registry/people-form.mjs
@@ -0,0 +1,116 @@
+import { api } from '../../lib/api.mjs';
+
+class PeopleForm extends HTMLElement {
+ #onSave = null;
+
+ connectedCallback() {
+ if (!this.shadowRoot) {
+ this.attachShadow({ mode: 'open' });
+ this.shadowRoot.innerHTML = ``;
+ }
+ }
+
+ open(person, onSave) {
+ this.#onSave = onSave;
+ const isEdit = !!person;
+ const d = this.shadowRoot.querySelector('dialog');
+ d.innerHTML = `
+
+
+
+ `;
+
+ if (window.lucide) lucide.createIcons({ nodes: [d] });
+
+ d.querySelector('#dlg-close').addEventListener('click', () => d.close());
+ d.querySelector('#dlg-cancel').addEventListener('click', () => d.close());
+
+ d.querySelector('#dlg-save').addEventListener('click', async () => {
+ const name = d.querySelector('#p-name').value.trim();
+ const errEl = d.querySelector('#form-error');
+ if (!name) { errEl.textContent = 'Name is required'; d.querySelector('#p-name').focus(); return; }
+ errEl.textContent = '';
+
+ const saveBtn = d.querySelector('#dlg-save');
+ saveBtn.disabled = true;
+ saveBtn.textContent = 'Saving…';
+
+ const payload = {
+ name,
+ role: d.querySelector('#p-role').value.trim(),
+ email: d.querySelector('#p-email').value.trim(),
+ phone: d.querySelector('#p-phone').value.trim(),
+ active: isEdit ? d.querySelector('#p-active').checked : true,
+ };
+
+ try {
+ if (isEdit) await api.put(`/registry/people/${person.id}`, payload);
+ else await api.post('/registry/people', payload);
+ d.close();
+ window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: isEdit ? 'Person updated' : 'Person added', type: 'success' } }));
+ this.#onSave?.();
+ } catch (err) {
+ errEl.textContent = err.message;
+ saveBtn.disabled = false;
+ saveBtn.textContent = isEdit ? 'Save Changes' : 'Add Person';
+ }
+ });
+
+ d.showModal();
+ d.querySelector('#p-name').focus();
+ }
+
+ #esc(s) { return (s || '').replace(/&/g,'&').replace(//g,'>'); }
+}
+
+customElements.define('people-form', PeopleForm);
diff --git a/web/components/registry/people-list.mjs b/web/components/registry/people-list.mjs
new file mode 100644
index 0000000..224dfa9
--- /dev/null
+++ b/web/components/registry/people-list.mjs
@@ -0,0 +1,143 @@
+import { api } from '../../lib/api.mjs';
+import './people-form.mjs';
+
+class PeopleList extends HTMLElement {
+ #people = [];
+ #loading = true;
+ #search = '';
+
+ connectedCallback() {
+ if (!this.shadowRoot) this.attachShadow({ mode: 'open' });
+ this.#load();
+ }
+
+ async #load() {
+ this.#loading = true;
+ this.#render();
+ try {
+ this.#people = await api.get(`/registry/people?all=1${this.#search ? '&search=' + encodeURIComponent(this.#search) : ''}`) || [];
+ } catch { this.#people = []; }
+ this.#loading = false;
+ this.#render();
+ }
+
+ async #deactivate(id) {
+ try {
+ await api.delete(`/registry/people/${id}`);
+ window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: 'Person deactivated', type: 'success' } }));
+ await this.#load();
+ } catch (err) {
+ window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: err.message, type: 'error' } }));
+ }
+ }
+
+ #initials(name) {
+ return (name || '?').split(' ').slice(0,2).map(w => w[0]).join('').toUpperCase();
+ }
+
+ #avatarColor(name) {
+ const colors = ['#0A7EA4','#8B5CF6','#E07B39','#1D9D6C','#D97706','#C0392B','#64748B'];
+ let h = 0;
+ for (const c of name || '') h = (h * 31 + c.charCodeAt(0)) & 0xffffffff;
+ return colors[Math.abs(h) % colors.length];
+ }
+
+ #render() {
+ const s = this.shadowRoot;
+ s.innerHTML = `
+
+
+
+
+ ${this.#loading ? '' :
+ this.#people.length === 0
+ ? `
+
+ No people found
+
`
+ : `
+ ${this.#people.map(p => `
+
+
${this.#initials(p.name)}
+
+
${this.#esc(p.name)}
+
+ ${p.role ? `${this.#esc(p.role)}` : ''}
+ ${p.phone ? `${this.#esc(p.phone)}` : ''}
+ ${p.email ? `${this.#esc(p.email)}` : ''}
+
+
+
${p.active ? 'Active' : 'Inactive'}
+
+
+ ${p.active ? `` : ''}
+
+
`).join('')}
+
`}
+
+ `;
+
+ if (window.lucide) lucide.createIcons({ nodes: [s] });
+
+ const searchEl = s.querySelector('#search');
+ let debounce;
+ searchEl.addEventListener('input', () => {
+ clearTimeout(debounce);
+ debounce = setTimeout(() => { this.#search = searchEl.value; this.#load(); }, 350);
+ });
+
+ s.querySelector('#new-btn').addEventListener('click', () => {
+ s.querySelector('#people-form').open(null, () => this.#load());
+ });
+
+ s.querySelectorAll('[data-edit]').forEach(btn => {
+ const p = this.#people.find(x => x.id === +btn.dataset.edit);
+ btn.addEventListener('click', () => s.querySelector('#people-form').open(p, () => this.#load()));
+ });
+
+ s.querySelectorAll('[data-deactivate]').forEach(btn =>
+ btn.addEventListener('click', () => this.#deactivate(+btn.dataset.deactivate)));
+ }
+
+ #esc(s) { return (s || '').replace(/&/g,'&').replace(//g,'>'); }
+}
+
+customElements.define('people-list', PeopleList);
diff --git a/web/components/registry/profile-form.mjs b/web/components/registry/profile-form.mjs
new file mode 100644
index 0000000..0b14448
--- /dev/null
+++ b/web/components/registry/profile-form.mjs
@@ -0,0 +1,232 @@
+import { api } from '../../lib/api.mjs';
+
+const PRIORITIES = ['low', 'normal', 'high', 'urgent'];
+
+class ProfileForm extends HTMLElement {
+ #onSave = null;
+
+ connectedCallback() {
+ if (!this.shadowRoot) {
+ this.attachShadow({ mode: 'open' });
+ this.shadowRoot.innerHTML = ``;
+ }
+ }
+
+ open(profile, onSave) {
+ this.#onSave = onSave;
+ const isEdit = !!profile;
+ const d = this.shadowRoot.querySelector('dialog');
+
+ d.innerHTML = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${isEdit ? `
+
+ Active
+
+
` : ''}
+
+
Default Steps
+
+ ${(profile?.steps || []).length === 0
+ ? '
No steps yet — add steps below
'
+ : (profile?.steps || []).map((s, i) => this.#stepRowHTML(s, i)).join('')}
+
+
+
+
+
+
+
+ `;
+
+ if (window.lucide) lucide.createIcons({ nodes: [d] });
+
+ // Local step state (not saved until the profile save)
+ const profileId = profile?.id || null;
+ let pendingSteps = (profile?.steps || []).map(s => ({ ...s }));
+
+ const renderSteps = () => {
+ const list = d.querySelector('#step-list');
+ const empty = pendingSteps.length === 0;
+ list.innerHTML = empty
+ ? 'No steps yet — add steps below
'
+ : pendingSteps.map((s, i) => this.#stepRowHTML(s, i)).join('');
+
+ list.querySelectorAll('.step-title-input').forEach((inp, i) => {
+ inp.addEventListener('input', () => { pendingSteps[i].title = inp.value; });
+ inp.addEventListener('change', () => { pendingSteps[i].title = inp.value; });
+ });
+ list.querySelectorAll('.step-req-check').forEach((cb, i) => {
+ cb.addEventListener('change', () => { pendingSteps[i].required = cb.checked; });
+ });
+ list.querySelectorAll('.step-del').forEach((btn, i) => {
+ btn.addEventListener('click', () => { pendingSteps.splice(i, 1); renderSteps(); });
+ });
+ };
+ renderSteps();
+
+ d.querySelector('#add-step-btn').addEventListener('click', () => {
+ const inp = d.querySelector('#new-step-title');
+ const title = inp.value.trim();
+ if (!title) { inp.focus(); return; }
+ pendingSteps.push({ id: null, step_order: pendingSteps.length + 1, title, description: '', required: true });
+ inp.value = '';
+ renderSteps();
+ });
+ d.querySelector('#new-step-title').addEventListener('keydown', e => {
+ if (e.key === 'Enter') { e.preventDefault(); d.querySelector('#add-step-btn').click(); }
+ });
+
+ d.querySelector('#dlg-close').addEventListener('click', () => d.close());
+ d.querySelector('#dlg-cancel').addEventListener('click', () => d.close());
+
+ d.querySelector('#dlg-save').addEventListener('click', async () => {
+ const name = d.querySelector('#p-name').value.trim();
+ const errEl = d.querySelector('#form-error');
+ if (!name) { errEl.textContent = 'Name is required'; d.querySelector('#p-name').focus(); return; }
+ errEl.textContent = '';
+
+ const saveBtn = d.querySelector('#dlg-save');
+ saveBtn.disabled = true;
+ saveBtn.textContent = 'Saving…';
+
+ const payload = {
+ name,
+ description: d.querySelector('#p-desc').value.trim(),
+ category: d.querySelector('#p-cat').value.trim(),
+ default_priority: d.querySelector('#p-pri').value,
+ default_duration_hours: d.querySelector('#p-dur').value ? +d.querySelector('#p-dur').value : null,
+ default_instructions: d.querySelector('#p-instr').value.trim(),
+ active: isEdit ? d.querySelector('#p-active').checked : true,
+ };
+
+ try {
+ let savedProfile;
+ if (isEdit) {
+ savedProfile = await api.put(`/profiles/${profile.id}`, payload);
+ } else {
+ savedProfile = await api.post('/profiles', payload);
+ }
+
+ // Sync steps: delete removed, update existing, create new
+ const savedId = savedProfile.id;
+ const origIds = new Set((profile?.steps || []).map(s => s.id));
+
+ // Delete removed
+ for (const orig of (profile?.steps || [])) {
+ if (!pendingSteps.find(s => s.id === orig.id)) {
+ await api.delete(`/profiles/${savedId}/steps/${orig.id}`);
+ }
+ }
+ // Update or create, re-assigning order
+ for (let i = 0; i < pendingSteps.length; i++) {
+ const s = pendingSteps[i];
+ const stepPayload = { title: s.title, description: s.description || '', required: s.required !== false, step_order: i + 1 };
+ if (s.id && origIds.has(s.id)) {
+ await api.put(`/profiles/${savedId}/steps/${s.id}`, stepPayload);
+ } else {
+ await api.post(`/profiles/${savedId}/steps`, stepPayload);
+ }
+ }
+
+ d.close();
+ window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: isEdit ? 'Profile updated' : 'Profile created', type: 'success' } }));
+ this.#onSave?.();
+ } catch (err) {
+ errEl.textContent = err.message;
+ saveBtn.disabled = false;
+ saveBtn.textContent = isEdit ? 'Save Changes' : 'Create Profile';
+ }
+ });
+
+ d.showModal();
+ d.querySelector('#p-name').focus();
+ }
+
+ #stepRowHTML(s, i) {
+ return `
+
+ ${i + 1}
+
+
+
+
`;
+ }
+
+ #esc(s) { return (s || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); }
+}
+
+customElements.define('profile-form', ProfileForm);
diff --git a/web/components/registry/profile-list.mjs b/web/components/registry/profile-list.mjs
new file mode 100644
index 0000000..35fbf3f
--- /dev/null
+++ b/web/components/registry/profile-list.mjs
@@ -0,0 +1,143 @@
+import { api } from '../../lib/api.mjs';
+import './profile-form.mjs';
+
+const PRIORITY_COLORS = { low: '#64748B', normal: '#0A7EA4', high: '#E07B39', urgent: '#C0392B' };
+
+class ProfileList extends HTMLElement {
+ #items = [];
+ #loading = true;
+ #search = '';
+
+ connectedCallback() {
+ if (!this.shadowRoot) this.attachShadow({ mode: 'open' });
+ this.#load();
+ }
+
+ async #load() {
+ this.#loading = true;
+ this.#render();
+ try {
+ this.#items = await api.get(`/profiles?all=1${this.#search ? '&search=' + encodeURIComponent(this.#search) : ''}`) || [];
+ } catch { this.#items = []; }
+ this.#loading = false;
+ this.#render();
+ }
+
+ async #deactivate(id) {
+ try {
+ await api.delete(`/profiles/${id}`);
+ window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: 'Profile deactivated', type: 'success' } }));
+ await this.#load();
+ } catch (err) {
+ window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: err.message, type: 'error' } }));
+ }
+ }
+
+ #render() {
+ const s = this.shadowRoot;
+ s.innerHTML = `
+
+
+
+
+ ${this.#loading ? '' :
+ this.#items.length === 0
+ ? `
+
+ No profiles found
+
`
+ : `
+ ${this.#items.map(p => `
+
+
+
+
${this.#esc(p.name)}
+
+ ${p.category ? `${this.#esc(p.category)}` : ''}
+
+ ${p.default_priority}
+ ${p.step_count} step${p.step_count !== 1 ? 's' : ''}
+ ${p.default_duration_hours ? `⏱ ${p.default_duration_hours}h` : ''}
+
+
+
${p.active ? 'Active' : 'Inactive'}
+
+
+ ${p.active ? `` : ''}
+
+
`).join('')}
+
`}
+
+ `;
+
+ if (window.lucide) lucide.createIcons({ nodes: [s] });
+
+ const searchEl = s.querySelector('#search');
+ let debounce;
+ searchEl.addEventListener('input', () => {
+ clearTimeout(debounce);
+ debounce = setTimeout(() => { this.#search = searchEl.value; this.#load(); }, 350);
+ });
+
+ s.querySelector('#new-btn').addEventListener('click', () =>
+ s.querySelector('#profile-form').open(null, () => this.#load()));
+
+ s.querySelectorAll('[data-edit]').forEach(btn => {
+ btn.addEventListener('click', async () => {
+ const id = +btn.dataset.edit;
+ try {
+ const full = await api.get(`/profiles/${id}`);
+ s.querySelector('#profile-form').open(full, () => this.#load());
+ } catch (err) {
+ window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: err.message, type: 'error' } }));
+ }
+ });
+ });
+
+ s.querySelectorAll('[data-deactivate]').forEach(btn =>
+ btn.addEventListener('click', () => this.#deactivate(+btn.dataset.deactivate)));
+ }
+
+ #esc(s) { return (s || '').replace(/&/g, '&').replace(//g, '>'); }
+}
+
+customElements.define('profile-list', ProfileList);
diff --git a/web/components/registry/vehicle-form.mjs b/web/components/registry/vehicle-form.mjs
new file mode 100644
index 0000000..ecd1d3b
--- /dev/null
+++ b/web/components/registry/vehicle-form.mjs
@@ -0,0 +1,112 @@
+import { api } from '../../lib/api.mjs';
+
+const VEHICLE_TYPES = ['Bucket Truck', 'Van', 'Pickup Truck', 'Trailer', 'Digger Derrick', 'Other'];
+
+class VehicleForm extends HTMLElement {
+ #onSave = null;
+
+ connectedCallback() {
+ if (!this.shadowRoot) {
+ this.attachShadow({ mode: 'open' });
+ this.shadowRoot.innerHTML = ``;
+ }
+ }
+
+ open(vehicle, onSave) {
+ this.#onSave = onSave;
+ const isEdit = !!vehicle;
+ const d = this.shadowRoot.querySelector('dialog');
+ d.innerHTML = `
+
+
+
+ `;
+
+ if (window.lucide) lucide.createIcons({ nodes: [d] });
+ d.querySelector('#dlg-close').addEventListener('click', () => d.close());
+ d.querySelector('#dlg-cancel').addEventListener('click', () => d.close());
+
+ d.querySelector('#dlg-save').addEventListener('click', async () => {
+ const unit = d.querySelector('#v-unit').value.trim();
+ const errEl = d.querySelector('#form-error');
+ if (!unit) { errEl.textContent = 'Unit number is required'; d.querySelector('#v-unit').focus(); return; }
+ errEl.textContent = '';
+
+ const saveBtn = d.querySelector('#dlg-save');
+ saveBtn.disabled = true;
+ saveBtn.textContent = 'Saving…';
+
+ const payload = {
+ unit_number: unit,
+ description: d.querySelector('#v-desc').value.trim(),
+ vehicle_type: d.querySelector('#v-type').value,
+ active: isEdit ? d.querySelector('#v-active').checked : true,
+ };
+
+ try {
+ if (isEdit) await api.put(`/registry/vehicles/${vehicle.id}`, payload);
+ else await api.post('/registry/vehicles', payload);
+ d.close();
+ window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: isEdit ? 'Vehicle updated' : 'Vehicle added', type: 'success' } }));
+ this.#onSave?.();
+ } catch (err) {
+ errEl.textContent = err.message;
+ saveBtn.disabled = false;
+ saveBtn.textContent = isEdit ? 'Save Changes' : 'Add Vehicle';
+ }
+ });
+
+ d.showModal();
+ d.querySelector('#v-unit').focus();
+ }
+
+ #esc(s) { return (s || '').replace(/&/g,'&').replace(//g,'>'); }
+}
+
+customElements.define('vehicle-form', VehicleForm);
diff --git a/web/components/registry/vehicle-list.mjs b/web/components/registry/vehicle-list.mjs
new file mode 100644
index 0000000..79f3a3c
--- /dev/null
+++ b/web/components/registry/vehicle-list.mjs
@@ -0,0 +1,128 @@
+import { api } from '../../lib/api.mjs';
+import './vehicle-form.mjs';
+
+class VehicleList extends HTMLElement {
+ #vehicles = [];
+ #loading = true;
+ #search = '';
+
+ connectedCallback() {
+ if (!this.shadowRoot) this.attachShadow({ mode: 'open' });
+ this.#load();
+ }
+
+ async #load() {
+ this.#loading = true;
+ this.#render();
+ try {
+ this.#vehicles = await api.get(`/registry/vehicles?all=1${this.#search ? '&search=' + encodeURIComponent(this.#search) : ''}`) || [];
+ } catch { this.#vehicles = []; }
+ this.#loading = false;
+ this.#render();
+ }
+
+ async #deactivate(id) {
+ try {
+ await api.delete(`/registry/vehicles/${id}`);
+ window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: 'Vehicle deactivated', type: 'success' } }));
+ await this.#load();
+ } catch (err) {
+ window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: err.message, type: 'error' } }));
+ }
+ }
+
+ #render() {
+ const s = this.shadowRoot;
+ s.innerHTML = `
+
+
+
+
+ ${this.#loading ? '' :
+ this.#vehicles.length === 0
+ ? `
+
+ No vehicles found
+
`
+ : `
+ ${this.#vehicles.map(v => `
+
+
+
+
${this.#esc(v.unit_number)}
+
+ ${v.description ? `${this.#esc(v.description)}` : ''}
+ ${v.vehicle_type ? `${this.#esc(v.vehicle_type)}` : ''}
+
+
+
${v.active ? 'Active' : 'Inactive'}
+
+
+ ${v.active ? `` : ''}
+
+
`).join('')}
+
`}
+
+ `;
+
+ if (window.lucide) lucide.createIcons({ nodes: [s] });
+
+ const searchEl = s.querySelector('#search');
+ let debounce;
+ searchEl.addEventListener('input', () => {
+ clearTimeout(debounce);
+ debounce = setTimeout(() => { this.#search = searchEl.value; this.#load(); }, 350);
+ });
+
+ s.querySelector('#new-btn').addEventListener('click', () =>
+ s.querySelector('#vehicle-form').open(null, () => this.#load()));
+
+ s.querySelectorAll('[data-edit]').forEach(btn => {
+ const v = this.#vehicles.find(x => x.id === +btn.dataset.edit);
+ btn.addEventListener('click', () => s.querySelector('#vehicle-form').open(v, () => this.#load()));
+ });
+
+ s.querySelectorAll('[data-deactivate]').forEach(btn =>
+ btn.addEventListener('click', () => this.#deactivate(+btn.dataset.deactivate)));
+ }
+
+ #esc(s) { return (s || '').replace(/&/g,'&').replace(//g,'>'); }
+}
+
+customElements.define('vehicle-list', VehicleList);
diff --git a/web/components/work-orders/wo-accounting.mjs b/web/components/work-orders/wo-accounting.mjs
new file mode 100644
index 0000000..21d26db
--- /dev/null
+++ b/web/components/work-orders/wo-accounting.mjs
@@ -0,0 +1,125 @@
+import { api } from '../../lib/api.mjs';
+
+const CODE_TYPES = [
+ { key: 'gl_account', label: 'GL Account Code', placeholder: 'e.g. 6100-Operations' },
+ { key: 'cost_center', label: 'Cost Center / Department', placeholder: 'e.g. CC-4200' },
+ { key: 'wbs', label: 'Project WBS / Phase', placeholder: 'e.g. P-1234-A' },
+ { key: 'billing_ref', label: 'Billing Reference', placeholder: 'e.g. INV-2024-001' },
+];
+
+class WoAccounting extends HTMLElement {
+ #woId = null;
+ #codes = {};
+ #saving = false;
+ #loading = true;
+
+ static get observedAttributes() { return ['wo-id']; }
+
+ connectedCallback() {
+ if (!this.shadowRoot) this.attachShadow({ mode: 'open' });
+ if (this.#woId) this.#load();
+ }
+
+ attributeChangedCallback(_, __, val) {
+ this.#woId = val ? +val : null;
+ if (this.shadowRoot && this.#woId) this.#load();
+ }
+
+ async #load() {
+ this.#loading = true;
+ this.#render();
+ try {
+ const rows = await api.get(`/work-orders/${this.#woId}/accounting`) || [];
+ this.#codes = {};
+ rows.forEach(r => { this.#codes[r.code_type] = { value: r.code_value, description: r.description }; });
+ } catch { this.#codes = {}; }
+ this.#loading = false;
+ this.#render();
+ }
+
+ async #save() {
+ if (this.#saving) return;
+ this.#saving = true;
+ this.#updateSaveIndicator('saving');
+ try {
+ const s = this.shadowRoot;
+ const payload = CODE_TYPES
+ .filter(ct => {
+ const v = s.querySelector(`[data-key="${ct.key}"] .code-value`)?.value?.trim();
+ return v;
+ })
+ .map(ct => ({
+ code_type: ct.key,
+ code_value: s.querySelector(`[data-key="${ct.key}"] .code-value`).value.trim(),
+ description: s.querySelector(`[data-key="${ct.key}"] .code-desc`).value.trim(),
+ }));
+ await api.put(`/work-orders/${this.#woId}/accounting`, payload);
+ this.#updateSaveIndicator('saved');
+ } catch (err) {
+ this.#updateSaveIndicator('error');
+ window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: err.message, type: 'error' } }));
+ } finally {
+ this.#saving = false;
+ }
+ }
+
+ #updateSaveIndicator(state) {
+ const el = this.shadowRoot?.querySelector('#save-indicator');
+ if (!el) return;
+ const msgs = { saving: '⋯ Saving…', saved: '✓ Saved', error: '✗ Error' };
+ const colors = { saving: 'var(--text-muted)', saved: 'var(--success)', error: 'var(--danger)' };
+ el.textContent = msgs[state];
+ el.style.color = colors[state];
+ if (state !== 'saving') setTimeout(() => { if (el) el.textContent = ''; }, 2500);
+ }
+
+ #render() {
+ const s = this.shadowRoot;
+ if (this.#loading) {
+ s.innerHTML = ``;
+ return;
+ }
+
+ s.innerHTML = `
+
+
+
+ ${CODE_TYPES.map((ct, i) => `
+ ${i > 0 ? '
' : ''}
+
`).join('')}
+
`;
+
+ s.querySelectorAll('.code-value, .code-desc').forEach(input => {
+ input.addEventListener('blur', () => this.#save());
+ input.addEventListener('keydown', e => { if (e.key === 'Enter') input.blur(); });
+ });
+ }
+
+ #esc(s) { return (s || '').replace(/&/g,'&').replace(//g,'>'); }
+}
+
+customElements.define('wo-accounting', WoAccounting);
diff --git a/web/components/work-orders/wo-checklist.mjs b/web/components/work-orders/wo-checklist.mjs
new file mode 100644
index 0000000..bfa6acc
--- /dev/null
+++ b/web/components/work-orders/wo-checklist.mjs
@@ -0,0 +1,210 @@
+import { api } from '../../lib/api.mjs';
+
+class WoChecklist extends HTMLElement {
+ #woId = null;
+ #steps = [];
+ #loading = true;
+
+ static get observedAttributes() { return ['wo-id']; }
+
+ connectedCallback() {
+ if (!this.shadowRoot) this.attachShadow({ mode: 'open' });
+ if (this.#woId) this.#load();
+ }
+
+ attributeChangedCallback(_, __, val) {
+ this.#woId = val ? +val : null;
+ if (this.shadowRoot && this.#woId) this.#load();
+ }
+
+ async #load() {
+ this.#loading = true;
+ this.#render();
+ try { this.#steps = await api.get(`/work-orders/${this.#woId}/steps`) || []; }
+ catch { this.#steps = []; }
+ this.#loading = false;
+ this.#render();
+ }
+
+ async #complete(stepId) {
+ try {
+ await api.post(`/work-orders/${this.#woId}/steps/${stepId}/complete`, {});
+ await this.#load();
+ this.dispatchEvent(new CustomEvent('steps:updated', { bubbles: true, composed: true }));
+ } catch (err) {
+ window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: err.message, type: 'error' } }));
+ }
+ }
+
+ async #uncomplete(stepId) {
+ try {
+ await api.post(`/work-orders/${this.#woId}/steps/${stepId}/uncomplete`, {});
+ await this.#load();
+ this.dispatchEvent(new CustomEvent('steps:updated', { bubbles: true, composed: true }));
+ } catch (err) {
+ window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: err.message, type: 'error' } }));
+ }
+ }
+
+ async #addStep(title) {
+ try {
+ await api.post(`/work-orders/${this.#woId}/steps`, { title, description: '', required: true });
+ await this.#load();
+ } catch (err) {
+ window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: err.message, type: 'error' } }));
+ }
+ }
+
+ async #deleteStep(stepId) {
+ try {
+ await api.delete(`/work-orders/${this.#woId}/steps/${stepId}`);
+ await this.#load();
+ } catch (err) {
+ window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: err.message, type: 'error' } }));
+ }
+ }
+
+ #render() {
+ const s = this.shadowRoot;
+ if (this.#loading) {
+ s.innerHTML = ``;
+ return;
+ }
+
+ const done = this.#steps.filter(st => st.completed).length;
+ const total = this.#steps.length;
+ const pct = total ? Math.round(done / total * 100) : 0;
+ const allDone = total > 0 && done === total;
+
+ s.innerHTML = `
+
+
+ ${allDone ? `
+
+
+ All ${total} steps complete — ready for review!
+
` : ''}
+
+
+
+ ${done} of ${total} steps complete
+ ${pct}%
+
+
+
+
+
+ ${total === 0
+ ? '
No steps yet — add your first step below
'
+ : this.#steps.map(st => `
+
+
+
+
+
+
+ ${st.description ? `
${this.#esc(st.description)}
` : ''}
+ ${st.completed && st.completed_by ? `
+
+
+ ${this.#esc(st.completed_by)}
+ ${st.completed_at ? '· ' + new Date(st.completed_at).toLocaleString() : ''}
+
` : ''}
+ ${st.notes ? `
"${this.#esc(st.notes)}"
` : ''}
+
+
+ ${st.completed
+ ? ``
+ : ``}
+
+
`).join('')}
+
+
+
+
Add Step
+
+
+
+
+
`;
+
+ if (window.lucide) lucide.createIcons({ nodes: [s] });
+
+ s.querySelectorAll('.step-check').forEach(cb => {
+ cb.addEventListener('change', () => {
+ if (cb.checked) this.#complete(+cb.dataset.id);
+ else this.#uncomplete(+cb.dataset.id);
+ });
+ });
+
+ s.querySelectorAll('[data-del]').forEach(btn =>
+ btn.addEventListener('click', () => this.#deleteStep(+btn.dataset.del)));
+
+ s.querySelectorAll('[data-undo]').forEach(btn =>
+ btn.addEventListener('click', () => this.#uncomplete(+btn.dataset.undo)));
+
+ const titleInput = s.querySelector('#new-step-title');
+ const addBtn = s.querySelector('#add-step-btn');
+ addBtn.addEventListener('click', () => {
+ const title = titleInput.value.trim();
+ if (!title) { titleInput.focus(); return; }
+ this.#addStep(title);
+ titleInput.value = '';
+ });
+ titleInput.addEventListener('keydown', e => {
+ if (e.key === 'Enter') addBtn.click();
+ });
+ }
+
+ #esc(s) { return (s || '').replace(/&/g,'&').replace(//g,'>'); }
+}
+
+customElements.define('wo-checklist', WoChecklist);
diff --git a/web/components/work-orders/wo-detail.mjs b/web/components/work-orders/wo-detail.mjs
index da14b80..fbabf4f 100644
--- a/web/components/work-orders/wo-detail.mjs
+++ b/web/components/work-orders/wo-detail.mjs
@@ -1,5 +1,11 @@
import { api } from '../../lib/api.mjs';
import { formatDateTime, formatDate } from '../../lib/format.mjs';
+import './wo-checklist.mjs';
+import './wo-resource-panel.mjs';
+import './wo-photo-panel.mjs';
+import './wo-map.mjs';
+import './wo-accounting.mjs';
+import './wo-timeline.mjs';
const TABS = ['Overview', 'Checklist', 'Resources', 'Photos', 'Accounting', 'Activity'];
const STATUS_TRANSITIONS = {
@@ -70,15 +76,36 @@ class WoDetail extends HTMLElement {
.field-value { font-size:.875rem; color:var(--text); line-height:1.5; }
.field-value.empty { color:var(--text-muted); font-style:italic; }
.text-block { background:var(--surface-2); border-radius:var(--radius-sm); padding:.75rem 1rem; font-size:.875rem; line-height:1.6; color:var(--text); white-space:pre-wrap; }
- .phase-badge { display:inline-flex; align-items:center; gap:.3rem; padding:.2rem .6rem; border-radius:999px; font-size:.7rem; font-weight:700; background:var(--surface-2); color:var(--text-muted); }
.status-menu { position:relative; }
.status-dropdown { position:absolute; top:calc(100% + .25rem); right:0; background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); box-shadow:var(--shadow-md); z-index:50; min-width:160px; overflow:hidden; }
.status-opt { display:flex; align-items:center; gap:.5rem; padding:.6rem 1rem; font-size:.875rem; cursor:pointer; border:none; background:none; width:100%; text-align:left; transition:background .15s; color:var(--text); }
.status-opt:hover { background:var(--surface-2); }
.status-dot { width:8px; height:8px; border-radius:50%; flex-shrink:0; }
- .placeholder { text-align:center; padding:3rem; color:var(--text-muted); }
- .placeholder i { margin-bottom:.75rem; }
@media (max-width:768px) { .detail-grid { grid-template-columns:1fr; } .meta-strip { gap:1rem; } }
+ .ap-dialog { border:1px solid var(--border); border-radius:var(--radius-lg); padding:0; box-shadow:var(--shadow-lg); width:460px; max-width:96vw; background:var(--surface); color:var(--text); }
+ .ap-dialog::backdrop { background:rgba(0,0,0,.45); }
+ .ap-header { display:flex; align-items:center; justify-content:space-between; padding:1rem 1.25rem; border-bottom:1px solid var(--border); }
+ .ap-title { font-size:.938rem; font-weight:700; }
+ .ap-close { background:none; border:none; cursor:pointer; color:var(--text-muted); padding:.25rem; display:flex; }
+ .ap-body { padding:1.1rem 1.25rem; display:flex; flex-direction:column; gap:.75rem; }
+ .ap-search { width:100%; border:1px solid var(--border); border-radius:var(--radius-sm); padding:.5rem .75rem; font-size:.875rem; background:var(--surface); color:var(--text); box-sizing:border-box; }
+ .ap-search:focus { outline:none; border-color:var(--teal); box-shadow:0 0 0 3px rgba(10,126,164,.12); }
+ .ap-list { max-height:240px; overflow-y:auto; display:flex; flex-direction:column; gap:.3rem; border:1px solid var(--border); border-radius:var(--radius-sm); padding:.35rem; background:var(--surface); }
+ .ap-item { padding:.55rem .75rem; border-radius:var(--radius-sm); cursor:pointer; display:flex; align-items:center; gap:.6rem; transition:background .12s; border:2px solid transparent; }
+ .ap-item:hover { background:var(--surface-2); }
+ .ap-item.selected { border-color:var(--teal); background:rgba(10,126,164,.06); }
+ .ap-item-name { font-size:.875rem; font-weight:500; color:var(--text); }
+ .ap-item-meta { font-size:.75rem; color:var(--text-muted); }
+ .ap-mode { display:flex; flex-direction:column; gap:.4rem; }
+ .ap-mode-label { font-size:.813rem; font-weight:600; color:var(--text); }
+ .ap-mode-opts { display:flex; gap:.5rem; }
+ .ap-mode-opt { flex:1; border:2px solid var(--border); border-radius:var(--radius-sm); padding:.6rem .75rem; cursor:pointer; background:var(--surface); transition:border-color .12s,background .12s; }
+ .ap-mode-opt.selected { border-color:var(--teal); background:rgba(10,126,164,.06); }
+ .ap-mode-opt-title { font-size:.813rem; font-weight:600; color:var(--text); }
+ .ap-mode-opt-desc { font-size:.75rem; color:var(--text-muted); margin-top:.15rem; }
+ .ap-footer { padding:.875rem 1.25rem; border-top:1px solid var(--border); display:flex; justify-content:flex-end; gap:.5rem; align-items:center; }
+ .ap-err { font-size:.813rem; color:var(--danger); flex:1; }
+ .ap-empty { text-align:center; padding:1.5rem; color:var(--text-muted); font-size:.875rem; }
`; }
#html() {
@@ -113,6 +140,9 @@ class WoDetail extends HTMLElement {
+
${transitions.length ? `
+
+ `;
}
#tabContent(wo) {
@@ -191,17 +250,35 @@ class WoDetail extends HTMLElement {
? `${this.#esc(wo.access_notes)}
`
: 'No access notes
'}
+ ${wo.lat && wo.lng ? `
+ ` : ''}
`;
+
case 'Checklist':
+ return ``;
+
case 'Resources':
+ return ``;
+
case 'Photos':
+ return ``;
+
case 'Accounting':
+ return ``;
+
case 'Activity':
- return `
-
-
${this.#tab} — Coming in Phase 2
-
This section will be available in the next phase of development.
-
`;
+ return ``;
+
default: return '';
}
}
@@ -243,6 +320,113 @@ class WoDetail extends HTMLElement {
window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: err.message, type: 'error' } }));
}
}));
+
+ // Apply Profile dialog
+ this.#bindApplyProfile(s);
+ }
+
+ #bindApplyProfile(s) {
+ const apBtn = s.querySelector('#apply-profile-btn');
+ const dialog = s.querySelector('#ap-dialog');
+ const closeBtn = s.querySelector('#ap-close');
+ const cancelBtn = s.querySelector('#ap-cancel');
+ const confirmBtn = s.querySelector('#ap-confirm');
+ const searchEl = s.querySelector('#ap-search');
+ const listEl = s.querySelector('#ap-profile-list');
+ const modeEl = s.querySelector('#ap-mode');
+ const errEl = s.querySelector('#ap-err');
+
+ if (!apBtn) return;
+
+ let allProfiles = [];
+ let selectedId = null;
+ let selectedMode = 'append';
+
+ const renderList = (items) => {
+ if (items.length === 0) {
+ listEl.innerHTML = 'No profiles found
';
+ return;
+ }
+ listEl.innerHTML = items.map(p => `
+
+
+
${this.#esc(p.name)}
+
${p.category ? `${p.category} · ` : ''}${p.step_count} step${p.step_count !== 1 ? 's' : ''} · ${p.default_priority} priority
+
+
`).join('');
+
+ listEl.querySelectorAll('.ap-item').forEach(item => {
+ item.addEventListener('click', () => {
+ selectedId = +item.dataset.id;
+ listEl.querySelectorAll('.ap-item').forEach(i => i.classList.toggle('selected', +i.dataset.id === selectedId));
+ confirmBtn.disabled = false;
+ });
+ });
+ };
+
+ apBtn.addEventListener('click', async () => {
+ selectedId = null;
+ selectedMode = 'append';
+ confirmBtn.disabled = true;
+ errEl.textContent = '';
+ searchEl.value = '';
+
+ // Show/hide mode selector based on whether WO already has steps
+ try {
+ const steps = await api.get(`/work-orders/${this.#woId}/steps`) || [];
+ modeEl.style.display = steps.length > 0 ? '' : 'none';
+ } catch { modeEl.style.display = 'none'; }
+
+ // Select append by default
+ modeEl.querySelectorAll('.ap-mode-opt').forEach(o => o.classList.toggle('selected', o.dataset.mode === 'append'));
+
+ listEl.innerHTML = 'Loading…
';
+ dialog.showModal();
+
+ try {
+ allProfiles = await api.get('/profiles') || [];
+ renderList(allProfiles);
+ } catch (err) {
+ listEl.innerHTML = `${err.message}
`;
+ }
+ });
+
+ searchEl.addEventListener('input', () => {
+ const q = searchEl.value.toLowerCase();
+ renderList(q ? allProfiles.filter(p => p.name.toLowerCase().includes(q) || (p.category || '').toLowerCase().includes(q)) : allProfiles);
+ });
+
+ modeEl.querySelectorAll('.ap-mode-opt').forEach(opt => {
+ opt.addEventListener('click', () => {
+ selectedMode = opt.dataset.mode;
+ modeEl.querySelectorAll('.ap-mode-opt').forEach(o => o.classList.toggle('selected', o.dataset.mode === selectedMode));
+ });
+ });
+
+ closeBtn.addEventListener('click', () => dialog.close());
+ cancelBtn.addEventListener('click', () => dialog.close());
+
+ confirmBtn.addEventListener('click', async () => {
+ if (!selectedId) return;
+ confirmBtn.disabled = true;
+ confirmBtn.textContent = 'Applying…';
+ errEl.textContent = '';
+ try {
+ const result = await api.post(`/work-orders/${this.#woId}/apply-profile/${selectedId}`, { mode: selectedMode });
+ dialog.close();
+ window.dispatchEvent(new CustomEvent('wo:toast', { detail: {
+ message: `Profile applied — ${result.steps_added} step${result.steps_added !== 1 ? 's' : ''} ${selectedMode === 'replace' ? 'loaded' : 'added'}`,
+ type: 'success'
+ }}));
+ // Reload WO and switch to Checklist tab
+ this.#tab = 'Checklist';
+ this.#load();
+ } catch (err) {
+ errEl.textContent = err.message;
+ confirmBtn.disabled = false;
+ confirmBtn.textContent = 'Apply Profile';
+ }
+ });
}
#esc(s) { return (s || '').replace(/&/g,'&').replace(//g,'>'); }
diff --git a/web/components/work-orders/wo-form.mjs b/web/components/work-orders/wo-form.mjs
index a7a59ae..dbfdbb5 100644
--- a/web/components/work-orders/wo-form.mjs
+++ b/web/components/work-orders/wo-form.mjs
@@ -60,6 +60,22 @@ class WoForm extends HTMLElement {
.btn:disabled { opacity:.5; cursor:not-allowed; }
.err { color:var(--danger); font-size:.813rem; text-align:right; }
@media (max-width:768px) { .layout { grid-template-columns:1fr; } .row2 { grid-template-columns:1fr; } }
+ .lp-dialog { border:1px solid var(--border); border-radius:var(--radius-lg); padding:0; box-shadow:var(--shadow-lg); width:440px; max-width:96vw; background:var(--surface); color:var(--text); }
+ .lp-dialog::backdrop { background:rgba(0,0,0,.45); }
+ .lp-header { display:flex; align-items:center; justify-content:space-between; padding:1rem 1.25rem; border-bottom:1px solid var(--border); }
+ .lp-title { font-size:.938rem; font-weight:700; }
+ .lp-close { background:none; border:none; cursor:pointer; color:var(--text-muted); padding:.25rem; display:flex; }
+ .lp-body { padding:1.1rem 1.25rem; display:flex; flex-direction:column; gap:.75rem; }
+ .lp-search { width:100%; border:1px solid var(--border); border-radius:var(--radius-sm); padding:.5rem .75rem; font-size:.875rem; background:var(--surface); color:var(--text); box-sizing:border-box; }
+ .lp-search:focus { outline:none; border-color:var(--teal); box-shadow:0 0 0 3px rgba(10,126,164,.12); }
+ .lp-list { max-height:260px; overflow-y:auto; display:flex; flex-direction:column; gap:.3rem; border:1px solid var(--border); border-radius:var(--radius-sm); padding:.35rem; }
+ .lp-item { padding:.55rem .75rem; border-radius:var(--radius-sm); cursor:pointer; display:flex; flex-direction:column; gap:.15rem; transition:background .12s; border:2px solid transparent; }
+ .lp-item:hover { background:var(--surface-2); }
+ .lp-item.selected { border-color:var(--teal); background:rgba(10,126,164,.06); }
+ .lp-item-name { font-size:.875rem; font-weight:500; color:var(--text); }
+ .lp-item-meta { font-size:.75rem; color:var(--text-muted); }
+ .lp-footer { padding:.875rem 1.25rem; border-top:1px solid var(--border); display:flex; justify-content:flex-end; gap:.5rem; }
+ .lp-empty { text-align:center; padding:1.5rem; color:var(--text-muted); font-size:.875rem; }
`; }
#html(wo, isNew) {
@@ -74,7 +90,12 @@ class WoForm extends HTMLElement {
-
Details
+
+ Details
+ ${(isNew || wo.status === 'draft') ? `` : ''}
+
@@ -135,7 +156,22 @@ class WoForm extends HTMLElement {
${isNew ? 'Create Work Order' : 'Save Changes'}
- `;
+
+
+
`;
}
#bind() {
@@ -144,6 +180,9 @@ class WoForm extends HTMLElement {
s.querySelector('#back')?.addEventListener('click', () => this.#goBack());
s.querySelector('#cancel-btn')?.addEventListener('click', () => this.#goBack());
+ // Load Profile dialog
+ this.#bindLoadProfile(s);
+
// Update priority dot colour on change
s.querySelector('[name="priority"]')?.addEventListener('change', e => {
const dot = s.querySelector('#pri-dot');
@@ -207,6 +246,86 @@ class WoForm extends HTMLElement {
});
}
+ #bindLoadProfile(s) {
+ const lpBtn = s.querySelector('#load-profile-btn');
+ const dialog = s.querySelector('#lp-dialog');
+ if (!lpBtn || !dialog) return;
+
+ const listEl = s.querySelector('#lp-list');
+ const searchEl = s.querySelector('#lp-search');
+ const confirmBtn = s.querySelector('#lp-confirm');
+ let allProfiles = [];
+ let selectedProfile = null;
+
+ const renderList = (items) => {
+ if (items.length === 0) { listEl.innerHTML = '
No profiles found
'; return; }
+ listEl.innerHTML = items.map(p => `
+
+
${this.#esc(p.name)}
+
${p.category ? `${p.category} · ` : ''}${p.step_count} step${p.step_count !== 1 ? 's' : ''} · ${p.default_priority} priority${p.default_duration_hours ? ` · ${p.default_duration_hours}h` : ''}
+
`).join('');
+ listEl.querySelectorAll('.lp-item').forEach(item => {
+ item.addEventListener('click', () => {
+ selectedProfile = allProfiles.find(p => p.id === +item.dataset.id);
+ listEl.querySelectorAll('.lp-item').forEach(i => i.classList.toggle('selected', +i.dataset.id === selectedProfile?.id));
+ confirmBtn.disabled = !selectedProfile;
+ });
+ });
+ };
+
+ lpBtn.addEventListener('click', async () => {
+ selectedProfile = null;
+ confirmBtn.disabled = true;
+ searchEl.value = '';
+ listEl.innerHTML = '
Loading…
';
+ dialog.showModal();
+ try {
+ allProfiles = await api.get('/profiles') || [];
+ renderList(allProfiles);
+ } catch (err) {
+ listEl.innerHTML = `
${err.message}
`;
+ }
+ });
+
+ searchEl.addEventListener('input', () => {
+ const q = searchEl.value.toLowerCase();
+ renderList(q ? allProfiles.filter(p => p.name.toLowerCase().includes(q) || (p.category || '').toLowerCase().includes(q)) : allProfiles);
+ });
+
+ s.querySelector('#lp-close').addEventListener('click', () => dialog.close());
+ s.querySelector('#lp-cancel').addEventListener('click', () => dialog.close());
+
+ confirmBtn.addEventListener('click', async () => {
+ if (!selectedProfile) return;
+ // Fetch full profile to get instructions
+ let full = selectedProfile;
+ try { full = await api.get(`/profiles/${selectedProfile.id}`); } catch { /* use list data */ }
+
+ dialog.close();
+
+ // Pre-fill form fields (only if currently empty)
+ const priEl = s.querySelector('[name="priority"]');
+ const instrEl = s.querySelector('[name="instructions"]');
+
+ if (priEl) { priEl.value = full.default_priority || 'normal'; priEl.dispatchEvent(new Event('change')); }
+ if (instrEl && !instrEl.value.trim() && full.default_instructions) {
+ instrEl.value = full.default_instructions;
+ }
+ // Compute scheduled_end from duration if start is filled and end is empty
+ const startEl = s.querySelector('[name="scheduled_start"]');
+ const endEl = s.querySelector('[name="scheduled_end"]');
+ if (full.default_duration_hours && startEl?.value && !endEl?.value) {
+ const startMs = new Date(startEl.value).getTime();
+ const endDate = new Date(startMs + full.default_duration_hours * 3600000);
+ // Format back to datetime-local value (YYYY-MM-DDTHH:MM)
+ endEl.value = endDate.toISOString().slice(0, 16);
+ }
+
+ this.#dirty = true;
+ window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: `Profile "${full.name}" loaded`, type: 'success' } }));
+ });
+ }
+
#goBack() {
if (this.#dirty && !confirm('You have unsaved changes. Leave anyway?')) return;
if (this.#woId) {
diff --git a/web/components/work-orders/wo-map.mjs b/web/components/work-orders/wo-map.mjs
new file mode 100644
index 0000000..cd534f3
--- /dev/null
+++ b/web/components/work-orders/wo-map.mjs
@@ -0,0 +1,130 @@
+class WoMap extends HTMLElement {
+ #map = null;
+ #mounted = false;
+
+ static get observedAttributes() { return ['lat', 'lng', 'site-name', 'access-notes', 'wo-number']; }
+
+ connectedCallback() {
+ this.style.display = 'block';
+ // Light DOM — Leaflet needs real DOM, not shadow root
+ if (!this.querySelector('.wo-map-root')) this.#build();
+ }
+
+ attributeChangedCallback() {
+ if (this.#mounted) this.#update();
+ }
+
+ #build() {
+ const lat = parseFloat(this.getAttribute('lat'));
+ const lng = parseFloat(this.getAttribute('lng'));
+ const siteName = this.getAttribute('site-name') || 'Work Site';
+ const accessNotes = this.getAttribute('access-notes') || '';
+ const woNumber = this.getAttribute('wo-number') || '';
+
+ this.innerHTML = `
+
+
+ ${lat && lng ? `
+
+
` : `
+
+
+ No location set for this work order
+
`}
+ ${accessNotes ? `
+
+
+
${this.#esc(accessNotes)}
+
` : ''}
+
`;
+
+ if (window.lucide) lucide.createIcons({ nodes: [this] });
+
+ if (lat && lng) {
+ this.#initMap(lat, lng, siteName, woNumber);
+ this.#setDirectionsLink(lat, lng);
+ }
+ this.#mounted = true;
+ }
+
+ #initMap(lat, lng, siteName, woNumber) {
+ if (typeof L === 'undefined') {
+ // Leaflet not yet loaded — retry once it fires
+ window.addEventListener('load', () => this.#initMap(lat, lng, siteName, woNumber), { once: true });
+ return;
+ }
+ const mapId = `leaflet-map-${this.getAttribute('wo-id') || 'map'}`;
+ const container = this.querySelector(`#${mapId}`);
+ if (!container) return;
+
+ // Prevent double-init
+ if (container._leaflet_id) return;
+
+ this.#map = L.map(container, { zoomControl: true }).setView([lat, lng], 16);
+ L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
+ attribution: '© OpenStreetMap contributors',
+ maxZoom: 19,
+ }).addTo(this.#map);
+
+ const icon = L.divIcon({
+ className: '',
+ html: `
+ ${this.#esc(woNumber || '📍')}
+
`,
+ iconSize: [32, 32],
+ iconAnchor: [16, 32],
+ popupAnchor: [0, -32],
+ });
+
+ L.marker([lat, lng], { icon })
+ .addTo(this.#map)
+ .bindPopup(`
${this.#esc(siteName)}${lat.toFixed(5)}, ${lng.toFixed(5)}`)
+ .openPopup();
+ }
+
+ #setDirectionsLink(lat, lng) {
+ const btn = this.querySelector('#directions-btn');
+ if (!btn) return;
+ const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
+ btn.href = isIOS
+ ? `maps://maps.apple.com/?daddr=${lat},${lng}&dirflg=d`
+ : `https://maps.google.com/maps?daddr=${lat},${lng}`;
+ }
+
+ #update() {
+ if (this.#map) {
+ this.#map.remove();
+ this.#map = null;
+ }
+ this.#mounted = false;
+ this.#build();
+ }
+
+ #esc(s) { return (s || '').replace(/&/g,'&').replace(//g,'>'); }
+}
+
+customElements.define('wo-map', WoMap);
diff --git a/web/components/work-orders/wo-photo-panel.mjs b/web/components/work-orders/wo-photo-panel.mjs
new file mode 100644
index 0000000..d92c757
--- /dev/null
+++ b/web/components/work-orders/wo-photo-panel.mjs
@@ -0,0 +1,297 @@
+import { api } from '../../lib/api.mjs';
+
+const PHASES = ['all', 'before', 'during', 'after'];
+
+class WoPhotoPanel extends HTMLElement {
+ #woId = null;
+ #photos = [];
+ #phase = 'all';
+ #loading = true;
+ #lightboxIdx = -1;
+
+ static get observedAttributes() { return ['wo-id']; }
+
+ connectedCallback() {
+ if (!this.shadowRoot) this.attachShadow({ mode: 'open' });
+ if (this.#woId) this.#load();
+ }
+
+ attributeChangedCallback(_, __, val) {
+ this.#woId = val ? +val : null;
+ if (this.shadowRoot && this.#woId) this.#load();
+ }
+
+ async #load() {
+ this.#loading = true;
+ this.#render();
+ try { this.#photos = await api.get(`/work-orders/${this.#woId}/attachments`) || []; }
+ catch { this.#photos = []; }
+ this.#loading = false;
+ this.#render();
+ }
+
+ async #upload(files, phase, caption) {
+ const uploads = Array.from(files).map(async file => {
+ const form = new FormData();
+ form.append('file', file);
+ form.append('phase', phase);
+ form.append('caption', caption || '');
+ 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(), { timeout: 3000 }
+ ));
+ }
+ return api.upload(`/work-orders/${this.#woId}/attachments`, form);
+ });
+ try {
+ await Promise.all(uploads);
+ window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: `${files.length} photo(s) uploaded`, type: 'success' } }));
+ await this.#load();
+ } catch (err) {
+ window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: err.message, type: 'error' } }));
+ }
+ }
+
+ async #deletePhoto(id) {
+ try {
+ await api.delete(`/work-orders/${this.#woId}/attachments/${id}`);
+ await this.#load();
+ } catch (err) {
+ window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: err.message, type: 'error' } }));
+ }
+ }
+
+ #filteredPhotos() {
+ if (this.#phase === 'all') return this.#photos;
+ return this.#photos.filter(p => p.phase === this.#phase);
+ }
+
+ #render() {
+ const s = this.shadowRoot;
+ if (this.#loading) {
+ s.innerHTML = `
`;
+ return;
+ }
+
+ const filtered = this.#filteredPhotos();
+ const phaseCounts = { all: this.#photos.length, before: 0, during: 0, after: 0 };
+ this.#photos.forEach(p => { if (phaseCounts[p.phase] !== undefined) phaseCounts[p.phase]++; });
+
+ s.innerHTML = `
+
+
+
+ ${PHASES.map(ph => `
+ `).join('')}
+
+
+
+
+
+
+ ${filtered.length === 0
+ ? `
+
+
${this.#phase === 'all' ? 'No photos yet' : `No ${this.#phase} photos yet`}
+
`
+ : `
+ ${filtered.map((p, i) => `
+
+
})
+
+ ${p.caption ? `
${this.#esc(p.caption)}
` : ''}
+
+ ${p.phase ? `
${p.phase}
` : ''}
+
+
`).join('')}
+
`}
+
+
`;
+
+ if (window.lucide) lucide.createIcons({ nodes: [s] });
+ this.#bindEvents(filtered);
+ }
+
+ #bindEvents(filtered) {
+ const s = this.shadowRoot;
+
+ s.querySelectorAll('.phase-tab').forEach(tab =>
+ tab.addEventListener('click', () => { this.#phase = tab.dataset.phase; this.#render(); }));
+
+ s.querySelectorAll('.photo-tile').forEach(tile =>
+ tile.addEventListener('click', e => {
+ if (e.target.closest('.del-btn')) return;
+ this.#openLightbox(+tile.dataset.idx, filtered);
+ }));
+
+ s.querySelectorAll('.del-btn').forEach(btn =>
+ btn.addEventListener('click', e => { e.stopPropagation(); this.#deletePhoto(+btn.dataset.id); }));
+
+ const dialog = s.querySelector('#upload-dialog');
+ const fileInput = s.querySelector('#file-input');
+ const dropZone = s.querySelector('#drop-zone');
+ const preview = s.querySelector('#preview-list');
+ const uploadBtn = s.querySelector('#dlg-upload');
+
+ s.querySelector('#upload-btn').addEventListener('click', () => {
+ s.querySelector('#phase-select').value = this.#phase !== 'all' ? this.#phase : '';
+ s.querySelector('#caption-input').value = '';
+ preview.innerHTML = '';
+ uploadBtn.disabled = true;
+ dialog.showModal();
+ });
+
+ s.querySelector('#dlg-close').addEventListener('click', () => dialog.close());
+ s.querySelector('#dlg-cancel').addEventListener('click', () => dialog.close());
+
+ dropZone.addEventListener('click', () => fileInput.click());
+ dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag-over'); });
+ dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
+ dropZone.addEventListener('drop', e => {
+ e.preventDefault();
+ dropZone.classList.remove('drag-over');
+ this.#setFiles(e.dataTransfer.files, preview, uploadBtn);
+ });
+ fileInput.addEventListener('change', () => this.#setFiles(fileInput.files, preview, uploadBtn));
+
+ s.querySelector('#dlg-upload').addEventListener('click', () => {
+ const files = fileInput.files;
+ const phase = s.querySelector('#phase-select').value;
+ const caption = s.querySelector('#caption-input').value;
+ dialog.close();
+ this.#upload(files, phase, caption);
+ });
+ }
+
+ #setFiles(files, preview, btn) {
+ preview.innerHTML = Array.from(files).map(f =>
+ `
${this.#esc(f.name)}
`
+ ).join('');
+ if (window.lucide) lucide.createIcons({ nodes: [preview] });
+ btn.disabled = files.length === 0;
+ }
+
+ #openLightbox(idx, photos) {
+ const s = this.shadowRoot;
+ const show = (i) => {
+ const existing = s.querySelector('.lightbox');
+ if (existing) existing.remove();
+ if (i < 0 || i >= photos.length) return;
+ const p = photos[i];
+ const lb = document.createElement('div');
+ lb.className = 'lightbox';
+ lb.innerHTML = `
+
+
})
+ ${photos.length > 1 ? `
+
+
` : ''}
+ ${p.caption ? `
${this.#esc(p.caption)}
` : ''}`;
+ s.appendChild(lb);
+ if (window.lucide) lucide.createIcons({ nodes: [lb] });
+ lb.querySelector('.lightbox-close').addEventListener('click', () => lb.remove());
+ lb.addEventListener('click', e => { if (e.target === lb) lb.remove(); });
+ lb.querySelector('.lightbox-prev')?.addEventListener('click', e => { e.stopPropagation(); show(i - 1); });
+ lb.querySelector('.lightbox-next')?.addEventListener('click', e => { e.stopPropagation(); show(i + 1); });
+ };
+ show(idx);
+ }
+
+ #esc(s) { return (s || '').replace(/&/g,'&').replace(//g,'>'); }
+}
+
+customElements.define('wo-photo-panel', WoPhotoPanel);
diff --git a/web/components/work-orders/wo-resource-panel.mjs b/web/components/work-orders/wo-resource-panel.mjs
new file mode 100644
index 0000000..f621d44
--- /dev/null
+++ b/web/components/work-orders/wo-resource-panel.mjs
@@ -0,0 +1,277 @@
+import { api } from '../../lib/api.mjs';
+
+const SECTIONS = [
+ { type: 'person', label: 'People', icon: 'users', endpoint: 'people' },
+ { type: 'vehicle', label: 'Vehicles', icon: 'truck', endpoint: 'vehicles' },
+ { type: 'equipment', label: 'Equipment', icon: 'wrench', endpoint: 'equipment' },
+ { type: 'material', label: 'Materials', icon: 'package', endpoint: 'materials' },
+];
+
+class WoResourcePanel extends HTMLElement {
+ #woId = null;
+ #resources = [];
+ #registry = {};
+ #loading = true;
+ #pickerType = null;
+
+ static get observedAttributes() { return ['wo-id']; }
+
+ connectedCallback() {
+ if (!this.shadowRoot) this.attachShadow({ mode: 'open' });
+ if (this.#woId) this.#load();
+ }
+
+ attributeChangedCallback(_, __, val) {
+ this.#woId = val ? +val : null;
+ if (this.shadowRoot && this.#woId) this.#load();
+ }
+
+ async #load() {
+ this.#loading = true;
+ this.#render();
+ try {
+ 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 = { person: people || [], vehicle: vehicles || [], equipment: equipment || [], material: materials || [] };
+ } catch { this.#resources = []; this.#registry = {}; }
+ this.#loading = false;
+ this.#render();
+ }
+
+ async #assign(type, resourceId, quantity) {
+ try {
+ await api.post(`/work-orders/${this.#woId}/resources`, {
+ resource_type: type,
+ resource_id: +resourceId,
+ quantity: quantity ? +quantity : null,
+ });
+ await this.#load();
+ } catch (err) {
+ window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: err.message, type: 'error' } }));
+ }
+ }
+
+ async #remove(assignId) {
+ try {
+ await api.delete(`/work-orders/${this.#woId}/resources/${assignId}`);
+ await this.#load();
+ } catch (err) {
+ window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: err.message, type: 'error' } }));
+ }
+ }
+
+ #resourceName(item, type) {
+ if (type === 'vehicle') return `${item.unit_number}${item.description ? ' – ' + item.description : ''}`;
+ return item.name || item.unit_number || '—';
+ }
+
+ #sectionHTML(section) {
+ const assigned = this.#resources.filter(r => r.resource_type === section.type);
+ const count = assigned.length;
+ return `
+
+
+
+ ${count === 0 ? `
No ${section.label.toLowerCase()} assigned
` :
+ assigned.map(r => `
+
+ ${this.#esc(r.name)}
+ ${r.resource_type === 'material' && r.quantity != null
+ ? `${r.quantity} ${this.#unitFor(r.resource_id)}`
+ : ''}
+
+
`).join('')}
+
+
`;
+ }
+
+ #unitFor(resourceId) {
+ const item = (this.#registry['material'] || []).find(m => m.id === resourceId);
+ return item ? item.unit : '';
+ }
+
+ #render() {
+ const s = this.shadowRoot;
+ if (this.#loading) {
+ s.innerHTML = `
`;
+ return;
+ }
+
+ s.innerHTML = `
+
+
+
+ ${SECTIONS.map(sec => this.#sectionHTML(sec)).join('')}
+
+
+
`;
+
+ if (window.lucide) lucide.createIcons({ nodes: [s] });
+ this.#bindEvents();
+ }
+
+ #bindEvents() {
+ const s = this.shadowRoot;
+
+ s.querySelectorAll('[data-rid]').forEach(btn =>
+ btn.addEventListener('click', () => this.#remove(+btn.dataset.rid)));
+
+ s.querySelectorAll('.add-resource-btn').forEach(btn =>
+ btn.addEventListener('click', () => this.#openPicker(btn.dataset.type)));
+
+ const dialog = s.querySelector('#picker-dialog');
+ const closeBtn = s.querySelector('#picker-close');
+ const cancelBtn = s.querySelector('#picker-cancel');
+ const addBtn = s.querySelector('#picker-add');
+ const search = s.querySelector('#picker-search');
+
+ closeBtn.addEventListener('click', () => dialog.close());
+ cancelBtn.addEventListener('click', () => dialog.close());
+ dialog.addEventListener('click', e => { if (e.target === dialog) dialog.close(); });
+
+ search.addEventListener('input', () => this.#filterPicker(search.value));
+
+ addBtn.addEventListener('click', () => {
+ const selected = s.querySelector('.picker-item.selected');
+ if (!selected) return;
+ const qty = s.querySelector('#qty-input')?.value || null;
+ this.#assign(this.#pickerType, selected.dataset.id, qty);
+ dialog.close();
+ });
+ }
+
+ #openPicker(type) {
+ const s = this.shadowRoot;
+ const section = SECTIONS.find(sec => sec.type === type);
+ this.#pickerType = type;
+
+ s.querySelector('#picker-title').textContent = `Add ${section.label}`;
+ s.querySelector('#picker-search').value = '';
+ s.querySelector('#qty-row').style.display = type === 'material' ? 'flex' : 'none';
+ s.querySelector('#picker-add').disabled = true;
+
+ this.#filterPicker('');
+ s.querySelector('#picker-dialog').showModal();
+ s.querySelector('#picker-search').focus();
+ }
+
+ #filterPicker(query) {
+ const s = this.shadowRoot;
+ const type = this.#pickerType;
+ const list = this.#registry[type] || [];
+ const assigned = this.#resources.filter(r => r.resource_type === type).map(r => r.resource_id);
+ const q = query.toLowerCase();
+
+ const available = list.filter(item => {
+ if (assigned.includes(item.id)) return false;
+ const name = (item.name || item.unit_number || '').toLowerCase();
+ const sub = (item.role || item.vehicle_type || item.category || item.part_number || '').toLowerCase();
+ return !q || name.includes(q) || sub.includes(q);
+ });
+
+ const container = s.querySelector('#picker-list');
+ if (available.length === 0) {
+ container.innerHTML = '
No items available
';
+ return;
+ }
+
+ container.innerHTML = available.map(item => {
+ const name = type === 'vehicle'
+ ? `${item.unit_number}${item.description ? ' – ' + item.description : ''}`
+ : item.name;
+ const sub = item.role || item.vehicle_type || item.category || (item.part_number ? `#${item.part_number}` : '') || '';
+ return `
+
+
+
${this.#esc(name)}
+ ${sub ? `
${this.#esc(sub)}
` : ''}
+
+
`;
+ }).join('');
+
+ container.querySelectorAll('.picker-item').forEach(item => {
+ item.addEventListener('click', () => {
+ container.querySelectorAll('.picker-item').forEach(i => i.classList.remove('selected'));
+ item.classList.add('selected');
+ this.shadowRoot.querySelector('#picker-add').disabled = false;
+ });
+ });
+ }
+
+ #esc(s) { return (s || '').replace(/&/g,'&').replace(//g,'>'); }
+}
+
+customElements.define('wo-resource-panel', WoResourcePanel);
diff --git a/web/components/work-orders/wo-timeline.mjs b/web/components/work-orders/wo-timeline.mjs
new file mode 100644
index 0000000..5eb68e3
--- /dev/null
+++ b/web/components/work-orders/wo-timeline.mjs
@@ -0,0 +1,160 @@
+import { api } from '../../lib/api.mjs';
+
+const ACTION_ICONS = {
+ status_change: 'refresh-cw',
+ step_complete: 'check-square',
+ step_uncomplete: 'square',
+ resource_assigned: 'user-plus',
+ resource_removed: 'user-minus',
+ photo_upload: 'camera',
+ accounting_update: 'receipt',
+ created: 'plus-circle',
+ updated: 'edit-3',
+};
+
+const ACTION_LABELS = {
+ status_change: 'Status changed',
+ step_complete: 'Step completed',
+ step_uncomplete: 'Step reopened',
+ resource_assigned: 'Resource assigned',
+ resource_removed: 'Resource removed',
+ photo_upload: 'Photo uploaded',
+ accounting_update: 'Accounting updated',
+ created: 'Work order created',
+ updated: 'Work order updated',
+};
+
+class WoTimeline extends HTMLElement {
+ #woId = null;
+ #entries = [];
+ #loading = true;
+
+ static get observedAttributes() { return ['wo-id']; }
+
+ connectedCallback() {
+ if (!this.shadowRoot) this.attachShadow({ mode: 'open' });
+ if (this.#woId) this.#load();
+ }
+
+ attributeChangedCallback(_, __, val) {
+ this.#woId = val ? +val : null;
+ if (this.shadowRoot && this.#woId) this.#load();
+ }
+
+ async #load() {
+ this.#loading = true;
+ this.#render();
+ try { this.#entries = await api.get(`/work-orders/${this.#woId}/activity`) || []; }
+ catch { this.#entries = []; }
+ this.#loading = false;
+ this.#render();
+ }
+
+ #relativeTime(dateStr) {
+ const d = new Date(dateStr);
+ const diff = Date.now() - d.getTime();
+ const mins = Math.floor(diff / 60000);
+ if (mins < 1) return 'just now';
+ if (mins < 60) return `${mins}m ago`;
+ const hrs = Math.floor(mins / 60);
+ if (hrs < 24) return `${hrs}h ago`;
+ const days = Math.floor(hrs / 24);
+ if (days < 7) return `${days}d ago`;
+ return d.toLocaleDateString();
+ }
+
+ #render() {
+ const s = this.shadowRoot;
+ if (this.#loading) {
+ s.innerHTML = `
`;
+ return;
+ }
+
+ s.innerHTML = `
+
+
+ ${this.#entries.length === 0 ? `
+
+
+
No activity recorded yet
+
` : `
+
+ ${this.#entries.map(e => {
+ const icon = ACTION_ICONS[e.action] || 'info';
+ const label = ACTION_LABELS[e.action] || e.action.replace(/_/g,' ');
+ const cls = e.action.includes('status') ? 'status' : e.action.includes('step') ? 'step' : e.action.includes('photo') ? 'photo' : '';
+ return `
+
+
+
+
+
+
${this.#esc(label)}
+ ${this.#detailHTML(e)}
+
+ ${this.#esc(e.performed_by)}
+ ·
+ ${this.#relativeTime(e.performed_at)}
+
+
+
`;
+ }).join('')}
+
`}
+
+
`;
+
+ if (window.lucide) lucide.createIcons({ nodes: [s] });
+ s.querySelector('#refresh-btn').addEventListener('click', () => this.#load());
+ }
+
+ #detailHTML(e) {
+ if (e.action === 'status_change' && e.old_value && e.new_value) {
+ return `
+
+ ${this.#esc(e.old_value.replace('_',' '))}
+ →
+ ${this.#esc(e.new_value.replace('_',' '))}
+
+
`;
+ }
+ if (e.new_value) {
+ return `
${this.#esc(e.new_value)}
`;
+ }
+ return '';
+ }
+
+ #esc(s) { return (s || '').replace(/&/g,'&').replace(//g,'>'); }
+}
+
+customElements.define('wo-timeline', WoTimeline);