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 = ` + +
+ ${isEdit ? 'Edit Equipment' : 'Add Equipment'} + +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+ ${isEdit ? ` +
+ Active + +
` : ''} +
+
+ `; + + 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 = ` + +
+ ${isEdit ? 'Edit Material' : 'Add Material'} + +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+ ${isEdit ? ` +
+ Active + +
` : ''} +
+
+ `; + + 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 = ` + +
+ ${isEdit ? 'Edit Person' : 'Add Person'} + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ ${isEdit ? ` +
+ Active + +
` : ''} +
+
+ `; + + 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 ? 'Edit Profile' : 'New Profile'} + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+ ${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 = ` + +
+ ${isEdit ? 'Edit Vehicle' : 'Add Vehicle'} + +
+
+
+ + +
+
+ + +
+
+ + +
+ ${isEdit ? ` +
+ Active + +
` : ''} +
+
+ `; + + 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 = ` + +
+ Accounting Codes + +
+
+ ${CODE_TYPES.map((ct, i) => ` + ${i > 0 ? '
' : ''} +
+
${ct.label}
+
+ + +
+
`).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.step_order}. + ${this.#esc(st.title)} +
+ ${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 ? `
+ + +
+ Apply Work Order Profile + +
+
+ +
Loading profiles…
+ +
+ +
`; } #tabContent(wo) { @@ -191,17 +250,35 @@ class WoDetail extends HTMLElement { ? `
${this.#esc(wo.access_notes)}
` : '
No access notes
'} + ${wo.lat && wo.lng ? ` +
+
Location
+ +
` : ''} `; + 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'}
- `; + + + +
+ Load Work Order Profile + +
+
+ +
Loading…
+
+ +
`; } #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) => ` +
+ ${this.#esc(p.caption)} +
+ ${p.caption ? `
${this.#esc(p.caption)}
` : ''} +
+ ${p.phase ? `
${p.phase}
` : ''} + +
`).join('')} +
`} + + +
+ Add Photos + +
+
+
+ +
+ + Click to select photos or drag & drop here + +
+
+
+
+ + +
+
+ + +
+
+ +
`; + + 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 = ` + + ${this.#esc(p.caption)} + ${photos.length > 1 ? ` + + ` : ''} + ${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 ? `${count}` : ''} + +
+
+ ${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('')} +
+ + +
+ Add Resource + +
+
+ +
+ +
+ +
`; + + 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)} + +
+
`; + }).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);