Add migration scripts, activity handler, and registry components for equipment, materials, and people

This commit is contained in:
2026-05-17 10:11:56 -04:00
parent fb67c76f45
commit 17e05cb61d
28 changed files with 3777 additions and 34 deletions
+152 -1
View File
@@ -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.* *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)
}
```
+42
View File
@@ -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)
}
+285
View File
@@ -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,
})
}
+280 -8
View File
@@ -11,26 +11,298 @@ type RegistryHandler struct{ db *sqlx.DB }
func NewRegistryHandler(db *sqlx.DB) *RegistryHandler { return &RegistryHandler{db: 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 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) 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 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) 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 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) 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 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) 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})
}
+12
View File
@@ -93,6 +93,18 @@ func (h *StepHandler) Complete(w http.ResponseWriter, r *http.Request) {
respond(w, http.StatusOK, step) 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) { func (h *StepHandler) Delete(w http.ResponseWriter, r *http.Request) {
sid, err := intParam(r, "sid") sid, err := intParam(r, "sid")
if err != nil { if err != nil {
+38 -5
View File
@@ -20,6 +20,9 @@ func NewRouter(cfg *config.Config, db *sqlx.DB) http.Handler {
// Serve frontend static files // Serve frontend static files
r.Handle("/*", http.FileServer(http.Dir("./web"))) 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 // Public auth routes
auth := handlers.NewAuthHandler(db, cfg) auth := handlers.NewAuthHandler(db, cfg)
r.Post("/api/auth/login", auth.Login) 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.Post("/api/work-orders/{id}/steps", step.Create)
r.Put("/api/work-orders/{id}/steps/{sid}", step.Update) 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}/complete", step.Complete)
r.Post("/api/work-orders/{id}/steps/{sid}/uncomplete", step.Uncomplete)
r.Delete("/api/work-orders/{id}/steps/{sid}", step.Delete) r.Delete("/api/work-orders/{id}/steps/{sid}", step.Delete)
res := handlers.NewResourceHandler(db) 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.Get("/api/work-orders/{id}/attachments", att.List)
r.Post("/api/work-orders/{id}/attachments", att.Upload) r.Post("/api/work-orders/{id}/attachments", att.Upload)
r.Delete("/api/work-orders/{id}/attachments/{aid}", att.Delete) 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) acc := handlers.NewAccountingHandler(db)
r.Get("/api/work-orders/{id}/accounting", acc.Get) r.Get("/api/work-orders/{id}/accounting", acc.Get)
r.Put("/api/work-orders/{id}/accounting", acc.Upsert) 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) reg := handlers.NewRegistryHandler(db)
r.Get("/api/registry/people", reg.People) r.Get("/api/registry/people", reg.ListPeople)
r.Get("/api/registry/vehicles", reg.Vehicles) r.Post("/api/registry/people", reg.CreatePerson)
r.Get("/api/registry/equipment", reg.Equipment) r.Put("/api/registry/people/{id}", reg.UpdatePerson)
r.Get("/api/registry/materials", reg.Materials) 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 return r
+44 -5
View File
@@ -101,11 +101,12 @@ type AccountingCode struct {
// ── Resource Registry ───────────────────────────────────────────────────────── // ── Resource Registry ─────────────────────────────────────────────────────────
type RegistryPerson struct { type RegistryPerson struct {
ID int `db:"id" json:"id"` ID int `db:"id" json:"id"`
Name string `db:"name" json:"name"` Name string `db:"name" json:"name"`
Role string `db:"role" json:"role"` Role string `db:"role" json:"role"`
Email string `db:"email" json:"email"` Email string `db:"email" json:"email"`
Phone string `db:"phone" json:"phone"` Phone string `db:"phone" json:"phone"`
Active bool `db:"active" json:"active"`
} }
type RegistryVehicle struct { type RegistryVehicle struct {
@@ -113,6 +114,7 @@ type RegistryVehicle struct {
UnitNumber string `db:"unit_number" json:"unit_number"` UnitNumber string `db:"unit_number" json:"unit_number"`
Description string `db:"description" json:"description"` Description string `db:"description" json:"description"`
VehicleType string `db:"vehicle_type" json:"vehicle_type"` VehicleType string `db:"vehicle_type" json:"vehicle_type"`
Active bool `db:"active" json:"active"`
} }
type RegistryEquipment struct { type RegistryEquipment struct {
@@ -120,6 +122,7 @@ type RegistryEquipment struct {
Name string `db:"name" json:"name"` Name string `db:"name" json:"name"`
AssetTag string `db:"asset_tag" json:"asset_tag"` AssetTag string `db:"asset_tag" json:"asset_tag"`
Category string `db:"category" json:"category"` Category string `db:"category" json:"category"`
Active bool `db:"active" json:"active"`
} }
type RegistryMaterial struct { type RegistryMaterial struct {
@@ -127,6 +130,42 @@ type RegistryMaterial struct {
Name string `db:"name" json:"name"` Name string `db:"name" json:"name"`
Unit string `db:"unit" json:"unit"` Unit string `db:"unit" json:"unit"`
PartNumber string `db:"part_number" json:"part_number"` 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 ────────────────────────────────────────────────────────────── // ── Users & Auth ──────────────────────────────────────────────────────────────
+31
View File
@@ -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
+16 -4
View File
@@ -13,6 +13,17 @@ import './components/work-orders/wo-list.mjs';
import './components/work-orders/wo-kanban.mjs'; import './components/work-orders/wo-kanban.mjs';
import './components/work-orders/wo-form.mjs'; import './components/work-orders/wo-form.mjs';
import './components/work-orders/wo-detail.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 { getUser, setToken, clearToken } from './lib/auth.mjs';
import { api } from './lib/api.mjs'; import { api } from './lib/api.mjs';
@@ -121,10 +132,11 @@ function startApp() {
.on('/work-orders/new', () => appRoot.setPage('<wo-form></wo-form>')) .on('/work-orders/new', () => appRoot.setPage('<wo-form></wo-form>'))
.on('/work-orders/:id/edit', ({ id }) => appRoot.setPage(`<wo-form wo-id="${id}"></wo-form>`)) .on('/work-orders/:id/edit', ({ id }) => appRoot.setPage(`<wo-form wo-id="${id}"></wo-form>`))
.on('/work-orders/:id', ({ id }) => appRoot.setPage(`<wo-detail wo-id="${id}"></wo-detail>`)) .on('/work-orders/:id', ({ id }) => appRoot.setPage(`<wo-detail wo-id="${id}"></wo-detail>`))
.on('/registry/people', () => appRoot.setPage('<p style="padding:2rem;color:var(--text-muted)">People registry — Phase 2</p>')) .on('/registry/people', () => appRoot.setPage('<people-list></people-list>'))
.on('/registry/vehicles', () => appRoot.setPage('<p style="padding:2rem;color:var(--text-muted)">Vehicles registry — Phase 2</p>')) .on('/registry/vehicles', () => appRoot.setPage('<vehicle-list></vehicle-list>'))
.on('/registry/equipment', () => appRoot.setPage('<p style="padding:2rem;color:var(--text-muted)">Equipment registry — Phase 2</p>')) .on('/registry/equipment', () => appRoot.setPage('<equipment-list></equipment-list>'))
.on('/registry/materials', () => appRoot.setPage('<p style="padding:2rem;color:var(--text-muted)">Materials registry — Phase 2</p>')) .on('/registry/materials', () => appRoot.setPage('<material-list></material-list>'))
.on('/registry/profiles', () => appRoot.setPage('<profile-list></profile-list>'))
.on('/reports', () => appRoot.setPage('<p style="padding:2rem;color:var(--text-muted)">Reports — Phase 3</p>')) .on('/reports', () => appRoot.setPage('<p style="padding:2rem;color:var(--text-muted)">Reports — Phase 3</p>'))
.on('/users', () => appRoot.setPage('<p style="padding:2rem;color:var(--text-muted)">User management — Phase 3</p>')) .on('/users', () => appRoot.setPage('<p style="padding:2rem;color:var(--text-muted)">User management — Phase 3</p>'))
.on('/settings', () => appRoot.setPage('<p style="padding:2rem;color:var(--text-muted)">Settings — Phase 4</p>')) .on('/settings', () => appRoot.setPage('<p style="padding:2rem;color:var(--text-muted)">Settings — Phase 4</p>'))
+1
View File
@@ -8,6 +8,7 @@ const NAV = [
{ label: 'Vehicles', href: '/registry/vehicles', icon: 'truck', section: 'resources' }, { label: 'Vehicles', href: '/registry/vehicles', icon: 'truck', section: 'resources' },
{ label: 'Equipment', href: '/registry/equipment', icon: 'wrench', section: 'resources' }, { label: 'Equipment', href: '/registry/equipment', icon: 'wrench', section: 'resources' },
{ label: 'Materials', href: '/registry/materials', icon: 'package', 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' }, { label: 'Reports', href: '/reports', icon: 'bar-chart-2', section: 'operations' },
]; ];
+115
View File
@@ -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 = `<style>:host{display:block}</style><dialog></dialog>`;
}
}
open(equipment, onSave) {
this.#onSave = onSave;
const isEdit = !!equipment;
const d = this.shadowRoot.querySelector('dialog');
d.innerHTML = `
<style>
dialog { border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 0; box-shadow: var(--shadow-lg); width: 440px; max-width: 95vw; background: var(--surface); color: var(--text); }
dialog::backdrop { background: rgba(0,0,0,.45); }
.dlg-header { display: flex; align-items: center; justify-content: space-between; padding: 1.1rem 1.25rem; border-bottom: 1px solid var(--border); }
.dlg-title { font-size: 1rem; font-weight: 700; }
.dlg-close { background: none; border: none; cursor: pointer; color: var(--text-muted); padding: .25rem; display: flex; }
form { padding: 1.25rem; display: flex; flex-direction: column; gap: .875rem; }
.field-label { font-size: .813rem; font-weight: 600; color: var(--text); display: block; margin-bottom: .3rem; }
.field-input, .field-select { width: 100%; border: 1px solid var(--border); border-radius: var(--radius-sm); padding: .55rem .75rem; font-size: .875rem; background: var(--surface); color: var(--text); box-sizing: border-box; transition: border-color .15s; }
.field-input:focus, .field-select:focus { outline: none; border-color: var(--teal); box-shadow: 0 0 0 3px rgba(10,126,164,.12); }
.row-2 { display: grid; grid-template-columns: 1fr 1fr; gap: .75rem; }
.toggle-row { display: flex; align-items: center; justify-content: space-between; padding: .5rem 0; }
.toggle-label { font-size: .875rem; font-weight: 500; }
input[type=checkbox] { width: 18px; height: 18px; accent-color: var(--teal); cursor: pointer; }
.dlg-footer { padding: .875rem 1.25rem; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; gap: .5rem; }
.btn-ghost { background: transparent; border: 1px solid var(--border); color: var(--text); border-radius: var(--radius); padding: .5rem 1rem; font-size: .875rem; font-weight: 600; cursor: pointer; }
.btn-primary { background: var(--teal); color: #fff; border: none; border-radius: var(--radius); padding: .5rem 1rem; font-size: .875rem; font-weight: 600; cursor: pointer; }
.btn-primary:disabled { opacity: .5; cursor: not-allowed; }
.error-msg { color: var(--danger); font-size: .813rem; min-height: 1.2em; }
</style>
<div class="dlg-header">
<span class="dlg-title">${isEdit ? 'Edit Equipment' : 'Add Equipment'}</span>
<button class="dlg-close" id="dlg-close"><i data-lucide="x" style="width:16px;height:16px"></i></button>
</div>
<form id="eq-form" novalidate>
<div>
<label class="field-label" for="eq-name">Name *</label>
<input class="field-input" id="eq-name" type="text" value="${this.#esc(equipment?.name || '')}" placeholder="e.g. Fujikura CT-50 Splicer" required maxlength="200">
</div>
<div class="row-2">
<div>
<label class="field-label" for="eq-tag">Asset Tag</label>
<input class="field-input" id="eq-tag" type="text" value="${this.#esc(equipment?.asset_tag || '')}" placeholder="e.g. EQ-1042" maxlength="100">
</div>
<div>
<label class="field-label" for="eq-cat">Category</label>
<select class="field-select" id="eq-cat">
<option value="">— Select —</option>
${CATEGORIES.map(c => `<option value="${c}" ${equipment?.category === c ? 'selected' : ''}>${c}</option>`).join('')}
</select>
</div>
</div>
${isEdit ? `
<div class="toggle-row">
<span class="toggle-label">Active</span>
<input type="checkbox" id="eq-active" ${equipment.active ? 'checked' : ''}>
</div>` : ''}
<div class="error-msg" id="form-error"></div>
</form>
<div class="dlg-footer">
<button class="btn-ghost" id="dlg-cancel">Cancel</button>
<button class="btn-primary" id="dlg-save">${isEdit ? 'Save Changes' : 'Add Equipment'}</button>
</div>`;
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
}
customElements.define('equipment-form', EquipmentForm);
+129
View File
@@ -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 = `
<style>
:host { display: block; }
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.25rem; flex-wrap: wrap; gap: .75rem; }
h1 { font-size: 1.2rem; font-weight: 700; color: var(--text); }
.toolbar { display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; }
.search-input { border: 1px solid var(--border); border-radius: var(--radius-sm); padding: .45rem .75rem; font-size: .875rem; background: var(--surface); color: var(--text); width: 200px; }
.search-input:focus { outline: none; border-color: var(--teal); box-shadow: 0 0 0 3px rgba(10,126,164,.12); }
.btn-primary { display: inline-flex; align-items: center; gap: .35rem; background: var(--teal); color: #fff; border: none; border-radius: var(--radius); padding: .5rem 1rem; font-size: .875rem; font-weight: 600; cursor: pointer; }
.btn-primary:hover { opacity: .88; }
.list { display: flex; flex-direction: column; gap: .5rem; }
.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: .875rem 1rem; display: flex; align-items: center; gap: 1rem; box-shadow: var(--shadow-sm); }
.card:hover { box-shadow: var(--shadow-md); }
.eq-icon { width: 40px; height: 40px; border-radius: var(--radius); background: var(--surface-2); display: flex; align-items: center; justify-content: center; color: var(--warning); flex-shrink: 0; }
.info { flex: 1; min-width: 0; }
.eq-name { font-weight: 600; color: var(--text); font-size: .938rem; }
.meta { font-size: .813rem; color: var(--text-muted); margin-top: .15rem; display: flex; gap: .75rem; flex-wrap: wrap; }
.asset-tag { font-family: monospace; font-size: .813rem; background: var(--surface-2); padding: .1rem .4rem; border-radius: var(--radius-sm); }
.status-badge { display: inline-flex; padding: .15rem .55rem; border-radius: 999px; font-size: .7rem; font-weight: 700; text-transform: uppercase; }
.status-active { background: #DCFCE7; color: #15803D; }
.status-inactive { background: var(--surface-2); color: var(--text-muted); }
.actions { display: flex; gap: .35rem; flex-shrink: 0; }
.icon-btn { background: none; border: 1px solid var(--border); border-radius: var(--radius-sm); padding: .35rem .5rem; cursor: pointer; color: var(--text-muted); display: flex; align-items: center; transition: background .15s; }
.icon-btn:hover { background: var(--surface-2); color: var(--text); }
.icon-btn.danger:hover { background: #FEF2F2; color: var(--danger); border-color: var(--danger); }
.empty { text-align: center; padding: 3rem; color: var(--text-muted); }
</style>
<div class="page-header">
<h1>Equipment</h1>
<div class="toolbar">
<input class="search-input" id="search" placeholder="Search equipment…" value="${this.#esc(this.#search)}">
<button class="btn-primary" id="new-btn">
<i data-lucide="plus" style="width:14px;height:14px"></i> Add Equipment
</button>
</div>
</div>
${this.#loading ? '<ui-spinner></ui-spinner>' :
this.#items.length === 0
? `<div class="empty">
<i data-lucide="wrench" style="width:32px;height:32px;opacity:.3;display:block;margin:0 auto .5rem"></i>
No equipment found
</div>`
: `<div class="list">
${this.#items.map(eq => `
<div class="card">
<div class="eq-icon"><i data-lucide="wrench" style="width:18px;height:18px"></i></div>
<div class="info">
<div class="eq-name">${this.#esc(eq.name)}</div>
<div class="meta">
${eq.asset_tag ? `<span class="asset-tag">${this.#esc(eq.asset_tag)}</span>` : ''}
${eq.category ? `<span>${this.#esc(eq.category)}</span>` : ''}
</div>
</div>
<span class="status-badge ${eq.active ? 'status-active' : 'status-inactive'}">${eq.active ? 'Active' : 'Inactive'}</span>
<div class="actions">
<button class="icon-btn" data-edit="${eq.id}" title="Edit">
<i data-lucide="pencil" style="width:14px;height:14px"></i>
</button>
${eq.active ? `<button class="icon-btn danger" data-deactivate="${eq.id}" title="Deactivate">
<i data-lucide="ban" style="width:14px;height:14px"></i>
</button>` : ''}
</div>
</div>`).join('')}
</div>`}
<equipment-form id="equipment-form"></equipment-form>`;
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
}
customElements.define('equipment-list', EquipmentList);
+115
View File
@@ -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 = `<style>:host{display:block}</style><dialog></dialog>`;
}
}
open(material, onSave) {
this.#onSave = onSave;
const isEdit = !!material;
const d = this.shadowRoot.querySelector('dialog');
d.innerHTML = `
<style>
dialog { border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 0; box-shadow: var(--shadow-lg); width: 440px; max-width: 95vw; background: var(--surface); color: var(--text); }
dialog::backdrop { background: rgba(0,0,0,.45); }
.dlg-header { display: flex; align-items: center; justify-content: space-between; padding: 1.1rem 1.25rem; border-bottom: 1px solid var(--border); }
.dlg-title { font-size: 1rem; font-weight: 700; }
.dlg-close { background: none; border: none; cursor: pointer; color: var(--text-muted); padding: .25rem; display: flex; }
form { padding: 1.25rem; display: flex; flex-direction: column; gap: .875rem; }
.field-label { font-size: .813rem; font-weight: 600; color: var(--text); display: block; margin-bottom: .3rem; }
.field-input, .field-select { width: 100%; border: 1px solid var(--border); border-radius: var(--radius-sm); padding: .55rem .75rem; font-size: .875rem; background: var(--surface); color: var(--text); box-sizing: border-box; transition: border-color .15s; }
.field-input:focus, .field-select:focus { outline: none; border-color: var(--teal); box-shadow: 0 0 0 3px rgba(10,126,164,.12); }
.row-2 { display: grid; grid-template-columns: 1fr 1fr; gap: .75rem; }
.toggle-row { display: flex; align-items: center; justify-content: space-between; padding: .5rem 0; }
.toggle-label { font-size: .875rem; font-weight: 500; }
input[type=checkbox] { width: 18px; height: 18px; accent-color: var(--teal); cursor: pointer; }
.dlg-footer { padding: .875rem 1.25rem; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; gap: .5rem; }
.btn-ghost { background: transparent; border: 1px solid var(--border); color: var(--text); border-radius: var(--radius); padding: .5rem 1rem; font-size: .875rem; font-weight: 600; cursor: pointer; }
.btn-primary { background: var(--teal); color: #fff; border: none; border-radius: var(--radius); padding: .5rem 1rem; font-size: .875rem; font-weight: 600; cursor: pointer; }
.btn-primary:disabled { opacity: .5; cursor: not-allowed; }
.error-msg { color: var(--danger); font-size: .813rem; min-height: 1.2em; }
</style>
<div class="dlg-header">
<span class="dlg-title">${isEdit ? 'Edit Material' : 'Add Material'}</span>
<button class="dlg-close" id="dlg-close"><i data-lucide="x" style="width:16px;height:16px"></i></button>
</div>
<form id="mat-form" novalidate>
<div>
<label class="field-label" for="m-name">Name *</label>
<input class="field-input" id="m-name" type="text" value="${this.#esc(material?.name || '')}" placeholder="e.g. Single Mode Fiber Cable" required maxlength="200">
</div>
<div class="row-2">
<div>
<label class="field-label" for="m-unit">Unit of Measure</label>
<select class="field-select" id="m-unit">
<option value="">— Select —</option>
${UNITS.map(u => `<option value="${u}" ${material?.unit === u ? 'selected' : ''}>${u}</option>`).join('')}
</select>
</div>
<div>
<label class="field-label" for="m-part">Part Number</label>
<input class="field-input" id="m-part" type="text" value="${this.#esc(material?.part_number || '')}" placeholder="e.g. SMF-09-500" maxlength="100">
</div>
</div>
${isEdit ? `
<div class="toggle-row">
<span class="toggle-label">Active</span>
<input type="checkbox" id="m-active" ${material.active ? 'checked' : ''}>
</div>` : ''}
<div class="error-msg" id="form-error"></div>
</form>
<div class="dlg-footer">
<button class="btn-ghost" id="dlg-cancel">Cancel</button>
<button class="btn-primary" id="dlg-save">${isEdit ? 'Save Changes' : 'Add Material'}</button>
</div>`;
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
}
customElements.define('material-form', MaterialForm);
+130
View File
@@ -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 = `
<style>
:host { display: block; }
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.25rem; flex-wrap: wrap; gap: .75rem; }
h1 { font-size: 1.2rem; font-weight: 700; color: var(--text); }
.toolbar { display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; }
.search-input { border: 1px solid var(--border); border-radius: var(--radius-sm); padding: .45rem .75rem; font-size: .875rem; background: var(--surface); color: var(--text); width: 200px; }
.search-input:focus { outline: none; border-color: var(--teal); box-shadow: 0 0 0 3px rgba(10,126,164,.12); }
.btn-primary { display: inline-flex; align-items: center; gap: .35rem; background: var(--teal); color: #fff; border: none; border-radius: var(--radius); padding: .5rem 1rem; font-size: .875rem; font-weight: 600; cursor: pointer; }
.btn-primary:hover { opacity: .88; }
.list { display: flex; flex-direction: column; gap: .5rem; }
.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: .875rem 1rem; display: flex; align-items: center; gap: 1rem; box-shadow: var(--shadow-sm); }
.card:hover { box-shadow: var(--shadow-md); }
.mat-icon { width: 40px; height: 40px; border-radius: var(--radius); background: var(--surface-2); display: flex; align-items: center; justify-content: center; color: var(--text-muted); flex-shrink: 0; }
.info { flex: 1; min-width: 0; }
.mat-name { font-weight: 600; color: var(--text); font-size: .938rem; }
.meta { font-size: .813rem; color: var(--text-muted); margin-top: .15rem; display: flex; gap: .75rem; align-items: center; }
.unit-pill { background: var(--surface-2); border-radius: var(--radius-sm); padding: .1rem .5rem; font-size: .75rem; font-weight: 600; color: var(--text-muted); }
.part-num { font-family: monospace; font-size: .813rem; }
.status-badge { display: inline-flex; padding: .15rem .55rem; border-radius: 999px; font-size: .7rem; font-weight: 700; text-transform: uppercase; }
.status-active { background: #DCFCE7; color: #15803D; }
.status-inactive { background: var(--surface-2); color: var(--text-muted); }
.actions { display: flex; gap: .35rem; flex-shrink: 0; }
.icon-btn { background: none; border: 1px solid var(--border); border-radius: var(--radius-sm); padding: .35rem .5rem; cursor: pointer; color: var(--text-muted); display: flex; align-items: center; transition: background .15s; }
.icon-btn:hover { background: var(--surface-2); color: var(--text); }
.icon-btn.danger:hover { background: #FEF2F2; color: var(--danger); border-color: var(--danger); }
.empty { text-align: center; padding: 3rem; color: var(--text-muted); }
</style>
<div class="page-header">
<h1>Materials</h1>
<div class="toolbar">
<input class="search-input" id="search" placeholder="Search materials…" value="${this.#esc(this.#search)}">
<button class="btn-primary" id="new-btn">
<i data-lucide="plus" style="width:14px;height:14px"></i> Add Material
</button>
</div>
</div>
${this.#loading ? '<ui-spinner></ui-spinner>' :
this.#items.length === 0
? `<div class="empty">
<i data-lucide="package" style="width:32px;height:32px;opacity:.3;display:block;margin:0 auto .5rem"></i>
No materials found
</div>`
: `<div class="list">
${this.#items.map(m => `
<div class="card">
<div class="mat-icon"><i data-lucide="package" style="width:18px;height:18px"></i></div>
<div class="info">
<div class="mat-name">${this.#esc(m.name)}</div>
<div class="meta">
${m.unit ? `<span class="unit-pill">${this.#esc(m.unit)}</span>` : ''}
${m.part_number ? `<span class="part-num">#${this.#esc(m.part_number)}</span>` : ''}
</div>
</div>
<span class="status-badge ${m.active ? 'status-active' : 'status-inactive'}">${m.active ? 'Active' : 'Inactive'}</span>
<div class="actions">
<button class="icon-btn" data-edit="${m.id}" title="Edit">
<i data-lucide="pencil" style="width:14px;height:14px"></i>
</button>
${m.active ? `<button class="icon-btn danger" data-deactivate="${m.id}" title="Deactivate">
<i data-lucide="ban" style="width:14px;height:14px"></i>
</button>` : ''}
</div>
</div>`).join('')}
</div>`}
<material-form id="material-form"></material-form>`;
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
}
customElements.define('material-list', MaterialList);
+116
View File
@@ -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 = `<style>:host{display:block}</style><dialog></dialog>`;
}
}
open(person, onSave) {
this.#onSave = onSave;
const isEdit = !!person;
const d = this.shadowRoot.querySelector('dialog');
d.innerHTML = `
<style>
dialog { border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 0; box-shadow: var(--shadow-lg); width: 440px; max-width: 95vw; background: var(--surface); color: var(--text); }
dialog::backdrop { background: rgba(0,0,0,.45); }
.dlg-header { display: flex; align-items: center; justify-content: space-between; padding: 1.1rem 1.25rem; border-bottom: 1px solid var(--border); }
.dlg-title { font-size: 1rem; font-weight: 700; }
.dlg-close { background: none; border: none; cursor: pointer; color: var(--text-muted); padding: .25rem; display: flex; }
form { padding: 1.25rem; display: flex; flex-direction: column; gap: .875rem; }
.field-label { font-size: .813rem; font-weight: 600; color: var(--text); display: block; margin-bottom: .3rem; }
.field-input, .field-select { width: 100%; border: 1px solid var(--border); border-radius: var(--radius-sm); padding: .55rem .75rem; font-size: .875rem; background: var(--surface); color: var(--text); box-sizing: border-box; transition: border-color .15s; }
.field-input:focus, .field-select:focus { outline: none; border-color: var(--teal); box-shadow: 0 0 0 3px rgba(10,126,164,.12); }
.row-2 { display: grid; grid-template-columns: 1fr 1fr; gap: .75rem; }
.toggle-row { display: flex; align-items: center; justify-content: space-between; padding: .5rem 0; }
.toggle-label { font-size: .875rem; font-weight: 500; }
input[type=checkbox] { width: 18px; height: 18px; accent-color: var(--teal); cursor: pointer; }
.dlg-footer { padding: .875rem 1.25rem; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; gap: .5rem; }
.btn-ghost { background: transparent; border: 1px solid var(--border); color: var(--text); border-radius: var(--radius); padding: .5rem 1rem; font-size: .875rem; font-weight: 600; cursor: pointer; }
.btn-primary { background: var(--teal); color: #fff; border: none; border-radius: var(--radius); padding: .5rem 1rem; font-size: .875rem; font-weight: 600; cursor: pointer; }
.btn-primary:disabled { opacity: .5; cursor: not-allowed; }
.error-msg { color: var(--danger); font-size: .813rem; min-height: 1.2em; }
</style>
<div class="dlg-header">
<span class="dlg-title">${isEdit ? 'Edit Person' : 'Add Person'}</span>
<button class="dlg-close" id="dlg-close"><i data-lucide="x" style="width:16px;height:16px"></i></button>
</div>
<form id="person-form" novalidate>
<div>
<label class="field-label" for="p-name">Name *</label>
<input class="field-input" id="p-name" type="text" value="${this.#esc(person?.name || '')}" placeholder="Full name" required maxlength="100">
</div>
<div>
<label class="field-label" for="p-role">Role / Title</label>
<input class="field-input" id="p-role" type="text" value="${this.#esc(person?.role || '')}" placeholder="e.g. Technician, Foreman" maxlength="100">
</div>
<div class="row-2">
<div>
<label class="field-label" for="p-email">Email</label>
<input class="field-input" id="p-email" type="email" value="${this.#esc(person?.email || '')}" placeholder="email@example.com" maxlength="200">
</div>
<div>
<label class="field-label" for="p-phone">Phone</label>
<input class="field-input" id="p-phone" type="tel" value="${this.#esc(person?.phone || '')}" placeholder="(555) 000-0000" maxlength="30">
</div>
</div>
${isEdit ? `
<div class="toggle-row">
<span class="toggle-label">Active</span>
<input type="checkbox" id="p-active" ${person.active ? 'checked' : ''}>
</div>` : ''}
<div class="error-msg" id="form-error"></div>
</form>
<div class="dlg-footer">
<button class="btn-ghost" id="dlg-cancel">Cancel</button>
<button class="btn-primary" id="dlg-save">${isEdit ? 'Save Changes' : 'Add Person'}</button>
</div>`;
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
}
customElements.define('people-form', PeopleForm);
+143
View File
@@ -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 = `
<style>
:host { display: block; }
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.25rem; flex-wrap: wrap; gap: .75rem; }
h1 { font-size: 1.2rem; font-weight: 700; color: var(--text); }
.toolbar { display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; }
.search-input { border: 1px solid var(--border); border-radius: var(--radius-sm); padding: .45rem .75rem; font-size: .875rem; background: var(--surface); color: var(--text); width: 200px; }
.search-input:focus { outline: none; border-color: var(--teal); box-shadow: 0 0 0 3px rgba(10,126,164,.12); }
.btn-primary { display: inline-flex; align-items: center; gap: .35rem; background: var(--teal); color: #fff; border: none; border-radius: var(--radius); padding: .5rem 1rem; font-size: .875rem; font-weight: 600; cursor: pointer; }
.btn-primary:hover { opacity: .88; }
.people-grid { display: flex; flex-direction: column; gap: .5rem; }
.person-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: .875rem 1rem; display: flex; align-items: center; gap: 1rem; box-shadow: var(--shadow-sm); transition: box-shadow .15s; }
.person-card:hover { box-shadow: var(--shadow-md); }
.avatar { width: 40px; height: 40px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: .875rem; font-weight: 700; color: #fff; flex-shrink: 0; }
.person-info { flex: 1; min-width: 0; }
.person-name { font-weight: 600; color: var(--text); font-size: .938rem; }
.person-meta { font-size: .813rem; color: var(--text-muted); margin-top: .15rem; display: flex; gap: .75rem; flex-wrap: wrap; }
.person-meta span { display: flex; align-items: center; gap: .25rem; }
.status-badge { display: inline-flex; align-items: center; gap: .3rem; padding: .15rem .55rem; border-radius: 999px; font-size: .7rem; font-weight: 700; text-transform: uppercase; }
.status-active { background: #DCFCE7; color: #15803D; }
.status-inactive { background: var(--surface-2); color: var(--text-muted); }
.actions { display: flex; gap: .35rem; flex-shrink: 0; }
.icon-btn { background: none; border: 1px solid var(--border); border-radius: var(--radius-sm); padding: .35rem .5rem; cursor: pointer; color: var(--text-muted); display: flex; align-items: center; transition: background .15s; }
.icon-btn:hover { background: var(--surface-2); color: var(--text); }
.icon-btn.danger:hover { background: #FEF2F2; color: var(--danger); border-color: var(--danger); }
.empty { text-align: center; padding: 3rem; color: var(--text-muted); }
@media (max-width: 768px) { .person-meta { display: none; } }
</style>
<div class="page-header">
<h1>People</h1>
<div class="toolbar">
<input class="search-input" id="search" placeholder="Search people…" value="${this.#esc(this.#search)}">
<button class="btn-primary" id="new-btn">
<i data-lucide="user-plus" style="width:14px;height:14px"></i> Add Person
</button>
</div>
</div>
${this.#loading ? '<ui-spinner></ui-spinner>' :
this.#people.length === 0
? `<div class="empty">
<i data-lucide="users" style="width:32px;height:32px;opacity:.3;display:block;margin:0 auto .5rem"></i>
No people found
</div>`
: `<div class="people-grid">
${this.#people.map(p => `
<div class="person-card">
<div class="avatar" style="background:${this.#avatarColor(p.name)}">${this.#initials(p.name)}</div>
<div class="person-info">
<div class="person-name">${this.#esc(p.name)}</div>
<div class="person-meta">
${p.role ? `<span><i data-lucide="briefcase" style="width:12px;height:12px"></i>${this.#esc(p.role)}</span>` : ''}
${p.phone ? `<span><i data-lucide="phone" style="width:12px;height:12px"></i>${this.#esc(p.phone)}</span>` : ''}
${p.email ? `<span><i data-lucide="mail" style="width:12px;height:12px"></i>${this.#esc(p.email)}</span>` : ''}
</div>
</div>
<span class="status-badge ${p.active ? 'status-active' : 'status-inactive'}">${p.active ? 'Active' : 'Inactive'}</span>
<div class="actions">
<button class="icon-btn" data-edit="${p.id}" title="Edit">
<i data-lucide="pencil" style="width:14px;height:14px"></i>
</button>
${p.active ? `<button class="icon-btn danger" data-deactivate="${p.id}" title="Deactivate">
<i data-lucide="user-x" style="width:14px;height:14px"></i>
</button>` : ''}
</div>
</div>`).join('')}
</div>`}
<people-form id="people-form"></people-form>`;
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
}
customElements.define('people-list', PeopleList);
+232
View File
@@ -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 = `<style>:host{display:block}</style><dialog></dialog>`;
}
}
open(profile, onSave) {
this.#onSave = onSave;
const isEdit = !!profile;
const d = this.shadowRoot.querySelector('dialog');
d.innerHTML = `
<style>
dialog { border:1px solid var(--border); border-radius:var(--radius-lg); padding:0; box-shadow:var(--shadow-lg); width:600px; max-width:96vw; max-height:90vh; background:var(--surface); color:var(--text); display:flex; flex-direction:column; }
dialog::backdrop { background:rgba(0,0,0,.45); }
.dlg-header { display:flex; align-items:center; justify-content:space-between; padding:1.1rem 1.25rem; border-bottom:1px solid var(--border); flex-shrink:0; }
.dlg-title { font-size:1rem; font-weight:700; }
.dlg-close { background:none; border:none; cursor:pointer; color:var(--text-muted); padding:.25rem; display:flex; }
.dlg-body { padding:1.25rem; display:flex; flex-direction:column; gap:.875rem; overflow-y:auto; flex:1; }
.field-label { font-size:.813rem; font-weight:600; color:var(--text); display:block; margin-bottom:.3rem; }
.field-input,.field-select,.field-textarea { width:100%; border:1px solid var(--border); border-radius:var(--radius-sm); padding:.55rem .75rem; font-size:.875rem; background:var(--surface); color:var(--text); box-sizing:border-box; transition:border-color .15s; font-family:inherit; }
.field-input:focus,.field-select:focus,.field-textarea:focus { outline:none; border-color:var(--teal); box-shadow:0 0 0 3px rgba(10,126,164,.12); }
.field-textarea { resize:vertical; min-height:80px; line-height:1.5; }
.row-2 { display:grid; grid-template-columns:1fr 1fr; gap:.75rem; }
.toggle-row { display:flex; align-items:center; justify-content:space-between; padding:.5rem 0; }
.toggle-label { font-size:.875rem; font-weight:500; }
input[type=checkbox] { width:18px; height:18px; accent-color:var(--teal); cursor:pointer; }
.section-title { font-size:.75rem; font-weight:700; color:var(--text-muted); text-transform:uppercase; letter-spacing:.06em; padding-top:.25rem; border-top:1px solid var(--border); margin-top:.25rem; }
.step-list { display:flex; flex-direction:column; gap:.4rem; }
.step-row { display:flex; align-items:center; gap:.5rem; background:var(--surface-2); border-radius:var(--radius-sm); padding:.4rem .6rem; }
.step-order { font-size:.75rem; font-weight:700; color:var(--text-muted); min-width:1.5rem; text-align:center; }
.step-title-input { flex:1; border:none; background:transparent; font-size:.875rem; color:var(--text); padding:.15rem .3rem; border-radius:4px; }
.step-title-input:focus { outline:1px solid var(--teal); background:var(--surface); }
.step-req { font-size:.7rem; color:var(--text-muted); white-space:nowrap; display:flex; align-items:center; gap:.25rem; }
.step-del { background:none; border:none; cursor:pointer; color:var(--text-muted); padding:.2rem; display:flex; border-radius:4px; }
.step-del:hover { color:var(--danger); background:#FEF2F2; }
.add-step-row { display:flex; gap:.5rem; }
.add-step-input { flex:1; border:1px solid var(--border); border-radius:var(--radius-sm); padding:.45rem .75rem; font-size:.875rem; background:var(--surface); color:var(--text); }
.add-step-input:focus { outline:none; border-color:var(--teal); }
.btn-add-step { background:var(--teal); color:#fff; border:none; border-radius:var(--radius-sm); padding:.45rem .85rem; font-size:.813rem; font-weight:600; cursor:pointer; white-space:nowrap; }
.btn-add-step:hover { opacity:.88; }
.dlg-footer { padding:.875rem 1.25rem; border-top:1px solid var(--border); display:flex; justify-content:flex-end; gap:.5rem; flex-shrink:0; }
.btn-ghost { background:transparent; border:1px solid var(--border); color:var(--text); border-radius:var(--radius); padding:.5rem 1rem; font-size:.875rem; font-weight:600; cursor:pointer; }
.btn-primary { background:var(--teal); color:#fff; border:none; border-radius:var(--radius); padding:.5rem 1rem; font-size:.875rem; font-weight:600; cursor:pointer; }
.btn-primary:disabled { opacity:.5; cursor:not-allowed; }
.error-msg { color:var(--danger); font-size:.813rem; min-height:1.2em; }
.empty-steps { font-size:.813rem; color:var(--text-muted); font-style:italic; text-align:center; padding:.5rem 0; }
</style>
<div class="dlg-header">
<span class="dlg-title">${isEdit ? 'Edit Profile' : 'New Profile'}</span>
<button class="dlg-close" id="dlg-close"><i data-lucide="x" style="width:16px;height:16px"></i></button>
</div>
<div class="dlg-body">
<div>
<label class="field-label" for="p-name">Name *</label>
<input class="field-input" id="p-name" type="text" value="${this.#esc(profile?.name || '')}" placeholder="e.g. Preventive Maintenance" required maxlength="200">
</div>
<div>
<label class="field-label" for="p-desc">Description</label>
<textarea class="field-textarea" id="p-desc" rows="2" placeholder="What type of work does this profile cover?">${this.#esc(profile?.description || '')}</textarea>
</div>
<div class="row-2">
<div>
<label class="field-label" for="p-cat">Category</label>
<input class="field-input" id="p-cat" type="text" value="${this.#esc(profile?.category || '')}" placeholder="e.g. Preventive, Emergency" maxlength="100">
</div>
<div>
<label class="field-label" for="p-pri">Default Priority</label>
<select class="field-select" id="p-pri">
${PRIORITIES.map(p => `<option value="${p}" ${(profile?.default_priority || 'normal') === p ? 'selected' : ''}>${p.charAt(0).toUpperCase() + p.slice(1)}</option>`).join('')}
</select>
</div>
</div>
<div>
<label class="field-label" for="p-dur">Default Duration (hours)</label>
<input class="field-input" id="p-dur" type="number" min="0" step="0.5" value="${profile?.default_duration_hours ?? ''}" placeholder="e.g. 4">
</div>
<div>
<label class="field-label" for="p-instr">Default Instructions</label>
<textarea class="field-textarea" id="p-instr" rows="4" placeholder="Instructions that will be copied to each work order using this profile.">${this.#esc(profile?.default_instructions || '')}</textarea>
</div>
${isEdit ? `
<div class="toggle-row">
<span class="toggle-label">Active</span>
<input type="checkbox" id="p-active" ${profile.active ? 'checked' : ''}>
</div>` : ''}
<div class="section-title">Default Steps</div>
<div class="step-list" id="step-list">
${(profile?.steps || []).length === 0
? '<div class="empty-steps" id="empty-steps">No steps yet — add steps below</div>'
: (profile?.steps || []).map((s, i) => this.#stepRowHTML(s, i)).join('')}
</div>
<div class="add-step-row">
<input class="add-step-input" id="new-step-title" placeholder="Step title…" maxlength="200">
<button class="btn-add-step" id="add-step-btn">+ Add Step</button>
</div>
<div class="error-msg" id="form-error"></div>
</div>
<div class="dlg-footer">
<button class="btn-ghost" id="dlg-cancel">Cancel</button>
<button class="btn-primary" id="dlg-save">${isEdit ? 'Save Changes' : 'Create Profile'}</button>
</div>`;
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
? '<div class="empty-steps">No steps yet — add steps below</div>'
: 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 `
<div class="step-row">
<span class="step-order">${i + 1}</span>
<input class="step-title-input" value="${this.#esc(s.title)}" placeholder="Step title…">
<label class="step-req">
<input type="checkbox" class="step-req-check" ${s.required !== false ? 'checked' : ''}> Required
</label>
<button class="step-del" title="Remove step"><i data-lucide="x" style="width:12px;height:12px"></i></button>
</div>`;
}
#esc(s) { return (s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); }
}
customElements.define('profile-form', ProfileForm);
+143
View File
@@ -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 = `
<style>
:host { display: block; }
.page-header { display:flex; align-items:center; justify-content:space-between; margin-bottom:1.25rem; flex-wrap:wrap; gap:.75rem; }
h1 { font-size:1.2rem; font-weight:700; color:var(--text); }
.toolbar { display:flex; gap:.5rem; align-items:center; flex-wrap:wrap; }
.search-input { border:1px solid var(--border); border-radius:var(--radius-sm); padding:.45rem .75rem; font-size:.875rem; background:var(--surface); color:var(--text); width:220px; }
.search-input:focus { outline:none; border-color:var(--teal); box-shadow:0 0 0 3px rgba(10,126,164,.12); }
.btn-primary { display:inline-flex; align-items:center; gap:.35rem; background:var(--teal); color:#fff; border:none; border-radius:var(--radius); padding:.5rem 1rem; font-size:.875rem; font-weight:600; cursor:pointer; }
.btn-primary:hover { opacity:.88; }
.list { display:flex; flex-direction:column; gap:.5rem; }
.card { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); padding:.875rem 1rem; display:flex; align-items:center; gap:1rem; box-shadow:var(--shadow-sm); transition:box-shadow .15s; }
.card:hover { box-shadow:var(--shadow-md); }
.profile-icon { width:40px; height:40px; border-radius:var(--radius); background:var(--surface-2); display:flex; align-items:center; justify-content:center; color:var(--teal); flex-shrink:0; }
.info { flex:1; min-width:0; }
.profile-name { font-weight:600; color:var(--text); font-size:.938rem; }
.meta { font-size:.813rem; color:var(--text-muted); margin-top:.15rem; display:flex; gap:.75rem; align-items:center; flex-wrap:wrap; }
.cat-pill { background:var(--surface-2); border-radius:var(--radius-sm); padding:.1rem .5rem; font-size:.75rem; font-weight:600; color:var(--text-muted); }
.step-count { font-size:.75rem; color:var(--text-muted); }
.pri-dot { width:8px; height:8px; border-radius:50%; display:inline-block; flex-shrink:0; }
.status-badge { display:inline-flex; padding:.15rem .55rem; border-radius:999px; font-size:.7rem; font-weight:700; text-transform:uppercase; }
.status-active { background:#DCFCE7; color:#15803D; }
.status-inactive { background:var(--surface-2); color:var(--text-muted); }
.actions { display:flex; gap:.35rem; flex-shrink:0; }
.icon-btn { background:none; border:1px solid var(--border); border-radius:var(--radius-sm); padding:.35rem .5rem; cursor:pointer; color:var(--text-muted); display:flex; align-items:center; transition:background .15s; }
.icon-btn:hover { background:var(--surface-2); color:var(--text); }
.icon-btn.danger:hover { background:#FEF2F2; color:var(--danger); border-color:var(--danger); }
.empty { text-align:center; padding:3rem; color:var(--text-muted); }
</style>
<div class="page-header">
<h1>Work Order Profiles</h1>
<div class="toolbar">
<input class="search-input" id="search" placeholder="Search profiles…" value="${this.#esc(this.#search)}">
<button class="btn-primary" id="new-btn">
<i data-lucide="plus" style="width:14px;height:14px"></i> New Profile
</button>
</div>
</div>
${this.#loading ? '<ui-spinner></ui-spinner>' :
this.#items.length === 0
? `<div class="empty">
<i data-lucide="layout-template" style="width:32px;height:32px;opacity:.3;display:block;margin:0 auto .5rem"></i>
No profiles found
</div>`
: `<div class="list">
${this.#items.map(p => `
<div class="card">
<div class="profile-icon"><i data-lucide="layout-template" style="width:18px;height:18px"></i></div>
<div class="info">
<div class="profile-name">${this.#esc(p.name)}</div>
<div class="meta">
${p.category ? `<span class="cat-pill">${this.#esc(p.category)}</span>` : ''}
<span class="pri-dot" style="background:${PRIORITY_COLORS[p.default_priority] || PRIORITY_COLORS.normal}"></span>
<span>${p.default_priority}</span>
<span class="step-count"><i data-lucide="list-checks" style="width:11px;height:11px;vertical-align:middle"></i> ${p.step_count} step${p.step_count !== 1 ? 's' : ''}</span>
${p.default_duration_hours ? `<span>⏱ ${p.default_duration_hours}h</span>` : ''}
</div>
</div>
<span class="status-badge ${p.active ? 'status-active' : 'status-inactive'}">${p.active ? 'Active' : 'Inactive'}</span>
<div class="actions">
<button class="icon-btn" data-edit="${p.id}" title="Edit">
<i data-lucide="pencil" style="width:14px;height:14px"></i>
</button>
${p.active ? `<button class="icon-btn danger" data-deactivate="${p.id}" title="Deactivate">
<i data-lucide="ban" style="width:14px;height:14px"></i>
</button>` : ''}
</div>
</div>`).join('')}
</div>`}
<profile-form id="profile-form"></profile-form>`;
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
}
customElements.define('profile-list', ProfileList);
+112
View File
@@ -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 = `<style>:host{display:block}</style><dialog></dialog>`;
}
}
open(vehicle, onSave) {
this.#onSave = onSave;
const isEdit = !!vehicle;
const d = this.shadowRoot.querySelector('dialog');
d.innerHTML = `
<style>
dialog { border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 0; box-shadow: var(--shadow-lg); width: 440px; max-width: 95vw; background: var(--surface); color: var(--text); }
dialog::backdrop { background: rgba(0,0,0,.45); }
.dlg-header { display: flex; align-items: center; justify-content: space-between; padding: 1.1rem 1.25rem; border-bottom: 1px solid var(--border); }
.dlg-title { font-size: 1rem; font-weight: 700; }
.dlg-close { background: none; border: none; cursor: pointer; color: var(--text-muted); padding: .25rem; display: flex; }
form { padding: 1.25rem; display: flex; flex-direction: column; gap: .875rem; }
.field-label { font-size: .813rem; font-weight: 600; color: var(--text); display: block; margin-bottom: .3rem; }
.field-input, .field-select { width: 100%; border: 1px solid var(--border); border-radius: var(--radius-sm); padding: .55rem .75rem; font-size: .875rem; background: var(--surface); color: var(--text); box-sizing: border-box; transition: border-color .15s; }
.field-input:focus, .field-select:focus { outline: none; border-color: var(--teal); box-shadow: 0 0 0 3px rgba(10,126,164,.12); }
.toggle-row { display: flex; align-items: center; justify-content: space-between; padding: .5rem 0; }
.toggle-label { font-size: .875rem; font-weight: 500; }
input[type=checkbox] { width: 18px; height: 18px; accent-color: var(--teal); cursor: pointer; }
.dlg-footer { padding: .875rem 1.25rem; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; gap: .5rem; }
.btn-ghost { background: transparent; border: 1px solid var(--border); color: var(--text); border-radius: var(--radius); padding: .5rem 1rem; font-size: .875rem; font-weight: 600; cursor: pointer; }
.btn-primary { background: var(--teal); color: #fff; border: none; border-radius: var(--radius); padding: .5rem 1rem; font-size: .875rem; font-weight: 600; cursor: pointer; }
.btn-primary:disabled { opacity: .5; cursor: not-allowed; }
.error-msg { color: var(--danger); font-size: .813rem; min-height: 1.2em; }
</style>
<div class="dlg-header">
<span class="dlg-title">${isEdit ? 'Edit Vehicle' : 'Add Vehicle'}</span>
<button class="dlg-close" id="dlg-close"><i data-lucide="x" style="width:16px;height:16px"></i></button>
</div>
<form id="vehicle-form" novalidate>
<div>
<label class="field-label" for="v-unit">Unit Number *</label>
<input class="field-input" id="v-unit" type="text" value="${this.#esc(vehicle?.unit_number || '')}" placeholder="e.g. VEH-001" required maxlength="50">
</div>
<div>
<label class="field-label" for="v-desc">Description</label>
<input class="field-input" id="v-desc" type="text" value="${this.#esc(vehicle?.description || '')}" placeholder="e.g. 2022 Ford F-350" maxlength="200">
</div>
<div>
<label class="field-label" for="v-type">Vehicle Type</label>
<select class="field-select" id="v-type">
<option value="">— Select type —</option>
${VEHICLE_TYPES.map(t => `<option value="${t}" ${vehicle?.vehicle_type === t ? 'selected' : ''}>${t}</option>`).join('')}
</select>
</div>
${isEdit ? `
<div class="toggle-row">
<span class="toggle-label">Active</span>
<input type="checkbox" id="v-active" ${vehicle.active ? 'checked' : ''}>
</div>` : ''}
<div class="error-msg" id="form-error"></div>
</form>
<div class="dlg-footer">
<button class="btn-ghost" id="dlg-cancel">Cancel</button>
<button class="btn-primary" id="dlg-save">${isEdit ? 'Save Changes' : 'Add Vehicle'}</button>
</div>`;
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
}
customElements.define('vehicle-form', VehicleForm);
+128
View File
@@ -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 = `
<style>
:host { display: block; }
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.25rem; flex-wrap: wrap; gap: .75rem; }
h1 { font-size: 1.2rem; font-weight: 700; color: var(--text); }
.toolbar { display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; }
.search-input { border: 1px solid var(--border); border-radius: var(--radius-sm); padding: .45rem .75rem; font-size: .875rem; background: var(--surface); color: var(--text); width: 200px; }
.search-input:focus { outline: none; border-color: var(--teal); box-shadow: 0 0 0 3px rgba(10,126,164,.12); }
.btn-primary { display: inline-flex; align-items: center; gap: .35rem; background: var(--teal); color: #fff; border: none; border-radius: var(--radius); padding: .5rem 1rem; font-size: .875rem; font-weight: 600; cursor: pointer; }
.btn-primary:hover { opacity: .88; }
.list { display: flex; flex-direction: column; gap: .5rem; }
.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: .875rem 1rem; display: flex; align-items: center; gap: 1rem; box-shadow: var(--shadow-sm); }
.card:hover { box-shadow: var(--shadow-md); }
.vehicle-icon { width: 40px; height: 40px; border-radius: var(--radius); background: var(--surface-2); display: flex; align-items: center; justify-content: center; color: var(--teal); flex-shrink: 0; }
.info { flex: 1; min-width: 0; }
.unit-num { font-weight: 700; color: var(--text); font-family: monospace; font-size: .938rem; }
.meta { font-size: .813rem; color: var(--text-muted); margin-top: .15rem; display: flex; gap: .75rem; flex-wrap: wrap; }
.status-badge { display: inline-flex; align-items: center; gap: .3rem; padding: .15rem .55rem; border-radius: 999px; font-size: .7rem; font-weight: 700; text-transform: uppercase; }
.status-active { background: #DCFCE7; color: #15803D; }
.status-inactive { background: var(--surface-2); color: var(--text-muted); }
.actions { display: flex; gap: .35rem; flex-shrink: 0; }
.icon-btn { background: none; border: 1px solid var(--border); border-radius: var(--radius-sm); padding: .35rem .5rem; cursor: pointer; color: var(--text-muted); display: flex; align-items: center; transition: background .15s; }
.icon-btn:hover { background: var(--surface-2); color: var(--text); }
.icon-btn.danger:hover { background: #FEF2F2; color: var(--danger); border-color: var(--danger); }
.empty { text-align: center; padding: 3rem; color: var(--text-muted); }
</style>
<div class="page-header">
<h1>Vehicles</h1>
<div class="toolbar">
<input class="search-input" id="search" placeholder="Search vehicles…" value="${this.#esc(this.#search)}">
<button class="btn-primary" id="new-btn">
<i data-lucide="plus" style="width:14px;height:14px"></i> Add Vehicle
</button>
</div>
</div>
${this.#loading ? '<ui-spinner></ui-spinner>' :
this.#vehicles.length === 0
? `<div class="empty">
<i data-lucide="truck" style="width:32px;height:32px;opacity:.3;display:block;margin:0 auto .5rem"></i>
No vehicles found
</div>`
: `<div class="list">
${this.#vehicles.map(v => `
<div class="card">
<div class="vehicle-icon"><i data-lucide="truck" style="width:18px;height:18px"></i></div>
<div class="info">
<div class="unit-num">${this.#esc(v.unit_number)}</div>
<div class="meta">
${v.description ? `<span>${this.#esc(v.description)}</span>` : ''}
${v.vehicle_type ? `<span>${this.#esc(v.vehicle_type)}</span>` : ''}
</div>
</div>
<span class="status-badge ${v.active ? 'status-active' : 'status-inactive'}">${v.active ? 'Active' : 'Inactive'}</span>
<div class="actions">
<button class="icon-btn" data-edit="${v.id}" title="Edit">
<i data-lucide="pencil" style="width:14px;height:14px"></i>
</button>
${v.active ? `<button class="icon-btn danger" data-deactivate="${v.id}" title="Deactivate">
<i data-lucide="ban" style="width:14px;height:14px"></i>
</button>` : ''}
</div>
</div>`).join('')}
</div>`}
<vehicle-form id="vehicle-form"></vehicle-form>`;
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
}
customElements.define('vehicle-list', VehicleList);
@@ -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 = `<style>:host{display:block;padding:2rem;text-align:center}</style><ui-spinner></ui-spinner>`;
return;
}
s.innerHTML = `
<style>
:host { display: block; }
.header-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.25rem; }
.section-title { font-size: .813rem; font-weight: 700; color: var(--text-muted); text-transform: uppercase; letter-spacing: .06em; }
#save-indicator { font-size: .813rem; font-weight: 500; transition: color .2s; }
.code-grid { display: flex; flex-direction: column; gap: 1rem; }
.code-row { display: grid; grid-template-columns: 1fr 1.5fr; gap: .75rem; align-items: start; }
.code-label { font-size: .813rem; font-weight: 600; color: var(--text); padding-top: .55rem; }
.code-inputs { display: flex; flex-direction: column; gap: .4rem; }
.code-value, .code-desc { width: 100%; border: 1px solid var(--border); border-radius: var(--radius-sm); padding: .5rem .75rem; font-size: .875rem; background: var(--surface); color: var(--text); transition: border-color .15s; box-sizing: border-box; }
.code-value:focus, .code-desc:focus { outline: none; border-color: var(--teal); box-shadow: 0 0 0 3px rgba(10,126,164,.12); }
.code-desc { font-size: .813rem; color: var(--text-muted); }
.code-desc::placeholder { font-style: italic; }
.divider { height: 1px; background: var(--border-lt); }
@media (max-width: 768px) { .code-row { grid-template-columns: 1fr; } }
</style>
<div class="header-row">
<span class="section-title">Accounting Codes</span>
<span id="save-indicator"></span>
</div>
<div class="code-grid">
${CODE_TYPES.map((ct, i) => `
${i > 0 ? '<div class="divider"></div>' : ''}
<div class="code-row" data-key="${ct.key}">
<div class="code-label">${ct.label}</div>
<div class="code-inputs">
<input class="code-value" placeholder="${ct.placeholder}" value="${this.#esc(this.#codes[ct.key]?.value || '')}">
<input class="code-desc" placeholder="Description…" value="${this.#esc(this.#codes[ct.key]?.description || '')}">
</div>
</div>`).join('')}
</div>`;
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
}
customElements.define('wo-accounting', WoAccounting);
+210
View File
@@ -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 = `<style>:host{display:block;padding:2rem;text-align:center}</style><ui-spinner></ui-spinner>`;
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 = `
<style>
:host { display: block; }
.progress-wrap { margin-bottom: 1.25rem; }
.progress-label { display: flex; justify-content: space-between; margin-bottom: .4rem; font-size: .813rem; color: var(--text-muted); font-weight: 500; }
.progress-bar { height: 8px; background: var(--surface-2); border-radius: 4px; overflow: hidden; }
.progress-fill { height: 100%; background: var(--teal); border-radius: 4px; transition: width .4s ease; width: ${pct}%; }
.all-done { display: flex; align-items: center; gap: .5rem; background: #DCFCE7; border: 1px solid #86EFAC; border-radius: var(--radius); padding: .75rem 1rem; color: #15803D; font-weight: 600; font-size: .875rem; margin-bottom: 1rem; }
.steps-list { display: flex; flex-direction: column; gap: .5rem; margin-bottom: 1.5rem; }
.step-row { display: flex; align-items: flex-start; gap: .75rem; padding: .85rem 1rem; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); transition: background .15s; }
.step-row:hover { background: var(--surface-2); }
.step-row.done { background: var(--surface-2); border-color: var(--border-lt); }
.step-row.done .step-title { text-decoration: line-through; color: var(--text-muted); }
.step-check-wrap { display: flex; align-items: center; justify-content: center; min-width: 24px; height: 24px; margin-top: .1rem; }
.step-check { width: 20px; height: 20px; accent-color: var(--teal); cursor: pointer; }
.step-check:disabled { cursor: not-allowed; opacity: .6; }
.step-body { flex: 1; min-width: 0; }
.step-header { display: flex; align-items: baseline; gap: .4rem; }
.step-num { font-size: .75rem; color: var(--text-muted); font-weight: 700; min-width: 1.5rem; }
.step-title { font-weight: 500; font-size: .875rem; color: var(--text); line-height: 1.4; }
.step-desc { font-size: .813rem; color: var(--text-muted); margin-top: .2rem; line-height: 1.5; }
.step-meta { font-size: .75rem; color: var(--text-muted); margin-top: .3rem; display: flex; align-items: center; gap: .3rem; }
.step-note { font-style: italic; margin-top: .2rem; font-size: .75rem; color: var(--text-muted); }
.step-actions { display: flex; gap: .25rem; align-items: center; flex-shrink: 0; }
.icon-btn { background: none; border: none; cursor: pointer; color: var(--text-muted); padding: .3rem; border-radius: var(--radius-sm); display: flex; align-items: center; transition: color .15s, background .15s; opacity: 0; }
.step-row:hover .icon-btn { opacity: 1; }
.icon-btn:hover { color: var(--danger); background: #FEF2F2; }
.icon-btn.undo-btn:hover { color: var(--teal); background: var(--surface-2); }
.empty-steps { text-align: center; padding: 2rem; color: var(--text-muted); font-size: .875rem; }
.add-section { border-top: 1px solid var(--border); padding-top: 1.25rem; }
.add-section-label { font-size: .75rem; font-weight: 700; color: var(--text-muted); text-transform: uppercase; letter-spacing: .06em; margin-bottom: .75rem; }
.add-row { display: flex; gap: .5rem; }
.add-input { flex: 1; border: 1px solid var(--border); border-radius: var(--radius-sm); padding: .55rem .75rem; font-size: .875rem; background: var(--surface); color: var(--text); transition: border-color .15s; }
.add-input:focus { outline: none; border-color: var(--teal); box-shadow: 0 0 0 3px rgba(10,126,164,.12); }
.add-btn { background: var(--teal); color: #fff; border: none; border-radius: var(--radius); padding: .55rem 1.1rem; font-weight: 600; font-size: .875rem; cursor: pointer; white-space: nowrap; transition: opacity .15s; }
.add-btn:hover { opacity: .88; }
@media (max-width: 768px) {
.icon-btn { opacity: 1; }
}
</style>
${allDone ? `
<div class="all-done">
<i data-lucide="check-circle-2" style="width:18px;height:18px;flex-shrink:0"></i>
All ${total} steps complete — ready for review!
</div>` : ''}
<div class="progress-wrap">
<div class="progress-label">
<span>${done} of ${total} steps complete</span>
<span>${pct}%</span>
</div>
<div class="progress-bar"><div class="progress-fill"></div></div>
</div>
<div class="steps-list">
${total === 0
? '<div class="empty-steps">No steps yet — add your first step below</div>'
: this.#steps.map(st => `
<div class="step-row${st.completed ? ' done' : ''}">
<div class="step-check-wrap">
<input type="checkbox" class="step-check" data-id="${st.id}"
${st.completed ? 'checked' : ''}>
</div>
<div class="step-body">
<div class="step-header">
<span class="step-num">${st.step_order}.</span>
<span class="step-title">${this.#esc(st.title)}</span>
</div>
${st.description ? `<div class="step-desc">${this.#esc(st.description)}</div>` : ''}
${st.completed && st.completed_by ? `
<div class="step-meta">
<i data-lucide="user-check" style="width:12px;height:12px"></i>
${this.#esc(st.completed_by)}
${st.completed_at ? '· ' + new Date(st.completed_at).toLocaleString() : ''}
</div>` : ''}
${st.notes ? `<div class="step-note">"${this.#esc(st.notes)}"</div>` : ''}
</div>
<div class="step-actions">
${st.completed
? `<button class="icon-btn undo-btn" data-undo="${st.id}" title="Mark incomplete">
<i data-lucide="rotate-ccw" style="width:14px;height:14px"></i>
</button>`
: `<button class="icon-btn" data-del="${st.id}" title="Remove step">
<i data-lucide="trash-2" style="width:14px;height:14px"></i>
</button>`}
</div>
</div>`).join('')}
</div>
<div class="add-section">
<div class="add-section-label">Add Step</div>
<div class="add-row">
<input class="add-input" id="new-step-title" placeholder="Step title…" maxlength="200">
<button class="add-btn" id="add-step-btn">
<i data-lucide="plus" style="width:14px;height:14px;vertical-align:middle"></i> Add
</button>
</div>
</div>`;
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
}
customElements.define('wo-checklist', WoChecklist);
+193 -9
View File
@@ -1,5 +1,11 @@
import { api } from '../../lib/api.mjs'; import { api } from '../../lib/api.mjs';
import { formatDateTime, formatDate } from '../../lib/format.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 TABS = ['Overview', 'Checklist', 'Resources', 'Photos', 'Accounting', 'Activity'];
const STATUS_TRANSITIONS = { 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 { font-size:.875rem; color:var(--text); line-height:1.5; }
.field-value.empty { color:var(--text-muted); font-style:italic; } .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; } .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-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-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 { 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-opt:hover { background:var(--surface-2); }
.status-dot { width:8px; height:8px; border-radius:50%; flex-shrink:0; } .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; } } @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() { #html() {
@@ -113,6 +140,9 @@ class WoDetail extends HTMLElement {
<button class="btn btn-ghost" id="edit-btn"> <button class="btn btn-ghost" id="edit-btn">
<i data-lucide="pencil" style="width:14px;height:14px"></i> Edit <i data-lucide="pencil" style="width:14px;height:14px"></i> Edit
</button> </button>
<button class="btn btn-ghost" id="apply-profile-btn">
<i data-lucide="layout-template" style="width:14px;height:14px"></i> Apply Profile
</button>
${transitions.length ? ` ${transitions.length ? `
<div class="status-menu" id="status-menu"> <div class="status-menu" id="status-menu">
<button class="btn btn-primary" id="status-btn"> <button class="btn btn-primary" id="status-btn">
@@ -162,7 +192,36 @@ class WoDetail extends HTMLElement {
<div class="tab-content" id="tab-content"> <div class="tab-content" id="tab-content">
${this.#tabContent(wo)} ${this.#tabContent(wo)}
</div>`; </div>
<dialog class="ap-dialog" id="ap-dialog">
<div class="ap-header">
<span class="ap-title">Apply Work Order Profile</span>
<button class="ap-close" id="ap-close"><i data-lucide="x" style="width:16px;height:16px"></i></button>
</div>
<div class="ap-body" id="ap-body">
<input class="ap-search" id="ap-search" placeholder="Search profiles…" autocomplete="off">
<div class="ap-list" id="ap-profile-list"><div class="ap-empty">Loading profiles…</div></div>
<div class="ap-mode" id="ap-mode" style="display:none">
<div class="ap-mode-label">Existing steps detected — how should we proceed?</div>
<div class="ap-mode-opts">
<div class="ap-mode-opt selected" data-mode="append">
<div class="ap-mode-opt-title">Append</div>
<div class="ap-mode-opt-desc">Add profile steps after existing steps</div>
</div>
<div class="ap-mode-opt" data-mode="replace">
<div class="ap-mode-opt-title">Replace</div>
<div class="ap-mode-opt-desc">Delete existing steps, load from profile</div>
</div>
</div>
</div>
</div>
<div class="ap-footer">
<span class="ap-err" id="ap-err"></span>
<button class="btn btn-ghost" id="ap-cancel">Cancel</button>
<button class="btn btn-primary" id="ap-confirm" disabled>Apply Profile</button>
</div>
</dialog>`;
} }
#tabContent(wo) { #tabContent(wo) {
@@ -191,17 +250,35 @@ class WoDetail extends HTMLElement {
? `<div class="text-block" style="font-size:.813rem">${this.#esc(wo.access_notes)}</div>` ? `<div class="text-block" style="font-size:.813rem">${this.#esc(wo.access_notes)}</div>`
: '<div class="field-value empty">No access notes</div>'} : '<div class="field-value empty">No access notes</div>'}
</div> </div>
${wo.lat && wo.lng ? `
<div class="detail-field" style="grid-column:1/-1">
<div class="field-label">Location</div>
<wo-map
lat="${wo.lat}"
lng="${wo.lng}"
site-name="${this.#esc(wo.site_name)}"
access-notes="${this.#esc(wo.access_notes)}"
wo-number="${this.#esc(wo.wo_number)}"
wo-id="${wo.id}"
></wo-map>
</div>` : ''}
</div>`; </div>`;
case 'Checklist': case 'Checklist':
return `<wo-checklist wo-id="${wo.id}"></wo-checklist>`;
case 'Resources': case 'Resources':
return `<wo-resource-panel wo-id="${wo.id}"></wo-resource-panel>`;
case 'Photos': case 'Photos':
return `<wo-photo-panel wo-id="${wo.id}"></wo-photo-panel>`;
case 'Accounting': case 'Accounting':
return `<wo-accounting wo-id="${wo.id}"></wo-accounting>`;
case 'Activity': case 'Activity':
return `<div class="placeholder"> return `<wo-timeline wo-id="${wo.id}"></wo-timeline>`;
<i data-lucide="construction" style="width:40px;height:40px;margin:0 auto .75rem;color:var(--text-muted)"></i>
<p style="font-weight:600;margin-bottom:.35rem">${this.#tab} — Coming in Phase 2</p>
<p style="font-size:.875rem">This section will be available in the next phase of development.</p>
</div>`;
default: return ''; default: return '';
} }
} }
@@ -243,6 +320,113 @@ class WoDetail extends HTMLElement {
window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: err.message, type: 'error' } })); 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 = '<div class="ap-empty">No profiles found</div>';
return;
}
listEl.innerHTML = items.map(p => `
<div class="ap-item${selectedId === p.id ? ' selected' : ''}" data-id="${p.id}">
<div style="flex:1;min-width:0">
<div class="ap-item-name">${this.#esc(p.name)}</div>
<div class="ap-item-meta">${p.category ? `${p.category} · ` : ''}${p.step_count} step${p.step_count !== 1 ? 's' : ''} · ${p.default_priority} priority</div>
</div>
</div>`).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 = '<div class="ap-empty">Loading…</div>';
dialog.showModal();
try {
allProfiles = await api.get('/profiles') || [];
renderList(allProfiles);
} catch (err) {
listEl.innerHTML = `<div class="ap-empty">${err.message}</div>`;
}
});
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); } #esc(s) { return (s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
+121 -2
View File
@@ -60,6 +60,22 @@ class WoForm extends HTMLElement {
.btn:disabled { opacity:.5; cursor:not-allowed; } .btn:disabled { opacity:.5; cursor:not-allowed; }
.err { color:var(--danger); font-size:.813rem; text-align:right; } .err { color:var(--danger); font-size:.813rem; text-align:right; }
@media (max-width:768px) { .layout { grid-template-columns:1fr; } .row2 { grid-template-columns:1fr; } } @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) { #html(wo, isNew) {
@@ -74,7 +90,12 @@ class WoForm extends HTMLElement {
<!-- Left: main fields --> <!-- Left: main fields -->
<div style="display:flex;flex-direction:column;gap:1rem;"> <div style="display:flex;flex-direction:column;gap:1rem;">
<div class="card"> <div class="card">
<div class="card-title">Details</div> <div style="display:flex;align-items:center;justify-content:space-between;padding-bottom:.75rem;border-bottom:1px solid var(--border);">
<span class="card-title" style="border:none;padding:0;margin:0">Details</span>
${(isNew || wo.status === 'draft') ? `<button type="button" class="btn btn-ghost" id="load-profile-btn" style="padding:.3rem .7rem;font-size:.75rem;">
<i data-lucide="layout-template" style="width:13px;height:13px"></i> Load Profile
</button>` : ''}
</div>
<label>Title *<input name="title" required value="${v('title')}" placeholder="Brief description of the work"></label> <label>Title *<input name="title" required value="${v('title')}" placeholder="Brief description of the work"></label>
<label>Description<textarea name="description" rows="3" placeholder="What needs to be done…">${v('description')}</textarea></label> <label>Description<textarea name="description" rows="3" placeholder="What needs to be done…">${v('description')}</textarea></label>
<label>Instructions<textarea name="instructions" rows="5" placeholder="Step-by-step instructions for the field crew…">${v('instructions')}</textarea></label> <label>Instructions<textarea name="instructions" rows="5" placeholder="Step-by-step instructions for the field crew…">${v('instructions')}</textarea></label>
@@ -135,7 +156,22 @@ class WoForm extends HTMLElement {
${isNew ? 'Create Work Order' : 'Save Changes'} ${isNew ? 'Create Work Order' : 'Save Changes'}
</button> </button>
</div> </div>
</form>`; </form>
<dialog class="lp-dialog" id="lp-dialog">
<div class="lp-header">
<span class="lp-title">Load Work Order Profile</span>
<button class="lp-close" id="lp-close"><i data-lucide="x" style="width:16px;height:16px"></i></button>
</div>
<div class="lp-body">
<input class="lp-search" id="lp-search" placeholder="Search profiles…" autocomplete="off">
<div class="lp-list" id="lp-list"><div class="lp-empty">Loading…</div></div>
</div>
<div class="lp-footer">
<button class="btn btn-ghost" id="lp-cancel">Cancel</button>
<button class="btn btn-primary" id="lp-confirm" disabled>Load Profile</button>
</div>
</dialog>`;
} }
#bind() { #bind() {
@@ -144,6 +180,9 @@ class WoForm extends HTMLElement {
s.querySelector('#back')?.addEventListener('click', () => this.#goBack()); s.querySelector('#back')?.addEventListener('click', () => this.#goBack());
s.querySelector('#cancel-btn')?.addEventListener('click', () => this.#goBack()); s.querySelector('#cancel-btn')?.addEventListener('click', () => this.#goBack());
// Load Profile dialog
this.#bindLoadProfile(s);
// Update priority dot colour on change // Update priority dot colour on change
s.querySelector('[name="priority"]')?.addEventListener('change', e => { s.querySelector('[name="priority"]')?.addEventListener('change', e => {
const dot = s.querySelector('#pri-dot'); 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 = '<div class="lp-empty">No profiles found</div>'; return; }
listEl.innerHTML = items.map(p => `
<div class="lp-item${selectedProfile?.id === p.id ? ' selected' : ''}" data-id="${p.id}">
<div class="lp-item-name">${this.#esc(p.name)}</div>
<div class="lp-item-meta">${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` : ''}</div>
</div>`).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 = '<div class="lp-empty">Loading…</div>';
dialog.showModal();
try {
allProfiles = await api.get('/profiles') || [];
renderList(allProfiles);
} catch (err) {
listEl.innerHTML = `<div class="lp-empty">${err.message}</div>`;
}
});
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() { #goBack() {
if (this.#dirty && !confirm('You have unsaved changes. Leave anyway?')) return; if (this.#dirty && !confirm('You have unsaved changes. Leave anyway?')) return;
if (this.#woId) { if (this.#woId) {
+130
View File
@@ -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 = `
<style>
.wo-map-root { }
.wo-map-container { height: 280px; border-radius: var(--radius); border: 1px solid var(--border); overflow: hidden; }
.wo-map-actions { display: flex; gap: .5rem; margin-top: .75rem; }
.map-btn { display: inline-flex; align-items: center; gap: .4rem; padding: .45rem .9rem; border-radius: var(--radius); font-size: .813rem; font-weight: 600; cursor: pointer; transition: opacity .15s; text-decoration: none; border: 1px solid var(--border); color: var(--text); background: var(--surface); }
.map-btn:hover { background: var(--surface-2); text-decoration: none; }
.map-btn.primary { background: var(--teal); color: #fff; border-color: transparent; }
.map-btn.primary:hover { opacity: .88; }
.access-notes { margin-top: .875rem; background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius); padding: .75rem 1rem; font-size: .875rem; color: var(--text); display: flex; gap: .6rem; align-items: flex-start; }
.access-notes i { color: var(--warning); flex-shrink: 0; margin-top: .1rem; }
.access-text { line-height: 1.5; }
.no-location { text-align: center; padding: 2.5rem; background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text-muted); font-size: .875rem; }
</style>
<div class="wo-map-root">
${lat && lng ? `
<div class="wo-map-container" id="leaflet-map-${this.getAttribute('wo-id') || 'map'}"></div>
<div class="wo-map-actions">
<a class="map-btn primary" id="directions-btn" href="#" target="_blank">
<i data-lucide="navigation" style="width:14px;height:14px"></i> Get Directions
</a>
</div>` : `
<div class="no-location">
<i data-lucide="map-pin-off" style="width:28px;height:28px;margin:0 auto .5rem;opacity:.4;display:block"></i>
No location set for this work order
</div>`}
${accessNotes ? `
<div class="access-notes">
<i data-lucide="key-round" style="width:16px;height:16px"></i>
<div class="access-text">${this.#esc(accessNotes)}</div>
</div>` : ''}
</div>`;
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: `<div style="
background: var(--teal, #0A7EA4);
color: #fff;
border-radius: 50% 50% 50% 0;
transform: rotate(-45deg);
width: 32px; height: 32px;
display: flex; align-items: center; justify-content: center;
box-shadow: 0 2px 6px rgba(0,0,0,.3);
font-size: 10px; font-weight: 700;">
<span style="transform:rotate(45deg)">${this.#esc(woNumber || '📍')}</span>
</div>`,
iconSize: [32, 32],
iconAnchor: [16, 32],
popupAnchor: [0, -32],
});
L.marker([lat, lng], { icon })
.addTo(this.#map)
.bindPopup(`<strong>${this.#esc(siteName)}</strong><br>${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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
}
customElements.define('wo-map', WoMap);
@@ -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 = `<style>:host{display:block;padding:2rem;text-align:center}</style><ui-spinner></ui-spinner>`;
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 = `
<style>
:host { display: block; }
.phase-tabs { display: flex; gap: 0; border-bottom: 2px solid var(--border); margin-bottom: 1rem; overflow-x: auto; }
.phase-tab { padding: .55rem 1rem; font-size: .875rem; font-weight: 500; color: var(--text-muted); border: none; background: none; cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -2px; white-space: nowrap; transition: color .15s; display: flex; align-items: center; gap: .35rem; }
.phase-tab.active { color: var(--teal); border-bottom-color: var(--teal); font-weight: 600; }
.phase-tab:hover:not(.active) { color: var(--text); }
.tab-count { background: var(--surface-2); border-radius: 999px; font-size: .7rem; font-weight: 700; padding: .1rem .4rem; }
.phase-tab.active .tab-count { background: var(--teal); color: #fff; }
.toolbar { display: flex; justify-content: flex-end; margin-bottom: 1rem; gap: .5rem; }
.upload-btn { display: inline-flex; align-items: center; gap: .4rem; background: var(--teal); color: #fff; border: none; border-radius: var(--radius); padding: .5rem 1rem; font-size: .875rem; font-weight: 600; cursor: pointer; transition: opacity .15s; }
.upload-btn:hover { opacity: .88; }
.photo-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: .75rem; }
.photo-tile { position: relative; aspect-ratio: 1; border-radius: var(--radius); overflow: hidden; background: var(--surface-2); cursor: pointer; border: 1px solid var(--border); }
.photo-tile img { width: 100%; height: 100%; object-fit: cover; display: block; transition: transform .2s; }
.photo-tile:hover img { transform: scale(1.04); }
.photo-overlay { position: absolute; inset: 0; background: rgba(0,0,0,0); transition: background .2s; display: flex; flex-direction: column; justify-content: flex-end; padding: .5rem; }
.photo-tile:hover .photo-overlay { background: rgba(0,0,0,.45); }
.photo-caption { color: #fff; font-size: .75rem; font-weight: 500; opacity: 0; transition: opacity .2s; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.photo-tile:hover .photo-caption { opacity: 1; }
.phase-pill { position: absolute; top: .4rem; left: .4rem; background: rgba(0,0,0,.55); color: #fff; font-size: .65rem; font-weight: 700; text-transform: uppercase; padding: .15rem .4rem; border-radius: 999px; }
.del-btn { position: absolute; top: .4rem; right: .4rem; background: rgba(0,0,0,.55); color: #fff; border: none; border-radius: var(--radius-sm); cursor: pointer; padding: .25rem; display: flex; opacity: 0; transition: opacity .15s; }
.photo-tile:hover .del-btn { opacity: 1; }
.empty-state { text-align: center; padding: 3rem 1rem; color: var(--text-muted); }
.empty-state p { font-size: .875rem; margin-top: .5rem; }
/* Lightbox */
.lightbox { position: fixed; inset: 0; background: rgba(0,0,0,.9); z-index: 1000; display: flex; align-items: center; justify-content: center; }
.lightbox-img { max-width: 90vw; max-height: 85vh; object-fit: contain; border-radius: var(--radius); }
.lightbox-close { position: fixed; top: 1rem; right: 1rem; background: rgba(255,255,255,.15); border: none; color: #fff; border-radius: 50%; width: 40px; height: 40px; cursor: pointer; display: flex; align-items: center; justify-content: center; }
.lightbox-prev, .lightbox-next { position: fixed; top: 50%; transform: translateY(-50%); background: rgba(255,255,255,.15); border: none; color: #fff; border-radius: 50%; width: 44px; height: 44px; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: background .15s; }
.lightbox-prev:hover, .lightbox-next:hover { background: rgba(255,255,255,.3); }
.lightbox-prev { left: 1rem; }
.lightbox-next { right: 1rem; }
.lightbox-caption { position: fixed; bottom: 1.5rem; left: 50%; transform: translateX(-50%); color: #fff; font-size: .875rem; background: rgba(0,0,0,.6); padding: .4rem 1rem; border-radius: 999px; max-width: 80vw; text-align: center; }
/* Upload dialog */
dialog { border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 0; box-shadow: var(--shadow-lg); width: 420px; max-width: 95vw; background: var(--surface); color: var(--text); }
dialog::backdrop { background: rgba(0,0,0,.45); }
.dlg-header { display: flex; align-items: center; justify-content: space-between; padding: 1rem 1.25rem; border-bottom: 1px solid var(--border); }
.dlg-title { font-size: 1rem; font-weight: 700; }
.dlg-close { background: none; border: none; cursor: pointer; color: var(--text-muted); display: flex; }
.dlg-body { padding: 1.25rem; display: flex; flex-direction: column; gap: .875rem; }
.field-label { font-size: .813rem; font-weight: 600; color: var(--text); display: block; margin-bottom: .3rem; }
.drop-zone { border: 2px dashed var(--border); border-radius: var(--radius); padding: 1.5rem; text-align: center; cursor: pointer; transition: border-color .15s, background .15s; color: var(--text-muted); font-size: .875rem; }
.drop-zone:hover, .drop-zone.drag-over { border-color: var(--teal); background: #E0F2FE; color: var(--teal); }
.drop-zone input { display: none; }
.preview-list { display: flex; flex-direction: column; gap: .35rem; max-height: 120px; overflow-y: auto; }
.preview-item { font-size: .813rem; color: var(--text-muted); display: flex; align-items: center; gap: .4rem; }
.select-field { width: 100%; border: 1px solid var(--border); border-radius: var(--radius-sm); padding: .5rem .75rem; font-size: .875rem; background: var(--surface); color: var(--text); }
.caption-input { 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; }
.dlg-footer { padding: .75rem 1.25rem; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; gap: .5rem; }
.btn-ghost { background: transparent; border: 1px solid var(--border); color: var(--text); border-radius: var(--radius); padding: .45rem .9rem; font-size: .813rem; font-weight: 600; cursor: pointer; }
.btn-primary { background: var(--teal); color: #fff; border: none; border-radius: var(--radius); padding: .45rem .9rem; font-size: .813rem; font-weight: 600; cursor: pointer; }
.btn-primary:disabled { opacity: .5; cursor: not-allowed; }
@media (max-width: 768px) { .photo-grid { grid-template-columns: repeat(2, 1fr); } .del-btn { opacity: 1; } }
</style>
<div class="phase-tabs">
${PHASES.map(ph => `
<button class="phase-tab${this.#phase === ph ? ' active' : ''}" data-phase="${ph}">
${ph.charAt(0).toUpperCase() + ph.slice(1)}
<span class="tab-count">${phaseCounts[ph] ?? 0}</span>
</button>`).join('')}
</div>
<div class="toolbar">
<button class="upload-btn" id="upload-btn">
<i data-lucide="camera" style="width:15px;height:15px"></i> Add Photos
</button>
</div>
${filtered.length === 0
? `<div class="empty-state">
<i data-lucide="image" style="width:36px;height:36px;margin:0 auto;opacity:.4;display:block"></i>
<p>${this.#phase === 'all' ? 'No photos yet' : `No ${this.#phase} photos yet`}</p>
</div>`
: `<div class="photo-grid">
${filtered.map((p, i) => `
<div class="photo-tile" data-idx="${i}">
<img src="${this.#esc(p.url)}" alt="${this.#esc(p.caption)}" loading="lazy">
<div class="photo-overlay">
${p.caption ? `<div class="photo-caption">${this.#esc(p.caption)}</div>` : ''}
</div>
${p.phase ? `<div class="phase-pill">${p.phase}</div>` : ''}
<button class="del-btn" data-id="${p.id}" title="Delete photo">
<i data-lucide="trash-2" style="width:13px;height:13px"></i>
</button>
</div>`).join('')}
</div>`}
<dialog id="upload-dialog">
<div class="dlg-header">
<span class="dlg-title">Add Photos</span>
<button class="dlg-close" id="dlg-close"><i data-lucide="x" style="width:16px;height:16px"></i></button>
</div>
<div class="dlg-body">
<div>
<label class="field-label">Photos</label>
<div class="drop-zone" id="drop-zone">
<i data-lucide="upload-cloud" style="width:24px;height:24px;margin:0 auto .5rem;display:block"></i>
Click to select photos or drag & drop here
<input type="file" id="file-input" accept="image/*" multiple capture="environment">
</div>
<div class="preview-list" id="preview-list"></div>
</div>
<div>
<label class="field-label" for="phase-select">Phase</label>
<select class="select-field" id="phase-select">
<option value="">— Select phase —</option>
<option value="before">Before</option>
<option value="during">During</option>
<option value="after">After</option>
</select>
</div>
<div>
<label class="field-label" for="caption-input">Caption (optional)</label>
<input class="caption-input" id="caption-input" type="text" placeholder="Describe what's in the photo…" maxlength="500">
</div>
</div>
<div class="dlg-footer">
<button class="btn-ghost" id="dlg-cancel">Cancel</button>
<button class="btn-primary" id="dlg-upload" disabled>Upload</button>
</div>
</dialog>`;
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 =>
`<div class="preview-item"><i data-lucide="image" style="width:13px;height:13px"></i> ${this.#esc(f.name)}</div>`
).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 = `
<button class="lightbox-close"><i data-lucide="x" style="width:18px;height:18px"></i></button>
<img class="lightbox-img" src="${this.#esc(p.url)}" alt="${this.#esc(p.caption)}">
${photos.length > 1 ? `
<button class="lightbox-prev"><i data-lucide="chevron-left" style="width:20px;height:20px"></i></button>
<button class="lightbox-next"><i data-lucide="chevron-right" style="width:20px;height:20px"></i></button>` : ''}
${p.caption ? `<div class="lightbox-caption">${this.#esc(p.caption)}</div>` : ''}`;
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
}
customElements.define('wo-photo-panel', WoPhotoPanel);
@@ -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 `
<div class="section" data-type="${section.type}">
<div class="section-header">
<span class="section-icon"><i data-lucide="${section.icon}" style="width:15px;height:15px"></i></span>
<span class="section-label">${section.label}</span>
${count ? `<span class="section-count">${count}</span>` : ''}
<button class="add-resource-btn" data-type="${section.type}" title="Add ${section.label}">
<i data-lucide="plus" style="width:14px;height:14px"></i>
</button>
</div>
<div class="assigned-list">
${count === 0 ? `<div class="empty-section">No ${section.label.toLowerCase()} assigned</div>` :
assigned.map(r => `
<div class="assigned-item">
<span class="item-name">${this.#esc(r.name)}</span>
${r.resource_type === 'material' && r.quantity != null
? `<span class="item-qty">${r.quantity} ${this.#unitFor(r.resource_id)}</span>`
: ''}
<button class="remove-btn" data-rid="${r.id}" title="Remove">
<i data-lucide="x" style="width:13px;height:13px"></i>
</button>
</div>`).join('')}
</div>
</div>`;
}
#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 = `<style>:host{display:block;padding:2rem;text-align:center}</style><ui-spinner></ui-spinner>`;
return;
}
s.innerHTML = `
<style>
:host { display: block; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
.section { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 1rem; }
.section-header { display: flex; align-items: center; gap: .5rem; margin-bottom: .75rem; }
.section-icon { color: var(--teal); display: flex; }
.section-label { font-weight: 700; font-size: .813rem; text-transform: uppercase; letter-spacing: .06em; color: var(--text-muted); flex: 1; }
.section-count { background: var(--teal); color: #fff; border-radius: 999px; font-size: .7rem; font-weight: 700; padding: .1rem .45rem; }
.add-resource-btn { background: none; border: 1px solid var(--border); color: var(--teal); border-radius: var(--radius-sm); cursor: pointer; padding: .25rem; display: flex; align-items: center; transition: background .15s; }
.add-resource-btn:hover { background: var(--surface-2); }
.assigned-list { display: flex; flex-direction: column; gap: .35rem; }
.assigned-item { display: flex; align-items: center; gap: .5rem; padding: .4rem .6rem; background: var(--surface-2); border-radius: var(--radius-sm); font-size: .875rem; }
.item-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.item-qty { font-size: .75rem; color: var(--text-muted); white-space: nowrap; }
.remove-btn { background: none; border: none; cursor: pointer; color: var(--text-muted); padding: .15rem; border-radius: var(--radius-sm); display: flex; flex-shrink: 0; transition: color .15s; }
.remove-btn:hover { color: var(--danger); }
.empty-section { font-size: .813rem; color: var(--text-muted); font-style: italic; padding: .25rem 0; }
/* Picker dialog */
dialog { border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 0; box-shadow: var(--shadow-lg); width: 400px; max-width: 95vw; background: var(--surface); color: var(--text); }
dialog::backdrop { background: rgba(0,0,0,.45); }
.dialog-header { display: flex; align-items: center; justify-content: space-between; padding: 1rem 1.25rem; border-bottom: 1px solid var(--border); }
.dialog-title { font-size: 1rem; font-weight: 700; }
.dialog-close { background: none; border: none; cursor: pointer; color: var(--text-muted); padding: .25rem; display: flex; }
.dialog-body { padding: 1rem 1.25rem; }
.picker-search { width: 100%; border: 1px solid var(--border); border-radius: var(--radius-sm); padding: .5rem .75rem; font-size: .875rem; margin-bottom: .75rem; background: var(--surface); color: var(--text); box-sizing: border-box; }
.picker-search:focus { outline: none; border-color: var(--teal); box-shadow: 0 0 0 3px rgba(10,126,164,.12); }
.picker-list { max-height: 240px; overflow-y: auto; display: flex; flex-direction: column; gap: .3rem; }
.picker-item { display: flex; align-items: center; gap: .75rem; padding: .6rem .75rem; border: 1px solid transparent; border-radius: var(--radius-sm); cursor: pointer; transition: background .15s; }
.picker-item:hover { background: var(--surface-2); border-color: var(--border); }
.picker-item.selected { background: #E0F2FE; border-color: var(--teal); }
.picker-name { font-size: .875rem; font-weight: 500; flex: 1; }
.picker-sub { font-size: .75rem; color: var(--text-muted); }
.picker-empty { text-align: center; padding: 1.5rem; color: var(--text-muted); font-size: .875rem; }
.qty-row { display: flex; align-items: center; gap: .5rem; margin-top: .75rem; padding-top: .75rem; border-top: 1px solid var(--border); }
.qty-label { font-size: .875rem; font-weight: 500; }
.qty-input { width: 80px; border: 1px solid var(--border); border-radius: var(--radius-sm); padding: .4rem .5rem; font-size: .875rem; background: var(--surface); color: var(--text); }
.dialog-footer { padding: .75rem 1.25rem; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; gap: .5rem; }
.btn-ghost { background: transparent; border: 1px solid var(--border); color: var(--text); border-radius: var(--radius); padding: .45rem .9rem; font-size: .813rem; font-weight: 600; cursor: pointer; }
.btn-primary { background: var(--teal); color: #fff; border: none; border-radius: var(--radius); padding: .45rem .9rem; font-size: .813rem; font-weight: 600; cursor: pointer; }
.btn-primary:disabled { opacity: .5; cursor: not-allowed; }
@media (max-width: 768px) { .grid { grid-template-columns: 1fr; } }
</style>
<div class="grid">
${SECTIONS.map(sec => this.#sectionHTML(sec)).join('')}
</div>
<dialog id="picker-dialog">
<div class="dialog-header">
<span class="dialog-title" id="picker-title">Add Resource</span>
<button class="dialog-close" id="picker-close"><i data-lucide="x" style="width:16px;height:16px"></i></button>
</div>
<div class="dialog-body">
<input class="picker-search" id="picker-search" placeholder="Search…" autocomplete="off">
<div class="picker-list" id="picker-list"></div>
<div class="qty-row" id="qty-row" style="display:none">
<label class="qty-label" for="qty-input">Quantity:</label>
<input class="qty-input" id="qty-input" type="number" min="0" step="0.01" value="1">
</div>
</div>
<div class="dialog-footer">
<button class="btn-ghost" id="picker-cancel">Cancel</button>
<button class="btn-primary" id="picker-add" disabled>Add Selected</button>
</div>
</dialog>`;
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 = '<div class="picker-empty">No items available</div>';
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 `
<div class="picker-item" data-id="${item.id}">
<div>
<div class="picker-name">${this.#esc(name)}</div>
${sub ? `<div class="picker-sub">${this.#esc(sub)}</div>` : ''}
</div>
</div>`;
}).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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
}
customElements.define('wo-resource-panel', WoResourcePanel);
+160
View File
@@ -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 = `<style>:host{display:block;padding:2rem;text-align:center}</style><ui-spinner></ui-spinner>`;
return;
}
s.innerHTML = `
<style>
:host { display: block; }
.timeline { display: flex; flex-direction: column; gap: 0; }
.entry { display: flex; gap: 1rem; padding: .875rem 0; position: relative; }
.entry:not(:last-child)::before {
content: '';
position: absolute;
left: 19px;
top: 44px;
bottom: 0;
width: 2px;
background: var(--border);
}
.entry-icon { width: 38px; height: 38px; border-radius: 50%; background: var(--surface-2); border: 2px solid var(--border); display: flex; align-items: center; justify-content: center; flex-shrink: 0; color: var(--text-muted); z-index: 1; }
.entry-icon.status { background: #E0F2FE; border-color: var(--teal); color: var(--teal); }
.entry-icon.step { background: #DCFCE7; border-color: var(--success); color: var(--success); }
.entry-icon.photo { background: var(--surface-2); border-color: var(--text-muted); }
.entry-body { flex: 1; min-width: 0; padding-top: .4rem; }
.entry-action { font-size: .875rem; font-weight: 600; color: var(--text); }
.entry-detail { font-size: .813rem; color: var(--text-muted); margin-top: .2rem; line-height: 1.4; }
.entry-meta { display: flex; align-items: center; gap: .5rem; margin-top: .3rem; font-size: .75rem; color: var(--text-muted); }
.entry-user { font-weight: 600; color: var(--text); }
.status-change { display: inline-flex; align-items: center; gap: .4rem; font-size: .813rem; }
.status-arrow { color: var(--text-muted); }
.status-badge { display: inline-flex; align-items: center; gap: .3rem; padding: .15rem .5rem; border-radius: 999px; font-size: .7rem; font-weight: 700; text-transform: uppercase; background: var(--surface-2); }
.empty { text-align: center; padding: 3rem 1rem; color: var(--text-muted); }
.empty p { font-size: .875rem; margin-top: .5rem; }
.refresh-btn { display: inline-flex; align-items: center; gap: .35rem; margin-top: 1rem; background: none; border: 1px solid var(--border); color: var(--text-muted); border-radius: var(--radius); padding: .4rem .75rem; font-size: .813rem; cursor: pointer; }
.refresh-btn:hover { background: var(--surface-2); }
</style>
${this.#entries.length === 0 ? `
<div class="empty">
<i data-lucide="activity" style="width:32px;height:32px;opacity:.3;display:block;margin:0 auto"></i>
<p>No activity recorded yet</p>
</div>` : `
<div class="timeline">
${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 `
<div class="entry">
<div class="entry-icon ${cls}">
<i data-lucide="${icon}" style="width:16px;height:16px"></i>
</div>
<div class="entry-body">
<div class="entry-action">${this.#esc(label)}</div>
${this.#detailHTML(e)}
<div class="entry-meta">
<span class="entry-user">${this.#esc(e.performed_by)}</span>
<span>·</span>
<span title="${new Date(e.performed_at).toLocaleString()}">${this.#relativeTime(e.performed_at)}</span>
</div>
</div>
</div>`;
}).join('')}
</div>`}
<button class="refresh-btn" id="refresh-btn">
<i data-lucide="rotate-cw" style="width:13px;height:13px"></i> Refresh
</button>`;
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 `<div class="entry-detail">
<span class="status-change">
<span class="status-badge">${this.#esc(e.old_value.replace('_',' '))}</span>
<span class="status-arrow">→</span>
<span class="status-badge" style="background:var(--surface-2)">${this.#esc(e.new_value.replace('_',' '))}</span>
</span>
</div>`;
}
if (e.new_value) {
return `<div class="entry-detail">${this.#esc(e.new_value)}</div>`;
}
return '';
}
#esc(s) { return (s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
}
customElements.define('wo-timeline', WoTimeline);