Compare commits
8 Commits
18c722e5bc
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6307babbfa | |||
| 309f19520b | |||
| 17e05cb61d | |||
| fb67c76f45 | |||
| 8d74f3e520 | |||
| e132c7a580 | |||
| c7df396a83 | |||
| 368d98c43c |
+25
-3
@@ -5,14 +5,15 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
"workorders/internal/api"
|
"workorders/internal/api"
|
||||||
"workorders/internal/config"
|
"workorders/internal/config"
|
||||||
"workorders/internal/repository"
|
"workorders/internal/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// Load .env if present (ignored in production containers where env is injected)
|
|
||||||
if _, err := os.Stat(".env"); err == nil {
|
if _, err := os.Stat(".env"); err == nil {
|
||||||
if err := godotenv.Load(); err != nil {
|
if err := godotenv.Load(); err != nil {
|
||||||
log.Printf("warning: could not load .env: %v", err)
|
log.Printf("warning: could not load .env: %v", err)
|
||||||
@@ -33,8 +34,10 @@ func main() {
|
|||||||
log.Fatalf("migrations: %v", err)
|
log.Fatalf("migrations: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("fetching JWKS from %s", cfg.JWKSUrl)
|
log.Printf("seeding default admin...")
|
||||||
api.InitJWKS(cfg.JWKSUrl)
|
if err := seedAdmin(db, cfg.AdminPassword); err != nil {
|
||||||
|
log.Printf("warning: seed admin: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
r := api.NewRouter(cfg, db)
|
r := api.NewRouter(cfg, db)
|
||||||
|
|
||||||
@@ -43,3 +46,22 @@ func main() {
|
|||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func seedAdmin(db *sqlx.DB, password string) error {
|
||||||
|
var count int
|
||||||
|
if err := db.Get(&count, "SELECT COUNT(*) FROM users"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = db.Exec(`
|
||||||
|
INSERT INTO users (username, email, display_name, password_hash, role)
|
||||||
|
VALUES (@p1, @p2, @p3, @p4, 'admin')`,
|
||||||
|
"admin", "admin@workorders.local", "Admin User", string(hash))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ services:
|
|||||||
- "localhost:host-gateway"
|
- "localhost:host-gateway"
|
||||||
volumes:
|
volumes:
|
||||||
- uploads:/uploads
|
- uploads:/uploads
|
||||||
|
- ./web:/app/web
|
||||||
depends_on:
|
depends_on:
|
||||||
mssql:
|
mssql:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ require (
|
|||||||
github.com/jmoiron/sqlx v1.3.5
|
github.com/jmoiron/sqlx v1.3.5
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/microsoft/go-mssqldb v1.7.2
|
github.com/microsoft/go-mssqldb v1.7.2
|
||||||
|
golang.org/x/crypto v0.18.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
|
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
|
||||||
github.com/golang-sql/sqlexp v0.1.0 // indirect
|
github.com/golang-sql/sqlexp v0.1.0 // indirect
|
||||||
golang.org/x/crypto v0.18.0 // indirect
|
|
||||||
golang.org/x/text v0.14.0 // indirect
|
golang.org/x/text v0.14.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -1,7 +1,110 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import "net/http"
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"workorders/internal/config"
|
||||||
|
"workorders/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AuthHandler struct {
|
||||||
|
db *sqlx.DB
|
||||||
|
cfg *config.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAuthHandler(db *sqlx.DB, cfg *config.Config) *AuthHandler {
|
||||||
|
return &AuthHandler{db: db, cfg: cfg}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
if err := decode(r, &body); err != nil || body.Username == "" || body.Password == "" {
|
||||||
|
respondError(w, http.StatusBadRequest, "username and password required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var user model.User
|
||||||
|
err := h.db.Get(&user,
|
||||||
|
`SELECT * FROM users WHERE (username=@p1 OR email=@p1) AND active=1`,
|
||||||
|
body.Username)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusUnauthorized, "invalid credentials")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(body.Password)); err != nil {
|
||||||
|
respondError(w, http.StatusUnauthorized, "invalid credentials")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := generateToken(user, h.cfg.JWTSecret)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "token generation failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.db.Exec(`UPDATE users SET last_login=GETUTCDATE() WHERE id=@p1`, user.ID)
|
||||||
|
|
||||||
|
respond(w, http.StatusOK, map[string]any{
|
||||||
|
"token": token,
|
||||||
|
"user": publicUser(user),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) Refresh(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := userFromCtx(r)
|
||||||
|
|
||||||
|
var dbUser model.User
|
||||||
|
if err := h.db.Get(&dbUser, `SELECT * FROM users WHERE id=@p1 AND active=1`, user.UserID); err != nil {
|
||||||
|
respondError(w, http.StatusUnauthorized, "user not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := generateToken(dbUser, h.cfg.JWTSecret)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "token generation failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
respond(w, http.StatusOK, map[string]any{"token": token})
|
||||||
|
}
|
||||||
|
|
||||||
func Me(w http.ResponseWriter, r *http.Request) {
|
func Me(w http.ResponseWriter, r *http.Request) {
|
||||||
respond(w, http.StatusOK, userFromCtx(r))
|
respond(w, http.StatusOK, userFromCtx(r))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func generateToken(user model.User, secret string) (string, error) {
|
||||||
|
claims := model.LocalClaims{
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
Subject: strconv.Itoa(user.ID),
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(8 * time.Hour)),
|
||||||
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
|
},
|
||||||
|
UserID: user.ID,
|
||||||
|
Username: user.Username,
|
||||||
|
Email: user.Email,
|
||||||
|
DisplayName: user.DisplayName,
|
||||||
|
Role: user.Role,
|
||||||
|
}
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
return token.SignedString([]byte(secret))
|
||||||
|
}
|
||||||
|
|
||||||
|
func publicUser(u model.User) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"id": u.ID,
|
||||||
|
"username": u.Username,
|
||||||
|
"email": u.Email,
|
||||||
|
"display_name": u.DisplayName,
|
||||||
|
"role": u.Role,
|
||||||
|
"avatar_url": u.AvatarURL,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,294 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
if body.StepType == "" {
|
||||||
|
body.StepType = "work_step"
|
||||||
|
}
|
||||||
|
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, step_type, type_config)
|
||||||
|
OUTPUT INSERTED.id VALUES (@p1, @p2, @p3, @p4, @p5, @p6, @p7)`,
|
||||||
|
profileID, maxOrder+1, body.Title, body.Description, body.Required, body.StepType, body.TypeConfig,
|
||||||
|
).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
|
||||||
|
}
|
||||||
|
if body.StepType == "" {
|
||||||
|
body.StepType = "work_step"
|
||||||
|
}
|
||||||
|
h.db.Exec(`UPDATE wo_profile_steps SET title=@p1, description=@p2, required=@p3, step_order=@p4, step_type=@p5, type_config=@p6 WHERE id=@p7`,
|
||||||
|
body.Title, body.Description, body.Required, body.StepOrder, body.StepType, body.TypeConfig, 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 {
|
||||||
|
if s.StepType == "" {
|
||||||
|
s.StepType = "work_step"
|
||||||
|
}
|
||||||
|
h.db.Exec(`
|
||||||
|
INSERT INTO wo_steps (wo_id, step_order, title, description, required, step_type, type_config)
|
||||||
|
VALUES (@p1, @p2, @p3, @p4, @p5, @p6, @p7)`,
|
||||||
|
woID, maxOrder+s.StepOrder, s.Title, s.Description, s.Required, s.StepType, s.TypeConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
|
|||||||
@@ -41,11 +41,14 @@ func (h *StepHandler) Create(w http.ResponseWriter, r *http.Request) {
|
|||||||
var maxOrder int
|
var maxOrder int
|
||||||
h.db.QueryRow(`SELECT ISNULL(MAX(step_order),0) FROM wo_steps WHERE wo_id=@p1`, woID).Scan(&maxOrder)
|
h.db.QueryRow(`SELECT ISNULL(MAX(step_order),0) FROM wo_steps WHERE wo_id=@p1`, woID).Scan(&maxOrder)
|
||||||
|
|
||||||
|
if body.StepType == "" {
|
||||||
|
body.StepType = "work_step"
|
||||||
|
}
|
||||||
var sid int
|
var sid int
|
||||||
err = h.db.QueryRow(`
|
err = h.db.QueryRow(`
|
||||||
INSERT INTO wo_steps (wo_id,step_order,title,description,required)
|
INSERT INTO wo_steps (wo_id,step_order,title,description,required,step_type,type_config)
|
||||||
OUTPUT INSERTED.id VALUES (@p1,@p2,@p3,@p4,@p5)`,
|
OUTPUT INSERTED.id VALUES (@p1,@p2,@p3,@p4,@p5,@p6,@p7)`,
|
||||||
woID, maxOrder+1, body.Title, body.Description, body.Required,
|
woID, maxOrder+1, body.Title, body.Description, body.Required, body.StepType, body.TypeConfig,
|
||||||
).Scan(&sid)
|
).Scan(&sid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, err.Error())
|
respondError(w, http.StatusInternalServerError, err.Error())
|
||||||
@@ -93,6 +96,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 {
|
||||||
|
|||||||
+31
-130
@@ -2,117 +2,21 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/rsa"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"math/big"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
"workorders/internal/model"
|
"workorders/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// jwksCache caches the public keys from Keycloak
|
var roleLevel = map[string]int{
|
||||||
type jwksCache struct {
|
"admin": 4, "dispatcher": 3, "field_tech": 2, "viewer": 1,
|
||||||
mu sync.RWMutex
|
|
||||||
keys map[string]*rsa.PublicKey
|
|
||||||
fetchAt time.Time
|
|
||||||
url string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var cache = &jwksCache{}
|
// JWTAuth validates a locally-issued HMAC-signed JWT in the Authorization header.
|
||||||
|
func JWTAuth(secret string) func(http.Handler) http.Handler {
|
||||||
func InitJWKS(url string) {
|
return func(next http.Handler) http.Handler {
|
||||||
cache.url = url
|
|
||||||
if err := cache.refresh(); err != nil {
|
|
||||||
log.Printf("JWKS initial fetch warning: %v (will retry per-request)", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *jwksCache) refresh() error {
|
|
||||||
resp, err := http.Get(c.url) //nolint:gosec
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("fetch JWKS: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
var jwks struct {
|
|
||||||
Keys []struct {
|
|
||||||
Kid string `json:"kid"`
|
|
||||||
Kty string `json:"kty"`
|
|
||||||
Alg string `json:"alg"`
|
|
||||||
N string `json:"n"`
|
|
||||||
E string `json:"e"`
|
|
||||||
} `json:"keys"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&jwks); err != nil {
|
|
||||||
return fmt.Errorf("decode JWKS: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
keys := make(map[string]*rsa.PublicKey, len(jwks.Keys))
|
|
||||||
for _, k := range jwks.Keys {
|
|
||||||
if k.Kty != "RSA" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
pub, err := rsaPublicKey(k.N, k.E)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
keys[k.Kid] = pub
|
|
||||||
}
|
|
||||||
|
|
||||||
c.mu.Lock()
|
|
||||||
c.keys = keys
|
|
||||||
c.fetchAt = time.Now()
|
|
||||||
c.mu.Unlock()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *jwksCache) get(kid string) (*rsa.PublicKey, error) {
|
|
||||||
c.mu.RLock()
|
|
||||||
key, ok := c.keys[kid]
|
|
||||||
stale := time.Since(c.fetchAt) > 10*time.Minute
|
|
||||||
c.mu.RUnlock()
|
|
||||||
|
|
||||||
if ok && !stale {
|
|
||||||
return key, nil
|
|
||||||
}
|
|
||||||
if err := c.refresh(); err != nil {
|
|
||||||
if ok {
|
|
||||||
return key, nil // use stale key if refresh fails
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
c.mu.RLock()
|
|
||||||
key, ok = c.keys[kid]
|
|
||||||
c.mu.RUnlock()
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("key %q not found", kid)
|
|
||||||
}
|
|
||||||
return key, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func rsaPublicKey(nStr, eStr string) (*rsa.PublicKey, error) {
|
|
||||||
nBytes, err := base64.RawURLEncoding.DecodeString(nStr)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
eBytes, err := base64.RawURLEncoding.DecodeString(eStr)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
n := new(big.Int).SetBytes(nBytes)
|
|
||||||
e := new(big.Int).SetBytes(eBytes)
|
|
||||||
return &rsa.PublicKey{N: n, E: int(e.Int64())}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// OIDCAuth validates a Keycloak-issued JWT in the Authorization header.
|
|
||||||
func OIDCAuth(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
auth := r.Header.Get("Authorization")
|
auth := r.Header.Get("Authorization")
|
||||||
if !strings.HasPrefix(auth, "Bearer ") {
|
if !strings.HasPrefix(auth, "Bearer ") {
|
||||||
@@ -121,44 +25,46 @@ func OIDCAuth(next http.Handler) http.Handler {
|
|||||||
}
|
}
|
||||||
raw := strings.TrimPrefix(auth, "Bearer ")
|
raw := strings.TrimPrefix(auth, "Bearer ")
|
||||||
|
|
||||||
token, err := jwt.Parse(raw, func(t *jwt.Token) (any, error) {
|
var claims model.LocalClaims
|
||||||
if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok {
|
_, err := jwt.ParseWithClaims(raw, &claims, func(t *jwt.Token) (any, error) {
|
||||||
|
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
|
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
|
||||||
}
|
}
|
||||||
kid, _ := t.Header["kid"].(string)
|
return []byte(secret), nil
|
||||||
return cache.get(kid)
|
|
||||||
}, jwt.WithExpirationRequired())
|
}, jwt.WithExpirationRequired())
|
||||||
|
|
||||||
if err != nil || !token.Valid {
|
if err != nil {
|
||||||
jsonError(w, "invalid token", http.StatusUnauthorized)
|
jsonError(w, "invalid token", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
claims, ok := token.Claims.(jwt.MapClaims)
|
|
||||||
if !ok {
|
|
||||||
jsonError(w, "invalid claims", http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
user := model.UserClaims{
|
user := model.UserClaims{
|
||||||
Sub: stringClaim(claims, "sub"),
|
UserID: claims.UserID,
|
||||||
Email: stringClaim(claims, "email"),
|
Username: claims.Username,
|
||||||
Name: stringClaim(claims, "name"),
|
Email: claims.Email,
|
||||||
|
DisplayName: claims.DisplayName,
|
||||||
|
Role: claims.Role,
|
||||||
}
|
}
|
||||||
if ra, ok := claims["realm_access"].(map[string]any); ok {
|
|
||||||
if roles, ok := ra["roles"].([]any); ok {
|
|
||||||
for _, r := range roles {
|
|
||||||
if s, ok := r.(string); ok {
|
|
||||||
user.Roles = append(user.Roles, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.WithValue(r.Context(), model.CtxUserKey, user)
|
ctx := context.WithValue(r.Context(), model.CtxUserKey, user)
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireRole blocks requests where the user's role is below the required level.
|
||||||
|
func RequireRole(minRole string) func(http.Handler) http.Handler {
|
||||||
|
minLevel := roleLevel[minRole]
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := UserFromCtx(r)
|
||||||
|
if roleLevel[user.Role] >= minLevel {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonError(w, "forbidden", http.StatusForbidden)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func CORS(next http.Handler) http.Handler {
|
func CORS(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -173,11 +79,6 @@ func CORS(next http.Handler) http.Handler {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func stringClaim(c jwt.MapClaims, key string) string {
|
|
||||||
v, _ := c[key].(string)
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
|
|
||||||
func jsonError(w http.ResponseWriter, msg string, code int) {
|
func jsonError(w http.ResponseWriter, msg string, code int) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(code)
|
w.WriteHeader(code)
|
||||||
|
|||||||
+47
-9
@@ -20,9 +20,19 @@ 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")))
|
||||||
|
|
||||||
// Protected API
|
// Public routes — uploads served without auth (paths are UUIDs, not guessable)
|
||||||
|
r.Handle("/uploads/*", http.StripPrefix("/uploads/", http.FileServer(http.Dir(cfg.UploadPath))))
|
||||||
|
|
||||||
|
// Public auth routes
|
||||||
|
auth := handlers.NewAuthHandler(db, cfg)
|
||||||
|
r.Post("/api/auth/login", auth.Login)
|
||||||
|
|
||||||
|
// Protected API routes
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(OIDCAuth)
|
r.Use(JWTAuth(cfg.JWTSecret))
|
||||||
|
|
||||||
|
r.Post("/api/auth/refresh", auth.Refresh)
|
||||||
|
r.Get("/api/auth/me", handlers.Me)
|
||||||
|
|
||||||
wo := handlers.NewWorkOrderHandler(db, cfg)
|
wo := handlers.NewWorkOrderHandler(db, cfg)
|
||||||
r.Get("/api/work-orders", wo.List)
|
r.Get("/api/work-orders", wo.List)
|
||||||
@@ -37,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)
|
||||||
@@ -48,19 +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)
|
||||||
|
|
||||||
reg := handlers.NewRegistryHandler(db)
|
act := handlers.NewActivityHandler(db)
|
||||||
r.Get("/api/registry/people", reg.People)
|
r.Get("/api/work-orders/{id}/activity", act.List)
|
||||||
r.Get("/api/registry/vehicles", reg.Vehicles)
|
|
||||||
r.Get("/api/registry/equipment", reg.Equipment)
|
|
||||||
r.Get("/api/registry/materials", reg.Materials)
|
|
||||||
|
|
||||||
r.Get("/api/me", handlers.Me)
|
prof := handlers.NewProfileHandler(db)
|
||||||
|
r.Get("/api/profiles", prof.List)
|
||||||
|
r.Post("/api/profiles", prof.Create)
|
||||||
|
r.Get("/api/profiles/{id}", prof.Get)
|
||||||
|
r.Put("/api/profiles/{id}", prof.Update)
|
||||||
|
r.Delete("/api/profiles/{id}", prof.Delete)
|
||||||
|
r.Get("/api/profiles/{id}/steps", prof.ListSteps)
|
||||||
|
r.Post("/api/profiles/{id}/steps", prof.CreateStep)
|
||||||
|
r.Put("/api/profiles/{id}/steps/{sid}", prof.UpdateStep)
|
||||||
|
r.Delete("/api/profiles/{id}/steps/{sid}", prof.DeleteStep)
|
||||||
|
r.Post("/api/work-orders/{id}/apply-profile/{profileId}", prof.Apply)
|
||||||
|
|
||||||
|
reg := handlers.NewRegistryHandler(db)
|
||||||
|
r.Get("/api/registry/people", reg.ListPeople)
|
||||||
|
r.Post("/api/registry/people", reg.CreatePerson)
|
||||||
|
r.Put("/api/registry/people/{id}", reg.UpdatePerson)
|
||||||
|
r.Delete("/api/registry/people/{id}", reg.DeletePerson)
|
||||||
|
|
||||||
|
r.Get("/api/registry/vehicles", reg.ListVehicles)
|
||||||
|
r.Post("/api/registry/vehicles", reg.CreateVehicle)
|
||||||
|
r.Put("/api/registry/vehicles/{id}", reg.UpdateVehicle)
|
||||||
|
r.Delete("/api/registry/vehicles/{id}", reg.DeleteVehicle)
|
||||||
|
|
||||||
|
r.Get("/api/registry/equipment", reg.ListEquipment)
|
||||||
|
r.Post("/api/registry/equipment", reg.CreateEquipment)
|
||||||
|
r.Put("/api/registry/equipment/{id}", reg.UpdateEquipment)
|
||||||
|
r.Delete("/api/registry/equipment/{id}", reg.DeleteEquipment)
|
||||||
|
|
||||||
|
r.Get("/api/registry/materials", reg.ListMaterials)
|
||||||
|
r.Post("/api/registry/materials", reg.CreateMaterial)
|
||||||
|
r.Put("/api/registry/materials/{id}", reg.UpdateMaterial)
|
||||||
|
r.Delete("/api/registry/materials/{id}", reg.DeleteMaterial)
|
||||||
})
|
})
|
||||||
|
|
||||||
return r
|
return r
|
||||||
|
|||||||
@@ -6,21 +6,20 @@ type Config struct {
|
|||||||
Addr string
|
Addr string
|
||||||
DBDSN string
|
DBDSN string
|
||||||
JWTSecret string
|
JWTSecret string
|
||||||
|
AdminPassword string
|
||||||
UploadPath string
|
UploadPath string
|
||||||
BaseURL string
|
BaseURL string
|
||||||
OIDCIssuer string
|
|
||||||
JWKSUrl string
|
|
||||||
AppEnv string
|
AppEnv string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() *Config {
|
func Load() *Config {
|
||||||
return &Config{
|
return &Config{
|
||||||
Addr: env("ADDR", ":8080"),
|
Addr: env("ADDR", ":9080"),
|
||||||
DBDSN: env("DB_DSN", ""),
|
DBDSN: env("DB_DSN", ""),
|
||||||
|
JWTSecret: env("JWT_SECRET", "change-me-in-production"),
|
||||||
|
AdminPassword: env("ADMIN_PASSWORD", "admin123"),
|
||||||
UploadPath: env("UPLOAD_PATH", "./uploads"),
|
UploadPath: env("UPLOAD_PATH", "./uploads"),
|
||||||
BaseURL: env("BASE_URL", "http://localhost:8080"),
|
BaseURL: env("BASE_URL", "http://localhost:9080"),
|
||||||
OIDCIssuer: env("OIDC_ISSUER", "http://localhost:8180/realms/workorders"),
|
|
||||||
JWKSUrl: env("JWKS_URL", "http://localhost:8180/realms/workorders/protocol/openid-connect/certs"),
|
|
||||||
AppEnv: env("APP_ENV", "development"),
|
AppEnv: env("APP_ENV", "development"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
import "time"
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Work Order ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type WorkOrder struct {
|
type WorkOrder struct {
|
||||||
ID int `db:"id" json:"id"`
|
ID int `db:"id" json:"id"`
|
||||||
@@ -41,6 +47,8 @@ type WorkOrderListItem struct {
|
|||||||
PhotoCount int `db:"photo_count" json:"photo_count"`
|
PhotoCount int `db:"photo_count" json:"photo_count"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Steps / Resources / Attachments / Accounting ─────────────────────────────
|
||||||
|
|
||||||
type Step struct {
|
type Step struct {
|
||||||
ID int `db:"id" json:"id"`
|
ID int `db:"id" json:"id"`
|
||||||
WOID int `db:"wo_id" json:"wo_id"`
|
WOID int `db:"wo_id" json:"wo_id"`
|
||||||
@@ -48,6 +56,8 @@ type Step struct {
|
|||||||
Title string `db:"title" json:"title"`
|
Title string `db:"title" json:"title"`
|
||||||
Description string `db:"description" json:"description"`
|
Description string `db:"description" json:"description"`
|
||||||
Required bool `db:"required" json:"required"`
|
Required bool `db:"required" json:"required"`
|
||||||
|
StepType string `db:"step_type" json:"step_type"`
|
||||||
|
TypeConfig string `db:"type_config" json:"type_config"`
|
||||||
Completed bool `db:"completed" json:"completed"`
|
Completed bool `db:"completed" json:"completed"`
|
||||||
CompletedBy *string `db:"completed_by" json:"completed_by"`
|
CompletedBy *string `db:"completed_by" json:"completed_by"`
|
||||||
CompletedAt *time.Time `db:"completed_at" json:"completed_at"`
|
CompletedAt *time.Time `db:"completed_at" json:"completed_at"`
|
||||||
@@ -90,12 +100,15 @@ type AccountingCode struct {
|
|||||||
Description string `db:"description" json:"description"`
|
Description string `db:"description" json:"description"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 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 {
|
||||||
@@ -103,6 +116,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 {
|
||||||
@@ -110,6 +124,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 {
|
||||||
@@ -117,13 +132,78 @@ 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"`
|
||||||
|
StepType string `db:"step_type" json:"step_type"`
|
||||||
|
TypeConfig string `db:"type_config" json:"type_config"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Users & Auth ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID int `db:"id" json:"id"`
|
||||||
|
Username string `db:"username" json:"username"`
|
||||||
|
Email string `db:"email" json:"email"`
|
||||||
|
DisplayName string `db:"display_name" json:"display_name"`
|
||||||
|
PasswordHash string `db:"password_hash" json:"-"`
|
||||||
|
Role string `db:"role" json:"role"`
|
||||||
|
AvatarURL *string `db:"avatar_url" json:"avatar_url,omitempty"`
|
||||||
|
Active bool `db:"active" json:"active"`
|
||||||
|
LastLogin *time.Time `db:"last_login" json:"last_login"`
|
||||||
|
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LocalClaims is the JWT payload for locally-issued tokens.
|
||||||
|
type LocalClaims struct {
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
UserID int `json:"uid"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
DisplayName string `json:"name"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserClaims is stored in request context after token validation.
|
||||||
type UserClaims struct {
|
type UserClaims struct {
|
||||||
Sub string
|
UserID int `json:"user_id"`
|
||||||
Email string
|
Username string `json:"username"`
|
||||||
Name string
|
Email string `json:"email"`
|
||||||
Roles []string
|
DisplayName string `json:"display_name"`
|
||||||
|
Role string `json:"role"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CtxKey string
|
type CtxKey string
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
CREATE TABLE users (
|
||||||
|
id INT IDENTITY PRIMARY KEY,
|
||||||
|
username NVARCHAR(100) NOT NULL UNIQUE,
|
||||||
|
email NVARCHAR(200) NOT NULL UNIQUE,
|
||||||
|
display_name NVARCHAR(200),
|
||||||
|
password_hash NVARCHAR(200) NOT NULL,
|
||||||
|
role NVARCHAR(30) NOT NULL DEFAULT 'viewer',
|
||||||
|
avatar_url NVARCHAR(500),
|
||||||
|
active BIT NOT NULL DEFAULT 1,
|
||||||
|
last_login DATETIME2,
|
||||||
|
created_at DATETIME2 NOT NULL DEFAULT GETUTCDATE(),
|
||||||
|
CONSTRAINT chk_user_role CHECK (role IN ('admin','dispatcher','field_tech','viewer'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX ix_users_username ON users (username);
|
||||||
|
CREATE INDEX ix_users_email ON users (email);
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- Add step_type and type_config to profile steps and WO steps
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID('wo_profile_steps') AND name = 'step_type')
|
||||||
|
ALTER TABLE wo_profile_steps ADD step_type NVARCHAR(30) NOT NULL DEFAULT 'work_step';
|
||||||
|
-- 'work_step' | 'photo' | 'inspection' | 'note'
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID('wo_profile_steps') AND name = 'type_config')
|
||||||
|
ALTER TABLE wo_profile_steps ADD type_config NVARCHAR(MAX) NULL;
|
||||||
|
-- JSON; shape depends on step_type (see CLAUDE.md)
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID('wo_steps') AND name = 'step_type')
|
||||||
|
ALTER TABLE wo_steps ADD step_type NVARCHAR(30) NOT NULL DEFAULT 'work_step';
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID('wo_steps') AND name = 'type_config')
|
||||||
|
ALTER TABLE wo_steps ADD type_config NVARCHAR(MAX) NULL;
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- Add step_type and type_config to profile steps and WO steps
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID('wo_profile_steps') AND name = 'step_type')
|
||||||
|
ALTER TABLE wo_profile_steps ADD step_type NVARCHAR(30) NOT NULL DEFAULT 'work_step';
|
||||||
|
-- 'work_step' | 'photo' | 'inspection' | 'note'
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID('wo_profile_steps') AND name = 'type_config')
|
||||||
|
ALTER TABLE wo_profile_steps ADD type_config NVARCHAR(MAX) NULL;
|
||||||
|
-- JSON; shape depends on step_type (see CLAUDE.md)
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID('wo_steps') AND name = 'step_type')
|
||||||
|
ALTER TABLE wo_steps ADD step_type NVARCHAR(30) NOT NULL DEFAULT 'work_step';
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID('wo_steps') AND name = 'type_config')
|
||||||
|
ALTER TABLE wo_steps ADD type_config NVARCHAR(MAX) NULL;
|
||||||
+141
-77
@@ -1,84 +1,148 @@
|
|||||||
import { setToken } from './lib/api.mjs';
|
// ── Register all custom elements ──────────────────────────────────────────────
|
||||||
import './components/wo-list.mjs';
|
import './components/shared/ui-badge.mjs';
|
||||||
import './components/wo-form.mjs';
|
import './components/shared/ui-button.mjs';
|
||||||
|
import './components/shared/ui-spinner.mjs';
|
||||||
|
import './components/shared/ui-toast.mjs';
|
||||||
|
import './components/shared/ui-empty.mjs';
|
||||||
|
import './components/shared/ui-dialog.mjs';
|
||||||
|
import './components/layout/app-root.mjs';
|
||||||
|
import './components/layout/app-sidebar.mjs';
|
||||||
|
import './components/layout/app-topbar.mjs';
|
||||||
|
import './components/layout/app-mobile-nav.mjs';
|
||||||
|
import './components/work-orders/wo-list.mjs';
|
||||||
|
import './components/work-orders/wo-kanban.mjs';
|
||||||
|
import './components/work-orders/wo-form.mjs';
|
||||||
|
import './components/work-orders/wo-detail.mjs';
|
||||||
|
import './components/work-orders/wo-checklist.mjs';
|
||||||
|
import './components/work-orders/wo-resource-panel.mjs';
|
||||||
|
import './components/work-orders/wo-photo-panel.mjs';
|
||||||
|
import './components/work-orders/wo-map.mjs';
|
||||||
|
import './components/work-orders/wo-accounting.mjs';
|
||||||
|
import './components/work-orders/wo-timeline.mjs';
|
||||||
|
import './components/registry/people-list.mjs';
|
||||||
|
import './components/registry/vehicle-list.mjs';
|
||||||
|
import './components/registry/equipment-list.mjs';
|
||||||
|
import './components/registry/material-list.mjs';
|
||||||
|
import './components/registry/profile-list.mjs';
|
||||||
|
|
||||||
// ── Keycloak init ─────────────────────────────────────────────────────────────
|
import { getUser, setToken, clearToken } from './lib/auth.mjs';
|
||||||
const keycloak = new Keycloak({
|
import { api } from './lib/api.mjs';
|
||||||
url: window.KEYCLOAK_URL || 'http://localhost:8180',
|
import { router } from './lib/router.mjs';
|
||||||
realm: window.KEYCLOAK_REALM || 'workorders',
|
import { showToast } from './components/shared/ui-toast.mjs';
|
||||||
clientId: window.KEYCLOAK_CLIENT_ID || 'workorders-app',
|
|
||||||
|
const root = document.getElementById('root');
|
||||||
|
|
||||||
|
window.addEventListener('auth:expired', () => { clearToken(); showLoginPage(); });
|
||||||
|
window.addEventListener('auth:logout', () => { clearToken(); showLoginPage(); });
|
||||||
|
|
||||||
|
const user = getUser();
|
||||||
|
if (user) {
|
||||||
|
startApp();
|
||||||
|
} else {
|
||||||
|
showLoginPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Login page ────────────────────────────────────────────────────────────────
|
||||||
|
function showLoginPage() {
|
||||||
|
root.innerHTML = `
|
||||||
|
<style>
|
||||||
|
.login-wrap {
|
||||||
|
min-height: 100vh; display: flex; align-items: center; justify-content: center;
|
||||||
|
background: var(--bg); padding: 1rem;
|
||||||
|
}
|
||||||
|
.login-card {
|
||||||
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg); padding: 2.5rem 2rem;
|
||||||
|
width: 100%; max-width: 380px; box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
.login-brand {
|
||||||
|
display: flex; align-items: center; justify-content: center; gap: .75rem;
|
||||||
|
margin-bottom: 1.75rem;
|
||||||
|
}
|
||||||
|
.login-icon {
|
||||||
|
width: 40px; height: 40px; background: var(--teal); border-radius: var(--radius);
|
||||||
|
display: flex; align-items: center; justify-content: center; color: #fff;
|
||||||
|
}
|
||||||
|
.login-title { font-size: 1.25rem; font-weight: 700; color: var(--text); }
|
||||||
|
.login-card form { display: flex; flex-direction: column; gap: 1rem; }
|
||||||
|
.login-card label { display: flex; flex-direction: column; gap: .3rem; font-size: .875rem; font-weight: 600; color: var(--text); }
|
||||||
|
.login-card input { border: 1px solid var(--border); border-radius: var(--radius-sm); padding: .6rem .75rem; background: var(--surface); color: var(--text); font-size: .938rem; width: 100%; transition: border-color .15s; }
|
||||||
|
.login-card input:focus { outline: none; border-color: var(--teal); box-shadow: 0 0 0 3px rgba(10,126,164,.15); }
|
||||||
|
.login-btn {
|
||||||
|
background: var(--teal); color: #fff; border: none; border-radius: var(--radius);
|
||||||
|
padding: .65rem; font-size: .938rem; font-weight: 600; cursor: pointer;
|
||||||
|
transition: opacity .15s; display: flex; align-items: center; justify-content: center; gap: .4rem;
|
||||||
|
}
|
||||||
|
.login-btn:hover { opacity: .88; }
|
||||||
|
.login-btn:disabled { opacity: .5; cursor: not-allowed; }
|
||||||
|
.login-error { color: var(--danger); font-size: .813rem; text-align: center; min-height: 1.25rem; }
|
||||||
|
</style>
|
||||||
|
<div class="login-wrap">
|
||||||
|
<div class="login-card">
|
||||||
|
<div class="login-brand">
|
||||||
|
<div class="login-icon"><i data-lucide="clipboard-list" style="width:20px;height:20px"></i></div>
|
||||||
|
<span class="login-title">Work Orders</span>
|
||||||
|
</div>
|
||||||
|
<form id="login-form">
|
||||||
|
<label>Username or Email<input id="login-user" type="text" autocomplete="username" required placeholder="admin"></label>
|
||||||
|
<label>Password<input id="login-pass" type="password" autocomplete="current-password" required placeholder="••••••••"></label>
|
||||||
|
<div class="login-error" id="login-error"></div>
|
||||||
|
<button type="submit" class="login-btn" id="login-btn">Sign In</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
if (window.lucide) lucide.createIcons({ root: root });
|
||||||
|
|
||||||
|
root.querySelector('#login-form').addEventListener('submit', async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const btn = root.querySelector('#login-btn');
|
||||||
|
const err = root.querySelector('#login-error');
|
||||||
|
const username = root.querySelector('#login-user').value.trim();
|
||||||
|
const password = root.querySelector('#login-pass').value;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Signing in…';
|
||||||
|
err.textContent = '';
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
if (!res.ok) throw new Error(json.error || 'Login failed');
|
||||||
|
setToken(json.data.token);
|
||||||
|
startApp();
|
||||||
|
} catch (ex) {
|
||||||
|
err.textContent = ex.message;
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Sign In';
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
keycloak.init({
|
|
||||||
onLoad: 'login-required',
|
|
||||||
pkceMethod: 'S256',
|
|
||||||
checkLoginIframe: false,
|
|
||||||
})
|
|
||||||
.then(authenticated => {
|
|
||||||
if (!authenticated) { keycloak.login(); return; }
|
|
||||||
setToken(keycloak.token);
|
|
||||||
|
|
||||||
// Refresh token before it expires
|
|
||||||
setInterval(async () => {
|
|
||||||
try { await keycloak.updateToken(60); setToken(keycloak.token); }
|
|
||||||
catch { keycloak.login(); }
|
|
||||||
}, 30_000);
|
|
||||||
|
|
||||||
window.addEventListener('auth:expired', () => keycloak.login());
|
|
||||||
renderApp(keycloak);
|
|
||||||
})
|
|
||||||
.catch(() => document.getElementById('app').innerHTML =
|
|
||||||
'<p style="color:red;padding:2rem">Failed to connect to authentication server.</p>');
|
|
||||||
|
|
||||||
// ── App shell ─────────────────────────────────────────────────────────────────
|
|
||||||
function renderApp(kc) {
|
|
||||||
const app = document.getElementById('app');
|
|
||||||
const userName = kc.tokenParsed?.name || kc.tokenParsed?.preferred_username || 'User';
|
|
||||||
|
|
||||||
// Nav
|
|
||||||
const nav = document.createElement('nav');
|
|
||||||
nav.innerHTML = `
|
|
||||||
<span class="brand">Work Orders</span>
|
|
||||||
<span class="spacer"></span>
|
|
||||||
<span class="user-info">${userName}</span>
|
|
||||||
<button id="logout" style="background:transparent;border:1px solid rgba(255,255,255,.4);color:#fff;padding:.3rem .8rem;font-size:.85rem">Logout</button>`;
|
|
||||||
document.body.insertBefore(nav, app);
|
|
||||||
nav.querySelector('#logout').addEventListener('click', () => kc.logout());
|
|
||||||
|
|
||||||
showList();
|
|
||||||
|
|
||||||
app.addEventListener('wo:select', e => showDetail(e.detail.id));
|
|
||||||
app.addEventListener('wo:cancel', () => showList());
|
|
||||||
app.addEventListener('wo:saved', () => showList());
|
|
||||||
|
|
||||||
document.addEventListener('keydown', e => { if (e.key === 'Escape') showList(); });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function showList() {
|
// ── Main app ──────────────────────────────────────────────────────────────────
|
||||||
const app = document.getElementById('app');
|
function startApp() {
|
||||||
app.innerHTML = `
|
root.innerHTML = '<app-root></app-root>';
|
||||||
<div class="page-header">
|
const appRoot = root.querySelector('app-root');
|
||||||
<h1>Work Orders</h1>
|
|
||||||
<button id="new-wo">+ New Work Order</button>
|
|
||||||
</div>
|
|
||||||
<wo-list id="wo-list"></wo-list>`;
|
|
||||||
app.querySelector('#new-wo').addEventListener('click', showCreate);
|
|
||||||
}
|
|
||||||
|
|
||||||
function showCreate() {
|
router
|
||||||
const app = document.getElementById('app');
|
.on('/', () => appRoot.setPage('<wo-list></wo-list>'))
|
||||||
app.innerHTML = `
|
.on('/work-orders', () => appRoot.setPage('<wo-list></wo-list>'))
|
||||||
<div class="page-header"><h1>New Work Order</h1></div>
|
.on('/work-orders/new', () => appRoot.setPage('<wo-form></wo-form>'))
|
||||||
<div class="card"><wo-form></wo-form></div>`;
|
.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('/registry/people', () => appRoot.setPage('<people-list></people-list>'))
|
||||||
|
.on('/registry/vehicles', () => appRoot.setPage('<vehicle-list></vehicle-list>'))
|
||||||
|
.on('/registry/equipment', () => appRoot.setPage('<equipment-list></equipment-list>'))
|
||||||
|
.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('/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>'))
|
||||||
|
.start();
|
||||||
|
|
||||||
function showDetail(id) {
|
// Global navigation events from WO components
|
||||||
const app = document.getElementById('app');
|
window.addEventListener('wo:navigate', e => router.navigate(e.detail.path));
|
||||||
app.innerHTML = `
|
window.addEventListener('wo:toast', e => showToast(e.detail.message, e.detail.type));
|
||||||
<div class="page-header">
|
|
||||||
<h1>Edit Work Order</h1>
|
|
||||||
<button id="back" class="secondary">← Back</button>
|
|
||||||
</div>
|
|
||||||
<div class="card"><wo-form wo-id="${id}"></wo-form></div>`;
|
|
||||||
app.querySelector('#back').addEventListener('click', showList);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
const TABS = [
|
||||||
|
{ label: 'Dashboard', href: '/', icon: 'layout-dashboard' },
|
||||||
|
{ label: 'Work Orders', href: '/work-orders', icon: 'clipboard-list' },
|
||||||
|
{ label: 'People', href: '/registry/people', icon: 'users' },
|
||||||
|
{ label: 'Reports', href: '/reports', icon: 'bar-chart-2' },
|
||||||
|
];
|
||||||
|
|
||||||
|
class AppMobileNav extends HTMLElement {
|
||||||
|
connectedCallback() {
|
||||||
|
this.#render();
|
||||||
|
window.addEventListener('hashchange', () => this.#render());
|
||||||
|
}
|
||||||
|
|
||||||
|
#render() {
|
||||||
|
const path = decodeURIComponent(location.hash.slice(1)) || '/';
|
||||||
|
this.innerHTML = `
|
||||||
|
<style>
|
||||||
|
app-mobile-nav a {
|
||||||
|
flex: 1; display: flex; flex-direction: column; align-items: center;
|
||||||
|
justify-content: center; gap: .2rem; text-decoration: none;
|
||||||
|
color: var(--text-muted); font-size: .65rem; font-weight: 500;
|
||||||
|
padding: .4rem; position: relative; transition: color .15s;
|
||||||
|
}
|
||||||
|
app-mobile-nav a.active { color: var(--teal); }
|
||||||
|
app-mobile-nav a.active::after {
|
||||||
|
content: ''; position: absolute; bottom: 0; left: 25%; right: 25%;
|
||||||
|
height: 2px; background: var(--teal); border-radius: 1px;
|
||||||
|
}
|
||||||
|
app-mobile-nav a i { width: 20px; height: 20px; }
|
||||||
|
</style>
|
||||||
|
${TABS.map(t => {
|
||||||
|
const active = t.href === '/' ? path === '/' : path.startsWith(t.href);
|
||||||
|
return `<a href="#${t.href}" class="${active ? 'active' : ''}">
|
||||||
|
<i data-lucide="${t.icon}"></i>
|
||||||
|
<span>${t.label}</span>
|
||||||
|
</a>`;
|
||||||
|
}).join('')}`;
|
||||||
|
if (window.lucide) lucide.createIcons({ root: this });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
customElements.define('app-mobile-nav', AppMobileNav);
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
class AppRoot extends HTMLElement {
|
||||||
|
connectedCallback() {
|
||||||
|
this.innerHTML = `
|
||||||
|
<app-sidebar></app-sidebar>
|
||||||
|
<div class="app-shell">
|
||||||
|
<app-topbar></app-topbar>
|
||||||
|
<main id="main-content"></main>
|
||||||
|
</div>
|
||||||
|
<app-mobile-nav></app-mobile-nav>`;
|
||||||
|
|
||||||
|
// Apply saved collapse state
|
||||||
|
if (localStorage.getItem('sidebar-collapsed') === 'true') {
|
||||||
|
this.classList.add('collapsed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setPage(html) {
|
||||||
|
const main = document.getElementById('main-content');
|
||||||
|
if (main) {
|
||||||
|
main.innerHTML = html;
|
||||||
|
main.scrollTop = 0;
|
||||||
|
if (window.lucide) lucide.createIcons({ root: main });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
customElements.define('app-root', AppRoot);
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { getUser, clearToken } from '../../lib/auth.mjs';
|
||||||
|
import { router } from '../../lib/router.mjs';
|
||||||
|
|
||||||
|
const NAV = [
|
||||||
|
{ label: 'Dashboard', href: '/', icon: 'layout-dashboard', section: 'main' },
|
||||||
|
{ label: 'Work Orders', href: '/work-orders', icon: 'clipboard-list', section: 'main' },
|
||||||
|
{ label: 'People', href: '/registry/people', icon: 'users', section: 'resources' },
|
||||||
|
{ label: 'Vehicles', href: '/registry/vehicles', icon: 'truck', section: 'resources' },
|
||||||
|
{ label: 'Equipment', href: '/registry/equipment', icon: 'wrench', section: 'resources' },
|
||||||
|
{ label: 'Materials', href: '/registry/materials', icon: 'package', section: 'resources' },
|
||||||
|
{ label: 'Profiles', href: '/registry/profiles', icon: 'layout-template', section: 'resources' },
|
||||||
|
{ label: 'Reports', href: '/reports', icon: 'bar-chart-2', section: 'operations' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ADMIN_NAV = [
|
||||||
|
{ label: 'Users', href: '/users', icon: 'user-cog' },
|
||||||
|
{ label: 'Settings', href: '/settings', icon: 'settings' },
|
||||||
|
];
|
||||||
|
|
||||||
|
class AppSidebar extends HTMLElement {
|
||||||
|
#collapsed = localStorage.getItem('sidebar-collapsed') === 'true';
|
||||||
|
|
||||||
|
connectedCallback() { this.#render(); this.#listenRoute(); }
|
||||||
|
|
||||||
|
#listenRoute() {
|
||||||
|
window.addEventListener('hashchange', () => this.#updateActive());
|
||||||
|
}
|
||||||
|
|
||||||
|
#toggle() {
|
||||||
|
this.#collapsed = !this.#collapsed;
|
||||||
|
localStorage.setItem('sidebar-collapsed', this.#collapsed);
|
||||||
|
const appRoot = document.querySelector('app-root');
|
||||||
|
if (appRoot) appRoot.classList.toggle('collapsed', this.#collapsed);
|
||||||
|
this.#render();
|
||||||
|
}
|
||||||
|
|
||||||
|
#updateActive() {
|
||||||
|
const path = decodeURIComponent(location.hash.slice(1)) || '/';
|
||||||
|
this.querySelectorAll('.nav-item').forEach(el => {
|
||||||
|
const href = el.dataset.href;
|
||||||
|
const active = href === '/' ? path === '/' : path.startsWith(href);
|
||||||
|
el.classList.toggle('active', active);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#navItemHTML(item, currentPath) {
|
||||||
|
const active = item.href === '/' ? currentPath === '/' : currentPath.startsWith(item.href);
|
||||||
|
return `
|
||||||
|
<a class="nav-item${active ? ' active' : ''}" data-href="${item.href}" href="#${item.href}" title="${item.label}">
|
||||||
|
<i data-lucide="${item.icon}"></i>
|
||||||
|
<span class="label">${item.label}</span>
|
||||||
|
</a>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
#render() {
|
||||||
|
const user = getUser();
|
||||||
|
const path = decodeURIComponent(location.hash.slice(1)) || '/';
|
||||||
|
const c = this.#collapsed;
|
||||||
|
const appRoot = document.querySelector('app-root');
|
||||||
|
if (appRoot) appRoot.classList.toggle('collapsed', c);
|
||||||
|
|
||||||
|
const initials = (user?.displayName || user?.username || 'U')
|
||||||
|
.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase();
|
||||||
|
|
||||||
|
this.innerHTML = `
|
||||||
|
<style>
|
||||||
|
app-sidebar { overflow: hidden; }
|
||||||
|
.top { display: flex; align-items: center; justify-content: space-between; padding: 1rem; min-height: 56px; border-bottom: 1px solid rgba(255,255,255,.06); }
|
||||||
|
.brand { display: flex; align-items: center; gap: .6rem; text-decoration: none; }
|
||||||
|
.brand-icon { width: 28px; height: 28px; background: var(--teal); border-radius: 6px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||||||
|
.brand-icon svg { color: #fff; }
|
||||||
|
.brand-name { font-size: .9rem; font-weight: 700; color: #fff; white-space: nowrap; overflow: hidden; transition: opacity .2s; }
|
||||||
|
.collapsed .brand-name { opacity: 0; width: 0; }
|
||||||
|
.toggle-btn { background: none; border: none; cursor: pointer; color: rgba(255,255,255,.5); padding: .3rem; border-radius: 4px; display: flex; flex-shrink: 0; transition: color .15s; }
|
||||||
|
.toggle-btn:hover { color: #fff; }
|
||||||
|
.nav { flex: 1; overflow-y: auto; padding: .5rem 0; }
|
||||||
|
.section-label { padding: .5rem 1rem .25rem; font-size: .65rem; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; color: rgba(255,255,255,.3); white-space: nowrap; overflow: hidden; transition: opacity .2s; }
|
||||||
|
.collapsed .section-label { opacity: 0; }
|
||||||
|
.nav-item { display: flex; align-items: center; gap: .75rem; padding: .55rem 1rem; color: rgba(255,255,255,.7); text-decoration: none; font-size: .875rem; font-weight: 500; transition: background .15s, color .15s; cursor: pointer; border-left: 3px solid transparent; }
|
||||||
|
.nav-item:hover { background: var(--sidebar-hover); color: #fff; text-decoration: none; }
|
||||||
|
.nav-item.active { background: rgba(10,126,164,.2); color: #fff; border-left-color: var(--teal); }
|
||||||
|
.nav-item i { width: 18px; height: 18px; flex-shrink: 0; }
|
||||||
|
.nav-item .label { white-space: nowrap; overflow: hidden; transition: opacity .2s; }
|
||||||
|
.collapsed .nav-item .label { opacity: 0; width: 0; }
|
||||||
|
.collapsed .nav-item { justify-content: center; padding: .55rem; }
|
||||||
|
.divider { height: 1px; background: rgba(255,255,255,.06); margin: .5rem 0; }
|
||||||
|
.bottom { border-top: 1px solid rgba(255,255,255,.06); padding: .75rem 1rem; display: flex; align-items: center; gap: .6rem; }
|
||||||
|
.avatar { width: 32px; height: 32px; border-radius: 50%; background: var(--teal); color: #fff; display: flex; align-items: center; justify-content: center; font-size: .75rem; font-weight: 700; flex-shrink: 0; }
|
||||||
|
.user-info { flex: 1; overflow: hidden; transition: opacity .2s; }
|
||||||
|
.collapsed .user-info { opacity: 0; width: 0; }
|
||||||
|
.user-name { font-size: .813rem; font-weight: 600; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.user-role { font-size: .7rem; color: rgba(255,255,255,.45); white-space: nowrap; }
|
||||||
|
.logout-btn { background: none; border: none; cursor: pointer; color: rgba(255,255,255,.4); padding: .3rem; border-radius: 4px; display: flex; flex-shrink: 0; transition: color .15s; }
|
||||||
|
.logout-btn:hover { color: #fff; }
|
||||||
|
.collapsed .logout-btn { display: none; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="top ${c ? 'collapsed' : ''}">
|
||||||
|
<a class="brand" href="#/">
|
||||||
|
<div class="brand-icon"><i data-lucide="clipboard-list" style="width:16px;height:16px"></i></div>
|
||||||
|
<span class="brand-name">Work Orders</span>
|
||||||
|
</a>
|
||||||
|
<button class="toggle-btn" id="toggle-btn" title="Toggle sidebar">
|
||||||
|
<i data-lucide="${c ? 'chevrons-right' : 'chevrons-left'}" style="width:18px;height:18px"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="nav ${c ? 'collapsed' : ''}">
|
||||||
|
<div class="section-label">Main</div>
|
||||||
|
${NAV.filter(n => n.section === 'main').map(n => this.#navItemHTML(n, path)).join('')}
|
||||||
|
<div class="divider"></div>
|
||||||
|
<div class="section-label">Resources</div>
|
||||||
|
${NAV.filter(n => n.section === 'resources').map(n => this.#navItemHTML(n, path)).join('')}
|
||||||
|
<div class="divider"></div>
|
||||||
|
<div class="section-label">Operations</div>
|
||||||
|
${NAV.filter(n => n.section === 'operations').map(n => this.#navItemHTML(n, path)).join('')}
|
||||||
|
${user?.role === 'admin' ? `
|
||||||
|
<div class="divider"></div>
|
||||||
|
<div class="section-label">Admin</div>
|
||||||
|
${ADMIN_NAV.map(n => this.#navItemHTML(n, path)).join('')}` : ''}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="bottom ${c ? 'collapsed' : ''}">
|
||||||
|
<div class="avatar">${initials}</div>
|
||||||
|
<div class="user-info">
|
||||||
|
<div class="user-name">${user?.displayName || user?.username || 'User'}</div>
|
||||||
|
<div class="user-role">${user?.role || ''}</div>
|
||||||
|
</div>
|
||||||
|
<button class="logout-btn" id="logout-btn" title="Sign out">
|
||||||
|
<i data-lucide="log-out" style="width:16px;height:16px"></i>
|
||||||
|
</button>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
if (window.lucide) lucide.createIcons({ root: this });
|
||||||
|
this.querySelector('#toggle-btn')?.addEventListener('click', () => this.#toggle());
|
||||||
|
this.querySelector('#logout-btn')?.addEventListener('click', () => {
|
||||||
|
clearToken();
|
||||||
|
window.dispatchEvent(new CustomEvent('auth:logout'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
customElements.define('app-sidebar', AppSidebar);
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { getUser, clearToken } from '../../lib/auth.mjs';
|
||||||
|
|
||||||
|
const BREADCRUMBS = {
|
||||||
|
'/': ['Dashboard'],
|
||||||
|
'/work-orders': ['Work Orders'],
|
||||||
|
'/work-orders/new': ['Work Orders', 'New'],
|
||||||
|
'/registry/people': ['Resources', 'People'],
|
||||||
|
'/registry/vehicles': ['Resources', 'Vehicles'],
|
||||||
|
'/registry/equipment': ['Resources', 'Equipment'],
|
||||||
|
'/registry/materials': ['Resources', 'Materials'],
|
||||||
|
'/reports': ['Reports'],
|
||||||
|
'/users': ['Admin', 'Users'],
|
||||||
|
'/settings': ['Admin', 'Settings'],
|
||||||
|
};
|
||||||
|
|
||||||
|
class AppTopbar extends HTMLElement {
|
||||||
|
#menuOpen = false;
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
this.#render();
|
||||||
|
window.addEventListener('hashchange', () => this.#render());
|
||||||
|
document.addEventListener('click', e => {
|
||||||
|
if (!this.contains(e.target) && this.#menuOpen) {
|
||||||
|
this.#menuOpen = false;
|
||||||
|
this.#render();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#render() {
|
||||||
|
const user = getUser();
|
||||||
|
const path = decodeURIComponent(location.hash.slice(1)) || '/';
|
||||||
|
const crumb = BREADCRUMBS[path] || [path.split('/').filter(Boolean).map(s => s.replace(/-/g, ' ')).join(' › ')];
|
||||||
|
const initials = (user?.displayName || user?.username || 'U')
|
||||||
|
.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase();
|
||||||
|
|
||||||
|
this.innerHTML = `
|
||||||
|
<style>
|
||||||
|
app-topbar { position: relative; }
|
||||||
|
.breadcrumb { flex: 1; display: flex; align-items: center; gap: .4rem; font-size: .875rem; }
|
||||||
|
.crumb { color: var(--text-muted); }
|
||||||
|
.crumb:last-child { color: var(--text); font-weight: 600; }
|
||||||
|
.sep { color: var(--border); }
|
||||||
|
.right { display: flex; align-items: center; gap: .5rem; margin-left: auto; }
|
||||||
|
.avatar-btn { background: none; border: none; cursor: pointer; display: flex; align-items: center; gap: .5rem; padding: .3rem .5rem; border-radius: var(--radius); transition: background .15s; }
|
||||||
|
.avatar-btn:hover { background: var(--surface-2); }
|
||||||
|
.avatar { width: 30px; height: 30px; border-radius: 50%; background: var(--teal); color: #fff; display: flex; align-items: center; justify-content: center; font-size: .75rem; font-weight: 700; flex-shrink: 0; }
|
||||||
|
.user-name { font-size: .813rem; font-weight: 500; color: var(--text); }
|
||||||
|
.dropdown { position: absolute; top: calc(100% + .25rem); right: 1.25rem; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow-md); min-width: 180px; z-index: 100; overflow: hidden; }
|
||||||
|
.dd-item { display: flex; align-items: center; gap: .6rem; padding: .65rem 1rem; font-size: .875rem; color: var(--text); cursor: pointer; border: none; background: none; width: 100%; text-align: left; transition: background .15s; }
|
||||||
|
.dd-item:hover { background: var(--surface-2); }
|
||||||
|
.dd-item.danger { color: var(--danger); }
|
||||||
|
.dd-divider { height: 1px; background: var(--border); margin: .25rem 0; }
|
||||||
|
.mobile-menu-btn { display: none; background: none; border: none; cursor: pointer; color: var(--text); padding: .4rem; }
|
||||||
|
@media (max-width: 768px) { .mobile-menu-btn { display: flex; } .breadcrumb { font-size: .813rem; } }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<button class="mobile-menu-btn" id="mobile-menu">
|
||||||
|
<i data-lucide="menu" style="width:22px;height:22px"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<nav class="breadcrumb">
|
||||||
|
${crumb.map((c, i) => `
|
||||||
|
<span class="crumb">${c}</span>
|
||||||
|
${i < crumb.length - 1 ? '<span class="sep">/</span>' : ''}
|
||||||
|
`).join('')}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="right">
|
||||||
|
<button class="avatar-btn" id="user-menu-btn">
|
||||||
|
<div class="avatar">${initials}</div>
|
||||||
|
<span class="user-name">${user?.displayName || user?.username || ''}</span>
|
||||||
|
<i data-lucide="chevron-down" style="width:14px;height:14px;color:var(--text-muted)"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${this.#menuOpen ? `
|
||||||
|
<div class="dropdown" id="dropdown">
|
||||||
|
<button class="dd-item">
|
||||||
|
<i data-lucide="user" style="width:15px;height:15px"></i> My Profile
|
||||||
|
</button>
|
||||||
|
<div class="dd-divider"></div>
|
||||||
|
<button class="dd-item danger" id="dd-logout">
|
||||||
|
<i data-lucide="log-out" style="width:15px;height:15px"></i> Sign Out
|
||||||
|
</button>
|
||||||
|
</div>` : ''}`;
|
||||||
|
|
||||||
|
if (window.lucide) lucide.createIcons({ root: this });
|
||||||
|
|
||||||
|
this.querySelector('#user-menu-btn')?.addEventListener('click', e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
this.#menuOpen = !this.#menuOpen;
|
||||||
|
this.#render();
|
||||||
|
});
|
||||||
|
this.querySelector('#dd-logout')?.addEventListener('click', () => {
|
||||||
|
clearToken();
|
||||||
|
window.dispatchEvent(new CustomEvent('auth:logout'));
|
||||||
|
});
|
||||||
|
this.querySelector('#mobile-menu')?.addEventListener('click', () => {
|
||||||
|
window.dispatchEvent(new CustomEvent('sidebar:toggle'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
customElements.define('app-topbar', AppTopbar);
|
||||||
@@ -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({ root: d });
|
||||||
|
d.querySelector('#dlg-close').addEventListener('click', () => d.close());
|
||||||
|
d.querySelector('#dlg-cancel').addEventListener('click', () => d.close());
|
||||||
|
|
||||||
|
d.querySelector('#dlg-save').addEventListener('click', async () => {
|
||||||
|
const name = d.querySelector('#eq-name').value.trim();
|
||||||
|
const errEl = d.querySelector('#form-error');
|
||||||
|
if (!name) { errEl.textContent = 'Name is required'; d.querySelector('#eq-name').focus(); return; }
|
||||||
|
errEl.textContent = '';
|
||||||
|
|
||||||
|
const saveBtn = d.querySelector('#dlg-save');
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
saveBtn.textContent = 'Saving…';
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
name,
|
||||||
|
asset_tag: d.querySelector('#eq-tag').value.trim(),
|
||||||
|
category: d.querySelector('#eq-cat').value,
|
||||||
|
active: isEdit ? d.querySelector('#eq-active').checked : true,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isEdit) await api.put(`/registry/equipment/${equipment.id}`, payload);
|
||||||
|
else await api.post('/registry/equipment', payload);
|
||||||
|
d.close();
|
||||||
|
window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: isEdit ? 'Equipment updated' : 'Equipment added', type: 'success' } }));
|
||||||
|
this.#onSave?.();
|
||||||
|
} catch (err) {
|
||||||
|
errEl.textContent = err.message;
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
saveBtn.textContent = isEdit ? 'Save Changes' : 'Add Equipment';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
d.showModal();
|
||||||
|
d.querySelector('#eq-name').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
#esc(s) { return (s || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('equipment-form', EquipmentForm);
|
||||||
@@ -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({ root: s });
|
||||||
|
|
||||||
|
const searchEl = s.querySelector('#search');
|
||||||
|
let debounce;
|
||||||
|
searchEl.addEventListener('input', () => {
|
||||||
|
clearTimeout(debounce);
|
||||||
|
debounce = setTimeout(() => { this.#search = searchEl.value; this.#load(); }, 350);
|
||||||
|
});
|
||||||
|
|
||||||
|
s.querySelector('#new-btn').addEventListener('click', () =>
|
||||||
|
s.querySelector('#equipment-form').open(null, () => this.#load()));
|
||||||
|
|
||||||
|
s.querySelectorAll('[data-edit]').forEach(btn => {
|
||||||
|
const eq = this.#items.find(x => x.id === +btn.dataset.edit);
|
||||||
|
btn.addEventListener('click', () => s.querySelector('#equipment-form').open(eq, () => this.#load()));
|
||||||
|
});
|
||||||
|
|
||||||
|
s.querySelectorAll('[data-deactivate]').forEach(btn =>
|
||||||
|
btn.addEventListener('click', () => this.#deactivate(+btn.dataset.deactivate)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#esc(s) { return (s || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('equipment-list', EquipmentList);
|
||||||
@@ -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({ root: d });
|
||||||
|
d.querySelector('#dlg-close').addEventListener('click', () => d.close());
|
||||||
|
d.querySelector('#dlg-cancel').addEventListener('click', () => d.close());
|
||||||
|
|
||||||
|
d.querySelector('#dlg-save').addEventListener('click', async () => {
|
||||||
|
const name = d.querySelector('#m-name').value.trim();
|
||||||
|
const errEl = d.querySelector('#form-error');
|
||||||
|
if (!name) { errEl.textContent = 'Name is required'; d.querySelector('#m-name').focus(); return; }
|
||||||
|
errEl.textContent = '';
|
||||||
|
|
||||||
|
const saveBtn = d.querySelector('#dlg-save');
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
saveBtn.textContent = 'Saving…';
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
name,
|
||||||
|
unit: d.querySelector('#m-unit').value,
|
||||||
|
part_number: d.querySelector('#m-part').value.trim(),
|
||||||
|
active: isEdit ? d.querySelector('#m-active').checked : true,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isEdit) await api.put(`/registry/materials/${material.id}`, payload);
|
||||||
|
else await api.post('/registry/materials', payload);
|
||||||
|
d.close();
|
||||||
|
window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: isEdit ? 'Material updated' : 'Material added', type: 'success' } }));
|
||||||
|
this.#onSave?.();
|
||||||
|
} catch (err) {
|
||||||
|
errEl.textContent = err.message;
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
saveBtn.textContent = isEdit ? 'Save Changes' : 'Add Material';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
d.showModal();
|
||||||
|
d.querySelector('#m-name').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
#esc(s) { return (s || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('material-form', MaterialForm);
|
||||||
@@ -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({ root: s });
|
||||||
|
|
||||||
|
const searchEl = s.querySelector('#search');
|
||||||
|
let debounce;
|
||||||
|
searchEl.addEventListener('input', () => {
|
||||||
|
clearTimeout(debounce);
|
||||||
|
debounce = setTimeout(() => { this.#search = searchEl.value; this.#load(); }, 350);
|
||||||
|
});
|
||||||
|
|
||||||
|
s.querySelector('#new-btn').addEventListener('click', () =>
|
||||||
|
s.querySelector('#material-form').open(null, () => this.#load()));
|
||||||
|
|
||||||
|
s.querySelectorAll('[data-edit]').forEach(btn => {
|
||||||
|
const m = this.#items.find(x => x.id === +btn.dataset.edit);
|
||||||
|
btn.addEventListener('click', () => s.querySelector('#material-form').open(m, () => this.#load()));
|
||||||
|
});
|
||||||
|
|
||||||
|
s.querySelectorAll('[data-deactivate]').forEach(btn =>
|
||||||
|
btn.addEventListener('click', () => this.#deactivate(+btn.dataset.deactivate)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#esc(s) { return (s || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('material-list', MaterialList);
|
||||||
@@ -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({ root: d });
|
||||||
|
|
||||||
|
d.querySelector('#dlg-close').addEventListener('click', () => d.close());
|
||||||
|
d.querySelector('#dlg-cancel').addEventListener('click', () => d.close());
|
||||||
|
|
||||||
|
d.querySelector('#dlg-save').addEventListener('click', async () => {
|
||||||
|
const name = d.querySelector('#p-name').value.trim();
|
||||||
|
const errEl = d.querySelector('#form-error');
|
||||||
|
if (!name) { errEl.textContent = 'Name is required'; d.querySelector('#p-name').focus(); return; }
|
||||||
|
errEl.textContent = '';
|
||||||
|
|
||||||
|
const saveBtn = d.querySelector('#dlg-save');
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
saveBtn.textContent = 'Saving…';
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
name,
|
||||||
|
role: d.querySelector('#p-role').value.trim(),
|
||||||
|
email: d.querySelector('#p-email').value.trim(),
|
||||||
|
phone: d.querySelector('#p-phone').value.trim(),
|
||||||
|
active: isEdit ? d.querySelector('#p-active').checked : true,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isEdit) await api.put(`/registry/people/${person.id}`, payload);
|
||||||
|
else await api.post('/registry/people', payload);
|
||||||
|
d.close();
|
||||||
|
window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: isEdit ? 'Person updated' : 'Person added', type: 'success' } }));
|
||||||
|
this.#onSave?.();
|
||||||
|
} catch (err) {
|
||||||
|
errEl.textContent = err.message;
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
saveBtn.textContent = isEdit ? 'Save Changes' : 'Add Person';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
d.showModal();
|
||||||
|
d.querySelector('#p-name').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
#esc(s) { return (s || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('people-form', PeopleForm);
|
||||||
@@ -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({ root: s });
|
||||||
|
|
||||||
|
const searchEl = s.querySelector('#search');
|
||||||
|
let debounce;
|
||||||
|
searchEl.addEventListener('input', () => {
|
||||||
|
clearTimeout(debounce);
|
||||||
|
debounce = setTimeout(() => { this.#search = searchEl.value; this.#load(); }, 350);
|
||||||
|
});
|
||||||
|
|
||||||
|
s.querySelector('#new-btn').addEventListener('click', () => {
|
||||||
|
s.querySelector('#people-form').open(null, () => this.#load());
|
||||||
|
});
|
||||||
|
|
||||||
|
s.querySelectorAll('[data-edit]').forEach(btn => {
|
||||||
|
const p = this.#people.find(x => x.id === +btn.dataset.edit);
|
||||||
|
btn.addEventListener('click', () => s.querySelector('#people-form').open(p, () => this.#load()));
|
||||||
|
});
|
||||||
|
|
||||||
|
s.querySelectorAll('[data-deactivate]').forEach(btn =>
|
||||||
|
btn.addEventListener('click', () => this.#deactivate(+btn.dataset.deactivate)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#esc(s) { return (s || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('people-list', PeopleList);
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
import { api } from '../../lib/api.mjs';
|
||||||
|
|
||||||
|
const PRIORITIES = ['low', 'normal', 'high', 'urgent'];
|
||||||
|
|
||||||
|
const STEP_TYPES = [
|
||||||
|
{ value: 'work_step', label: 'Work Step', icon: 'check-square', color: '#0A7EA4', hint: '' },
|
||||||
|
{ value: 'photo', label: 'Photo', icon: 'camera', color: '#8B5CF6', hint: 'Requires a photo to complete' },
|
||||||
|
{ value: 'inspection', label: 'Inspect', icon: 'clipboard-check', color: '#E07B39', hint: 'Pass / Fail / N/A' },
|
||||||
|
{ value: 'note', label: 'Note', icon: 'file-text', color: '#64748B', hint: 'Free-text entry' },
|
||||||
|
];
|
||||||
|
|
||||||
|
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:640px; 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 */
|
||||||
|
.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:.45rem .6rem; border-left:3px solid var(--border); }
|
||||||
|
.step-row[data-type="photo"] { border-left-color:#8B5CF6; }
|
||||||
|
.step-row[data-type="inspection"] { border-left-color:#E07B39; }
|
||||||
|
.step-row[data-type="note"] { border-left-color:#64748B; }
|
||||||
|
.step-row[data-type="work_step"] { border-left-color:var(--teal); }
|
||||||
|
.step-order { font-size:.75rem; font-weight:700; color:var(--text-muted); min-width:1.5rem; text-align:center; flex-shrink:0; }
|
||||||
|
.step-type-pill { font-size:.65rem; font-weight:700; text-transform:uppercase; letter-spacing:.04em; padding:.15rem .4rem; border-radius:99px; white-space:nowrap; flex-shrink:0; cursor:pointer; }
|
||||||
|
.step-title-input { flex:1; border:none; background:transparent; font-size:.875rem; color:var(--text); padding:.15rem .3rem; border-radius:4px; min-width:0; }
|
||||||
|
.step-title-input:focus { outline:1px solid var(--teal); background:var(--surface); }
|
||||||
|
.step-cfg-summary { font-size:.7rem; color:var(--text-muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:120px; }
|
||||||
|
.step-req { font-size:.7rem; color:var(--text-muted); white-space:nowrap; display:flex; align-items:center; gap:.25rem; flex-shrink:0; }
|
||||||
|
.step-del { background:none; border:none; cursor:pointer; color:var(--text-muted); padding:.2rem; display:flex; border-radius:4px; flex-shrink:0; }
|
||||||
|
.step-del:hover { color:var(--danger); background:#FEF2F2; }
|
||||||
|
/* Add step area */
|
||||||
|
.add-step-area { border:1px dashed var(--border); border-radius:var(--radius); padding:.875rem; display:flex; flex-direction:column; gap:.6rem; }
|
||||||
|
.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); }
|
||||||
|
/* Type picker */
|
||||||
|
.type-picker { display:flex; gap:.35rem; flex-wrap:wrap; }
|
||||||
|
.type-btn { display:flex; align-items:center; gap:.3rem; border:1px solid var(--border); border-radius:var(--radius-sm); padding:.3rem .65rem; font-size:.75rem; font-weight:600; cursor:pointer; background:var(--surface); color:var(--text-muted); transition:all .15s; }
|
||||||
|
.type-btn:hover { border-color:var(--teal); color:var(--teal); }
|
||||||
|
.type-btn.active { border-color:var(--selected-color, var(--teal)); background:color-mix(in srgb, var(--selected-color, var(--teal)) 10%, transparent); color:var(--selected-color, var(--teal)); }
|
||||||
|
.type-btn i { width:13px; height:13px; }
|
||||||
|
/* Config fields */
|
||||||
|
.cfg-fields { display:flex; flex-direction:column; gap:.45rem; padding:.5rem .6rem; background:var(--surface-2); border-radius:var(--radius-sm); }
|
||||||
|
.cfg-field-label { font-size:.75rem; font-weight:600; color:var(--text-muted); display:block; margin-bottom:.2rem; }
|
||||||
|
.cfg-input { width:100%; border:1px solid var(--border); border-radius:var(--radius-sm); padding:.4rem .6rem; font-size:.813rem; background:var(--surface); color:var(--text); box-sizing:border-box; }
|
||||||
|
.cfg-input:focus { outline:none; border-color:var(--teal); }
|
||||||
|
.cfg-select { width:100%; border:1px solid var(--border); border-radius:var(--radius-sm); padding:.4rem .6rem; font-size:.813rem; background:var(--surface); color:var(--text); }
|
||||||
|
.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; flex-shrink:0; }
|
||||||
|
.btn-add-step:hover { opacity:.88; }
|
||||||
|
/* Footer */
|
||||||
|
.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-area">
|
||||||
|
<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="type-picker" id="type-picker">
|
||||||
|
${STEP_TYPES.map(t => `
|
||||||
|
<button class="type-btn${t.value === 'work_step' ? ' active' : ''}" data-type="${t.value}"
|
||||||
|
style="--selected-color:${t.color}" title="${t.hint}">
|
||||||
|
<i data-lucide="${t.icon}" style="width:13px;height:13px"></i>
|
||||||
|
${t.label}
|
||||||
|
</button>`).join('')}
|
||||||
|
</div>
|
||||||
|
<div id="cfg-fields" style="display:none"></div>
|
||||||
|
</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({ root: d });
|
||||||
|
|
||||||
|
const profileId = profile?.id || null;
|
||||||
|
let pendingSteps = (profile?.steps || []).map(s => ({ ...s }));
|
||||||
|
let selectedType = 'work_step';
|
||||||
|
|
||||||
|
// ── Step list rendering ───────────────────────────────────────────────
|
||||||
|
const renderSteps = () => {
|
||||||
|
const list = d.querySelector('#step-list');
|
||||||
|
list.innerHTML = pendingSteps.length === 0
|
||||||
|
? '<div class="empty-steps">No steps yet — add steps below</div>'
|
||||||
|
: pendingSteps.map((s, i) => this.#stepRowHTML(s, i)).join('');
|
||||||
|
if (window.lucide) lucide.createIcons({ root: list });
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
// ── Type picker ───────────────────────────────────────────────────────
|
||||||
|
const cfgDiv = d.querySelector('#cfg-fields');
|
||||||
|
|
||||||
|
const renderCfgFields = (type) => {
|
||||||
|
if (type === 'work_step') {
|
||||||
|
cfgDiv.style.display = 'none';
|
||||||
|
cfgDiv.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cfgDiv.style.display = 'block';
|
||||||
|
if (type === 'photo') {
|
||||||
|
cfgDiv.innerHTML = `
|
||||||
|
<div class="cfg-fields">
|
||||||
|
<div>
|
||||||
|
<label class="cfg-field-label">Phase</label>
|
||||||
|
<select class="cfg-select" id="cfg-phase">
|
||||||
|
<option value="before">Before</option>
|
||||||
|
<option value="during">During</option>
|
||||||
|
<option value="after">After</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="cfg-field-label">Caption prompt (shown to field tech)</label>
|
||||||
|
<input class="cfg-input" id="cfg-caption" type="text" placeholder="e.g. Photo of existing equipment before work begins" maxlength="200">
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
} else if (type === 'inspection') {
|
||||||
|
cfgDiv.innerHTML = `
|
||||||
|
<div class="cfg-fields">
|
||||||
|
<div>
|
||||||
|
<label class="cfg-field-label">Criteria (what to inspect)</label>
|
||||||
|
<input class="cfg-input" id="cfg-criteria" type="text" placeholder="e.g. All cable slack coiled and secured per spec" maxlength="300">
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
} else if (type === 'note') {
|
||||||
|
cfgDiv.innerHTML = `
|
||||||
|
<div class="cfg-fields">
|
||||||
|
<div>
|
||||||
|
<label class="cfg-field-label">Prompt label</label>
|
||||||
|
<input class="cfg-input" id="cfg-prompt" type="text" placeholder="e.g. Record any site hazards or access issues" maxlength="200">
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCfgJSON = (type) => {
|
||||||
|
if (type === 'photo') {
|
||||||
|
const phase = cfgDiv.querySelector('#cfg-phase')?.value || 'during';
|
||||||
|
const caption = cfgDiv.querySelector('#cfg-caption')?.value.trim() || '';
|
||||||
|
return JSON.stringify({ phase, caption_prompt: caption });
|
||||||
|
}
|
||||||
|
if (type === 'inspection') {
|
||||||
|
const criteria = cfgDiv.querySelector('#cfg-criteria')?.value.trim() || '';
|
||||||
|
return criteria ? JSON.stringify({ criteria }) : '';
|
||||||
|
}
|
||||||
|
if (type === 'note') {
|
||||||
|
const prompt = cfgDiv.querySelector('#cfg-prompt')?.value.trim() || '';
|
||||||
|
return prompt ? JSON.stringify({ prompt }) : '';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
d.querySelectorAll('.type-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
selectedType = btn.dataset.type;
|
||||||
|
d.querySelectorAll('.type-btn').forEach(b => b.classList.toggle('active', b === btn));
|
||||||
|
renderCfgFields(selectedType);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Add step ──────────────────────────────────────────────────────────
|
||||||
|
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,
|
||||||
|
step_type: selectedType,
|
||||||
|
type_config: getCfgJSON(selectedType),
|
||||||
|
});
|
||||||
|
inp.value = '';
|
||||||
|
// Reset type picker to work_step after adding
|
||||||
|
selectedType = 'work_step';
|
||||||
|
d.querySelectorAll('.type-btn').forEach(b => b.classList.toggle('active', b.dataset.type === 'work_step'));
|
||||||
|
renderCfgFields('work_step');
|
||||||
|
renderSteps();
|
||||||
|
});
|
||||||
|
d.querySelector('#new-step-title').addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Enter') { e.preventDefault(); d.querySelector('#add-step-btn').click(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Dialog controls ───────────────────────────────────────────────────
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedId = savedProfile.id;
|
||||||
|
const origIds = new Set((profile?.steps || []).map(s => s.id));
|
||||||
|
|
||||||
|
// Delete removed steps
|
||||||
|
for (const orig of (profile?.steps || [])) {
|
||||||
|
if (!pendingSteps.find(s => s.id === orig.id)) {
|
||||||
|
await api.delete(`/profiles/${savedId}/steps/${orig.id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Update existing / create new
|
||||||
|
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,
|
||||||
|
step_type: s.step_type || 'work_step',
|
||||||
|
type_config: s.type_config || '',
|
||||||
|
};
|
||||||
|
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) {
|
||||||
|
const type = s.step_type || 'work_step';
|
||||||
|
const tdef = { work_step: { color: '#0A7EA4', label: 'Step' }, photo: { color: '#8B5CF6', label: 'Photo' }, inspection: { color: '#E07B39', label: 'Inspect' }, note: { color: '#64748B', label: 'Note' } };
|
||||||
|
const td = tdef[type] || tdef.work_step;
|
||||||
|
const cfg = this.#parseCfg(s.type_config);
|
||||||
|
const cfgSummary = type === 'photo' ? (cfg.phase ? cfg.phase + (cfg.caption_prompt ? ' · ' + cfg.caption_prompt : '') : '')
|
||||||
|
: type === 'inspection' ? (cfg.criteria || '')
|
||||||
|
: type === 'note' ? (cfg.prompt || '')
|
||||||
|
: '';
|
||||||
|
return `
|
||||||
|
<div class="step-row" data-type="${type}">
|
||||||
|
<span class="step-order">${i + 1}</span>
|
||||||
|
<span class="step-type-pill" style="background:color-mix(in srgb,${td.color} 15%,transparent);color:${td.color}">${td.label}</span>
|
||||||
|
<input class="step-title-input" value="${this.#esc(s.title)}" placeholder="Step title…">
|
||||||
|
${cfgSummary ? `<span class="step-cfg-summary" title="${this.#esc(cfgSummary)}">${this.#esc(cfgSummary)}</span>` : ''}
|
||||||
|
<label class="step-req">
|
||||||
|
<input type="checkbox" class="step-req-check" ${s.required !== false ? 'checked' : ''}> Req
|
||||||
|
</label>
|
||||||
|
<button class="step-del" title="Remove step"><i data-lucide="x" style="width:12px;height:12px"></i></button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
#parseCfg(str) { try { return JSON.parse(str || '{}'); } catch { return {}; } }
|
||||||
|
#esc(s) { return (s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('profile-form', ProfileForm);
|
||||||
@@ -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({ root: s });
|
||||||
|
|
||||||
|
const searchEl = s.querySelector('#search');
|
||||||
|
let debounce;
|
||||||
|
searchEl.addEventListener('input', () => {
|
||||||
|
clearTimeout(debounce);
|
||||||
|
debounce = setTimeout(() => { this.#search = searchEl.value; this.#load(); }, 350);
|
||||||
|
});
|
||||||
|
|
||||||
|
s.querySelector('#new-btn').addEventListener('click', () =>
|
||||||
|
s.querySelector('#profile-form').open(null, () => this.#load()));
|
||||||
|
|
||||||
|
s.querySelectorAll('[data-edit]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const id = +btn.dataset.edit;
|
||||||
|
try {
|
||||||
|
const full = await api.get(`/profiles/${id}`);
|
||||||
|
s.querySelector('#profile-form').open(full, () => this.#load());
|
||||||
|
} catch (err) {
|
||||||
|
window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: err.message, type: 'error' } }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
s.querySelectorAll('[data-deactivate]').forEach(btn =>
|
||||||
|
btn.addEventListener('click', () => this.#deactivate(+btn.dataset.deactivate)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#esc(s) { return (s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('profile-list', ProfileList);
|
||||||
@@ -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({ root: d });
|
||||||
|
d.querySelector('#dlg-close').addEventListener('click', () => d.close());
|
||||||
|
d.querySelector('#dlg-cancel').addEventListener('click', () => d.close());
|
||||||
|
|
||||||
|
d.querySelector('#dlg-save').addEventListener('click', async () => {
|
||||||
|
const unit = d.querySelector('#v-unit').value.trim();
|
||||||
|
const errEl = d.querySelector('#form-error');
|
||||||
|
if (!unit) { errEl.textContent = 'Unit number is required'; d.querySelector('#v-unit').focus(); return; }
|
||||||
|
errEl.textContent = '';
|
||||||
|
|
||||||
|
const saveBtn = d.querySelector('#dlg-save');
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
saveBtn.textContent = 'Saving…';
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
unit_number: unit,
|
||||||
|
description: d.querySelector('#v-desc').value.trim(),
|
||||||
|
vehicle_type: d.querySelector('#v-type').value,
|
||||||
|
active: isEdit ? d.querySelector('#v-active').checked : true,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isEdit) await api.put(`/registry/vehicles/${vehicle.id}`, payload);
|
||||||
|
else await api.post('/registry/vehicles', payload);
|
||||||
|
d.close();
|
||||||
|
window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: isEdit ? 'Vehicle updated' : 'Vehicle added', type: 'success' } }));
|
||||||
|
this.#onSave?.();
|
||||||
|
} catch (err) {
|
||||||
|
errEl.textContent = err.message;
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
saveBtn.textContent = isEdit ? 'Save Changes' : 'Add Vehicle';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
d.showModal();
|
||||||
|
d.querySelector('#v-unit').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
#esc(s) { return (s || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('vehicle-form', VehicleForm);
|
||||||
@@ -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({ root: s });
|
||||||
|
|
||||||
|
const searchEl = s.querySelector('#search');
|
||||||
|
let debounce;
|
||||||
|
searchEl.addEventListener('input', () => {
|
||||||
|
clearTimeout(debounce);
|
||||||
|
debounce = setTimeout(() => { this.#search = searchEl.value; this.#load(); }, 350);
|
||||||
|
});
|
||||||
|
|
||||||
|
s.querySelector('#new-btn').addEventListener('click', () =>
|
||||||
|
s.querySelector('#vehicle-form').open(null, () => this.#load()));
|
||||||
|
|
||||||
|
s.querySelectorAll('[data-edit]').forEach(btn => {
|
||||||
|
const v = this.#vehicles.find(x => x.id === +btn.dataset.edit);
|
||||||
|
btn.addEventListener('click', () => s.querySelector('#vehicle-form').open(v, () => this.#load()));
|
||||||
|
});
|
||||||
|
|
||||||
|
s.querySelectorAll('[data-deactivate]').forEach(btn =>
|
||||||
|
btn.addEventListener('click', () => this.#deactivate(+btn.dataset.deactivate)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#esc(s) { return (s || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('vehicle-list', VehicleList);
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
const STATUS_LABELS = {
|
||||||
|
draft: 'Draft', assigned: 'Assigned', scheduled: 'Scheduled',
|
||||||
|
in_progress: 'In Progress', pending_review: 'Pending Review', closed: 'Closed',
|
||||||
|
};
|
||||||
|
const PRIORITY_LABELS = { low: 'Low', normal: 'Normal', high: 'High', urgent: 'Urgent' };
|
||||||
|
|
||||||
|
const CSS = `
|
||||||
|
:host { display: inline-flex; }
|
||||||
|
.badge {
|
||||||
|
display: inline-flex; align-items: center; gap: .35rem;
|
||||||
|
padding: .2rem .6rem; border-radius: 999px;
|
||||||
|
font-size: .7rem; font-weight: 700; text-transform: uppercase;
|
||||||
|
letter-spacing: .04em; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
|
||||||
|
.s-draft { background:var(--status-draft-bg); color:var(--status-draft); }
|
||||||
|
.s-assigned { background:var(--status-assigned-bg); color:var(--status-assigned); }
|
||||||
|
.s-scheduled { background:var(--status-scheduled-bg); color:var(--status-scheduled); }
|
||||||
|
.s-in_progress { background:var(--status-in_progress-bg); color:var(--status-in_progress); }
|
||||||
|
.s-pending_review { background:var(--status-pending_review-bg); color:var(--status-pending_review); }
|
||||||
|
.s-closed { background:var(--status-closed-bg); color:var(--status-closed); }
|
||||||
|
.p-low { background:#F1F5F9; color:var(--priority-low); }
|
||||||
|
.p-normal { background:#E0F2FE; color:var(--priority-normal); }
|
||||||
|
.p-high { background:#FFF7ED; color:var(--priority-high); }
|
||||||
|
.p-urgent { background:#FEF2F2; color:var(--priority-urgent); }
|
||||||
|
`;
|
||||||
|
|
||||||
|
class UiBadge extends HTMLElement {
|
||||||
|
static get observedAttributes() { return ['type', 'value']; }
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
if (!this.shadowRoot) {
|
||||||
|
this.attachShadow({ mode: 'open' });
|
||||||
|
this.shadowRoot.innerHTML = `<style>${CSS}</style><span class="badge"></span>`;
|
||||||
|
}
|
||||||
|
this.#update();
|
||||||
|
}
|
||||||
|
|
||||||
|
attributeChangedCallback() {
|
||||||
|
if (this.shadowRoot) this.#update();
|
||||||
|
}
|
||||||
|
|
||||||
|
#update() {
|
||||||
|
const type = this.getAttribute('type') || 'status';
|
||||||
|
const value = this.getAttribute('value') || '';
|
||||||
|
const label = type === 'priority'
|
||||||
|
? (PRIORITY_LABELS[value] || value)
|
||||||
|
: (STATUS_LABELS[value] || value.replace('_', ' '));
|
||||||
|
const span = this.shadowRoot.querySelector('.badge');
|
||||||
|
span.className = `badge ${type === 'priority' ? 'p' : 's'}-${value}`;
|
||||||
|
span.innerHTML = `<span class="dot" style="background:currentColor"></span>${label}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('ui-badge', UiBadge);
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
class UiButton extends HTMLElement {
|
||||||
|
static get observedAttributes() { return ['variant', 'size', 'loading', 'disabled']; }
|
||||||
|
connectedCallback() { this.#render(); }
|
||||||
|
attributeChangedCallback() { this.#render(); }
|
||||||
|
|
||||||
|
#render() {
|
||||||
|
const variant = this.getAttribute('variant') || 'primary';
|
||||||
|
const size = this.getAttribute('size') || 'md';
|
||||||
|
const loading = this.hasAttribute('loading');
|
||||||
|
const disabled = this.hasAttribute('disabled') || loading;
|
||||||
|
const pad = { sm: '.35rem .75rem', md: '.5rem 1rem', lg: '.65rem 1.25rem' }[size] || '.5rem 1rem';
|
||||||
|
const fs = { sm: '.813rem', md: '.875rem', lg: '1rem' }[size] || '.875rem';
|
||||||
|
|
||||||
|
if (!this.shadowRoot) this.attachShadow({ mode: 'open' });
|
||||||
|
this.shadowRoot.innerHTML = `
|
||||||
|
<style>
|
||||||
|
:host { display: inline-flex; }
|
||||||
|
button {
|
||||||
|
display: inline-flex; align-items: center; gap: .4rem;
|
||||||
|
padding: ${pad}; border: none; border-radius: var(--radius);
|
||||||
|
font-size: ${fs}; font-weight: 600; cursor: pointer;
|
||||||
|
transition: opacity .15s, background .15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
button:disabled { opacity: .5; cursor: not-allowed; }
|
||||||
|
.primary { background: var(--teal); color: #fff; }
|
||||||
|
.primary:hover:not(:disabled) { background: var(--teal-dk); }
|
||||||
|
.ghost { background: transparent; border: 1px solid var(--border); color: var(--text); }
|
||||||
|
.ghost:hover:not(:disabled) { background: var(--surface-2); }
|
||||||
|
.danger { background: var(--danger); color: #fff; }
|
||||||
|
.danger:hover:not(:disabled) { opacity: .88; }
|
||||||
|
.icon { background: transparent; border: none; color: var(--text-muted); padding: .4rem; border-radius: var(--radius-sm); }
|
||||||
|
.icon:hover:not(:disabled) { background: var(--surface-2); color: var(--text); }
|
||||||
|
.ring {
|
||||||
|
width: 14px; height: 14px; border: 2px solid rgba(255,255,255,.4);
|
||||||
|
border-top-color: #fff; border-radius: 50%;
|
||||||
|
animation: spin .6s linear infinite; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
</style>
|
||||||
|
<button class="${variant}" ${disabled ? 'disabled' : ''}>
|
||||||
|
${loading ? '<div class="ring"></div>' : ''}
|
||||||
|
<slot></slot>
|
||||||
|
</button>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
customElements.define('ui-button', UiButton);
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
class UiDialog extends HTMLElement {
|
||||||
|
connectedCallback() {
|
||||||
|
this.attachShadow({ mode: 'open' });
|
||||||
|
this.shadowRoot.innerHTML = `
|
||||||
|
<style>
|
||||||
|
:host { display: contents; }
|
||||||
|
dialog {
|
||||||
|
border: none; border-radius: var(--radius-lg); padding: 0;
|
||||||
|
background: var(--surface); color: var(--text);
|
||||||
|
box-shadow: var(--shadow-lg); max-height: 90vh; overflow: hidden;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
}
|
||||||
|
dialog[open] { display: flex; }
|
||||||
|
dialog::backdrop { background: rgba(0,0,0,.45); backdrop-filter: blur(2px); }
|
||||||
|
.size-sm { width: min(400px, 95vw); }
|
||||||
|
.size-md { width: min(600px, 95vw); }
|
||||||
|
.size-lg { width: min(860px, 95vw); }
|
||||||
|
.header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 1.1rem 1.25rem; border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.header h2 { font-size: 1rem; font-weight: 600; }
|
||||||
|
.close-btn {
|
||||||
|
background: none; border: none; cursor: pointer; color: var(--text-muted);
|
||||||
|
padding: .25rem; border-radius: var(--radius-sm); display: flex;
|
||||||
|
transition: color .15s;
|
||||||
|
}
|
||||||
|
.close-btn:hover { color: var(--text); }
|
||||||
|
.body { padding: 1.25rem; overflow-y: auto; flex: 1; }
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
dialog { width: 100vw !important; max-height: 85vh; border-radius: var(--radius-lg) var(--radius-lg) 0 0; }
|
||||||
|
dialog[open] { position: fixed; bottom: 0; left: 0; margin: 0; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<dialog class="size-${this.getAttribute('size') || 'md'}">
|
||||||
|
<div class="header">
|
||||||
|
<h2>${this.getAttribute('title') || ''}</h2>
|
||||||
|
<button class="close-btn" aria-label="Close">
|
||||||
|
<i data-lucide="x" style="width:18px;height:18px"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="body"><slot></slot></div>
|
||||||
|
</dialog>`;
|
||||||
|
|
||||||
|
const dialog = this.shadowRoot.querySelector('dialog');
|
||||||
|
this.shadowRoot.querySelector('.close-btn').addEventListener('click', () => this.close());
|
||||||
|
dialog.addEventListener('click', e => { if (e.target === dialog) this.close(); });
|
||||||
|
document.addEventListener('keydown', e => { if (e.key === 'Escape') this.close(); });
|
||||||
|
if (window.lucide) lucide.createIcons({ root: this.shadowRoot });
|
||||||
|
}
|
||||||
|
|
||||||
|
open() { this.shadowRoot?.querySelector('dialog')?.showModal(); }
|
||||||
|
close() { this.shadowRoot?.querySelector('dialog')?.close(); this.dispatchEvent(new CustomEvent('ui:close', { bubbles: true, composed: true })); }
|
||||||
|
}
|
||||||
|
customElements.define('ui-dialog', UiDialog);
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
class UiEmpty extends HTMLElement {
|
||||||
|
connectedCallback() {
|
||||||
|
const icon = this.getAttribute('icon') || 'inbox';
|
||||||
|
const heading = this.getAttribute('heading') || 'Nothing here yet';
|
||||||
|
const body = this.getAttribute('body') || '';
|
||||||
|
|
||||||
|
this.attachShadow({ mode: 'open' });
|
||||||
|
this.shadowRoot.innerHTML = `
|
||||||
|
<style>
|
||||||
|
:host { display: flex; justify-content: center; }
|
||||||
|
.wrap {
|
||||||
|
display: flex; flex-direction: column; align-items: center;
|
||||||
|
gap: .75rem; padding: 3rem 2rem; text-align: center; max-width: 360px;
|
||||||
|
}
|
||||||
|
.icon-wrap {
|
||||||
|
width: 64px; height: 64px; border-radius: 50%;
|
||||||
|
background: var(--surface-2); display: flex; align-items: center;
|
||||||
|
justify-content: center; color: var(--text-muted);
|
||||||
|
}
|
||||||
|
h3 { font-size: 1rem; font-weight: 600; color: var(--text); }
|
||||||
|
p { font-size: .875rem; color: var(--text-muted); line-height: 1.6; }
|
||||||
|
::slotted(*) { margin-top: .5rem; }
|
||||||
|
</style>
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="icon-wrap">
|
||||||
|
<i data-lucide="${icon}" style="width:28px;height:28px"></i>
|
||||||
|
</div>
|
||||||
|
<h3>${heading}</h3>
|
||||||
|
${body ? `<p>${body}</p>` : ''}
|
||||||
|
<slot></slot>
|
||||||
|
</div>`;
|
||||||
|
if (window.lucide) lucide.createIcons({ root: this.shadowRoot });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
customElements.define('ui-empty', UiEmpty);
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
class UiSpinner extends HTMLElement {
|
||||||
|
connectedCallback() {
|
||||||
|
const size = this.getAttribute('size') || 'md';
|
||||||
|
const px = { sm: '16px', md: '24px', lg: '40px' }[size] || '24px';
|
||||||
|
this.attachShadow({ mode: 'open' });
|
||||||
|
this.shadowRoot.innerHTML = `
|
||||||
|
<style>
|
||||||
|
:host { display: inline-flex; align-items: center; justify-content: center; }
|
||||||
|
.ring {
|
||||||
|
width: ${px}; height: ${px};
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
border-top-color: var(--teal);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin .6s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
</style>
|
||||||
|
<div class="ring"></div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
customElements.define('ui-spinner', UiSpinner);
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
class UiToastContainer extends HTMLElement {
|
||||||
|
connectedCallback() {
|
||||||
|
Object.assign(this.style, {
|
||||||
|
position: 'fixed', top: '1rem', right: '1rem', zIndex: '9999',
|
||||||
|
display: 'flex', flexDirection: 'column', gap: '.5rem',
|
||||||
|
maxWidth: '380px', pointerEvents: 'none',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
show(message, type = 'info') {
|
||||||
|
const colors = { success: 'var(--success)', error: 'var(--danger)', info: 'var(--teal)', warning: 'var(--warning)' };
|
||||||
|
const icons = { success: 'check-circle', error: 'x-circle', info: 'info', warning: 'alert-triangle' };
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
Object.assign(toast.style, {
|
||||||
|
display: 'flex', alignItems: 'center', gap: '.6rem',
|
||||||
|
padding: '.75rem 1rem', borderRadius: '8px', color: '#fff',
|
||||||
|
fontSize: '.875rem', fontFamily: 'inherit',
|
||||||
|
boxShadow: '0 4px 12px rgba(0,0,0,.25)',
|
||||||
|
background: colors[type] || colors.info,
|
||||||
|
animation: 'toastIn .2s ease', pointerEvents: 'auto',
|
||||||
|
minWidth: '260px',
|
||||||
|
});
|
||||||
|
toast.innerHTML = `<i data-lucide="${icons[type] || 'info'}" style="width:16px;height:16px;flex-shrink:0"></i><span>${message}</span>`;
|
||||||
|
this.appendChild(toast);
|
||||||
|
if (window.lucide) lucide.createIcons({ root: toast });
|
||||||
|
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.textContent = `@keyframes toastIn{from{opacity:0;transform:translateX(1rem)}to{opacity:1;transform:none}}`;
|
||||||
|
if (!document.getElementById('toast-keyframes')) {
|
||||||
|
style.id = 'toast-keyframes';
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.style.opacity = '0';
|
||||||
|
toast.style.transition = 'opacity .3s';
|
||||||
|
setTimeout(() => toast.remove(), 300);
|
||||||
|
}, 4000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
customElements.define('ui-toast-container', UiToastContainer);
|
||||||
|
|
||||||
|
let _container = null;
|
||||||
|
|
||||||
|
export function showToast(message, type = 'info') {
|
||||||
|
if (!_container) {
|
||||||
|
_container = document.createElement('ui-toast-container');
|
||||||
|
document.body.appendChild(_container);
|
||||||
|
}
|
||||||
|
_container.show(message, type);
|
||||||
|
}
|
||||||
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('wo-accounting', WoAccounting);
|
||||||
@@ -0,0 +1,380 @@
|
|||||||
|
import { api } from '../../lib/api.mjs';
|
||||||
|
|
||||||
|
// Step type constants
|
||||||
|
const STEP_TYPES = {
|
||||||
|
work_step: { label: 'Step', icon: 'check-square', color: 'var(--teal)' },
|
||||||
|
photo: { label: 'Photo', icon: 'camera', color: '#8B5CF6' },
|
||||||
|
inspection: { label: 'Inspect', icon: 'clipboard-check', color: '#E07B39' },
|
||||||
|
note: { label: 'Note', icon: 'file-text', color: '#64748B' },
|
||||||
|
};
|
||||||
|
|
||||||
|
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, notes = '') {
|
||||||
|
try {
|
||||||
|
await api.post(`/work-orders/${this.#woId}/steps/${stepId}/complete`, { notes });
|
||||||
|
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, step_type: 'work_step' });
|
||||||
|
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' } }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async #uploadPhoto(step, file) {
|
||||||
|
const cfg = this.#cfg(step);
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file);
|
||||||
|
form.append('phase', cfg.phase || 'during');
|
||||||
|
form.append('caption', cfg.caption_prompt || step.title);
|
||||||
|
form.append('step_id', String(step.id));
|
||||||
|
try {
|
||||||
|
await api.upload(`/work-orders/${this.#woId}/attachments`, form);
|
||||||
|
await this.#complete(step.id, 'Photo captured');
|
||||||
|
} catch (err) {
|
||||||
|
window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: err.message, type: 'error' } }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#cfg(step) {
|
||||||
|
try { return JSON.parse(step.type_config || '{}'); } catch { return {}; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Per-type action HTML ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#workStepHTML(st) {
|
||||||
|
return `
|
||||||
|
<div class="step-check-wrap">
|
||||||
|
<input type="checkbox" class="step-check" data-id="${st.id}" ${st.completed ? 'checked' : ''}>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
#photoHTML(st) {
|
||||||
|
const cfg = this.#cfg(st);
|
||||||
|
const prompt = cfg.caption_prompt || 'Capture photo';
|
||||||
|
const phase = cfg.phase ? `<span class="phase-badge phase-${cfg.phase}">${cfg.phase}</span>` : '';
|
||||||
|
if (st.completed) {
|
||||||
|
return `<div class="type-icon photo-done"><i data-lucide="image" style="width:18px;height:18px"></i></div>`;
|
||||||
|
}
|
||||||
|
return `
|
||||||
|
<div class="photo-action">
|
||||||
|
<button class="photo-btn" data-photo-id="${st.id}" title="${this.#esc(prompt)}">
|
||||||
|
<i data-lucide="camera" style="width:16px;height:16px"></i>
|
||||||
|
</button>
|
||||||
|
${phase}
|
||||||
|
<input type="file" accept="image/*" capture="environment" class="photo-file-input" data-photo-id="${st.id}" style="display:none">
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
#inspectionHTML(st) {
|
||||||
|
if (st.completed) {
|
||||||
|
const result = st.notes || '';
|
||||||
|
const cls = result === 'PASS' ? 'insp-pass' : result === 'FAIL' ? 'insp-fail' : 'insp-na';
|
||||||
|
return `<div class="insp-result ${cls}">${result || 'Done'}</div>`;
|
||||||
|
}
|
||||||
|
return `
|
||||||
|
<div class="insp-btns">
|
||||||
|
<button class="insp-btn pass" data-insp="${st.id}" data-result="PASS">Pass</button>
|
||||||
|
<button class="insp-btn fail" data-insp="${st.id}" data-result="FAIL">Fail</button>
|
||||||
|
<button class="insp-btn na" data-insp="${st.id}" data-result="N/A">N/A</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
#noteHTML(st) {
|
||||||
|
const cfg = this.#cfg(st);
|
||||||
|
const prompt = cfg.prompt || 'Add note…';
|
||||||
|
if (st.completed) {
|
||||||
|
return `<div class="type-icon note-done"><i data-lucide="check" style="width:16px;height:16px"></i></div>`;
|
||||||
|
}
|
||||||
|
return `
|
||||||
|
<div class="note-action">
|
||||||
|
<textarea class="note-input" data-note-id="${st.id}" placeholder="${this.#esc(prompt)}" rows="2">${this.#esc(st.notes || '')}</textarea>
|
||||||
|
<button class="note-save-btn" data-note-id="${st.id}">Save</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
#typeActionHTML(st) {
|
||||||
|
switch (st.step_type || 'work_step') {
|
||||||
|
case 'photo': return this.#photoHTML(st);
|
||||||
|
case 'inspection': return this.#inspectionHTML(st);
|
||||||
|
case 'note': return this.#noteHTML(st);
|
||||||
|
default: return this.#workStepHTML(st);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#typeBadgeHTML(st) {
|
||||||
|
const t = STEP_TYPES[st.step_type] || STEP_TYPES.work_step;
|
||||||
|
if (st.step_type === 'work_step' || !st.step_type) return '';
|
||||||
|
return `<span class="type-badge" style="--badge-color:${t.color}">${t.label}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#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-row.type-photo { border-left: 3px solid #8B5CF6; }
|
||||||
|
.step-row.type-inspection { border-left: 3px solid #E07B39; }
|
||||||
|
.step-row.type-note { border-left: 3px solid #64748B; }
|
||||||
|
/* Work step checkbox */
|
||||||
|
.step-check-wrap { display: flex; align-items: center; justify-content: center; min-width: 24px; height: 24px; margin-top: .1rem; flex-shrink: 0; }
|
||||||
|
.step-check { width: 20px; height: 20px; accent-color: var(--teal); cursor: pointer; }
|
||||||
|
/* Photo step */
|
||||||
|
.photo-action { display: flex; align-items: center; gap: .4rem; flex-shrink: 0; }
|
||||||
|
.photo-btn { background: #8B5CF6; color: #fff; border: none; border-radius: var(--radius-sm); padding: .35rem .6rem; cursor: pointer; display: flex; align-items: center; gap: .3rem; font-size: .75rem; font-weight: 600; transition: opacity .15s; }
|
||||||
|
.photo-btn:hover { opacity: .85; }
|
||||||
|
.photo-done { color: #8B5CF6; display: flex; align-items: center; min-width: 24px; flex-shrink: 0; }
|
||||||
|
.phase-badge { font-size: .65rem; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; padding: .15rem .4rem; border-radius: 99px; flex-shrink: 0; }
|
||||||
|
.phase-before { background: #FEF3C7; color: #92400E; }
|
||||||
|
.phase-during { background: #DBEAFE; color: #1E40AF; }
|
||||||
|
.phase-after { background: #DCFCE7; color: #15803D; }
|
||||||
|
/* Inspection step */
|
||||||
|
.insp-btns { display: flex; gap: .3rem; flex-shrink: 0; }
|
||||||
|
.insp-btn { border: none; border-radius: var(--radius-sm); padding: .3rem .65rem; font-size: .75rem; font-weight: 700; cursor: pointer; transition: opacity .15s; }
|
||||||
|
.insp-btn:hover { opacity: .8; }
|
||||||
|
.insp-btn.pass { background: #DCFCE7; color: #15803D; }
|
||||||
|
.insp-btn.fail { background: #FEE2E2; color: #991B1B; }
|
||||||
|
.insp-btn.na { background: var(--surface-2); color: var(--text-muted); }
|
||||||
|
.insp-result { font-size: .75rem; font-weight: 700; padding: .3rem .65rem; border-radius: var(--radius-sm); flex-shrink: 0; }
|
||||||
|
.insp-pass { background: #DCFCE7; color: #15803D; }
|
||||||
|
.insp-fail { background: #FEE2E2; color: #991B1B; }
|
||||||
|
.insp-na { background: var(--surface-2); color: var(--text-muted); }
|
||||||
|
/* Note step */
|
||||||
|
.note-action { display: flex; flex-direction: column; gap: .35rem; flex: 1; min-width: 0; }
|
||||||
|
.note-input { border: 1px solid var(--border); border-radius: var(--radius-sm); padding: .45rem .6rem; font-size: .813rem; background: var(--surface); color: var(--text); resize: vertical; font-family: inherit; width: 100%; box-sizing: border-box; }
|
||||||
|
.note-input:focus { outline: none; border-color: var(--teal); }
|
||||||
|
.note-save-btn { align-self: flex-end; background: var(--teal); color: #fff; border: none; border-radius: var(--radius-sm); padding: .3rem .85rem; font-size: .813rem; font-weight: 600; cursor: pointer; transition: opacity .15s; }
|
||||||
|
.note-save-btn:hover { opacity: .88; }
|
||||||
|
.note-done { color: var(--teal); display: flex; align-items: center; min-width: 24px; flex-shrink: 0; }
|
||||||
|
/* Step body */
|
||||||
|
.step-body { flex: 1; min-width: 0; }
|
||||||
|
.step-header { display: flex; align-items: baseline; gap: .4rem; flex-wrap: wrap; }
|
||||||
|
.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); }
|
||||||
|
.type-badge { font-size: .65rem; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; padding: .15rem .4rem; border-radius: 99px; background: color-mix(in srgb, var(--badge-color) 15%, transparent); color: var(--badge-color); flex-shrink: 0; }
|
||||||
|
/* Step actions */
|
||||||
|
.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); }
|
||||||
|
/* Add section */
|
||||||
|
.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 => {
|
||||||
|
const type = st.step_type || 'work_step';
|
||||||
|
const cfg = this.#cfg(st);
|
||||||
|
// For note steps, the action takes flex:1 so body goes before it
|
||||||
|
const noteType = type === 'note' && !st.completed;
|
||||||
|
return `
|
||||||
|
<div class="step-row${st.completed ? ' done' : ''} type-${type}">
|
||||||
|
${noteType ? '' : this.#typeActionHTML(st)}
|
||||||
|
<div class="step-body" style="${noteType ? 'flex:0 0 auto;max-width:45%' : ''}">
|
||||||
|
<div class="step-header">
|
||||||
|
<span class="step-num">${st.step_order}.</span>
|
||||||
|
<span class="step-title">${this.#esc(st.title)}</span>
|
||||||
|
${this.#typeBadgeHTML(st)}
|
||||||
|
</div>
|
||||||
|
${st.description ? `<div class="step-desc">${this.#esc(st.description)}</div>` : ''}
|
||||||
|
${type === 'inspection' && !st.completed && cfg.criteria ? `<div class="step-desc">${this.#esc(cfg.criteria)}</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 && type !== 'note' ? `<div class="step-note">"${this.#esc(st.notes)}"</div>` : ''}
|
||||||
|
</div>
|
||||||
|
${noteType ? this.#typeActionHTML(st) : ''}
|
||||||
|
<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>`
|
||||||
|
: type === 'work_step' || !type
|
||||||
|
? `<button class="icon-btn" data-del="${st.id}" title="Remove step">
|
||||||
|
<i data-lucide="trash-2" 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({ root: s });
|
||||||
|
|
||||||
|
// Work step checkboxes
|
||||||
|
s.querySelectorAll('.step-check').forEach(cb => {
|
||||||
|
cb.addEventListener('change', () => {
|
||||||
|
if (cb.checked) this.#complete(+cb.dataset.id);
|
||||||
|
else this.#uncomplete(+cb.dataset.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Photo capture buttons
|
||||||
|
s.querySelectorAll('.photo-btn').forEach(btn => {
|
||||||
|
const stepId = +btn.dataset.photoId;
|
||||||
|
const fileInput = s.querySelector(`.photo-file-input[data-photo-id="${stepId}"]`);
|
||||||
|
btn.addEventListener('click', () => fileInput?.click());
|
||||||
|
fileInput?.addEventListener('change', e => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
const step = this.#steps.find(st => st.id === stepId);
|
||||||
|
if (step) this.#uploadPhoto(step, file);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Inspection buttons
|
||||||
|
s.querySelectorAll('.insp-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => this.#complete(+btn.dataset.insp, btn.dataset.result));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Note save buttons
|
||||||
|
s.querySelectorAll('.note-save-btn').forEach(btn => {
|
||||||
|
const stepId = +btn.dataset.noteId;
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const textarea = s.querySelector(`.note-input[data-note-id="${stepId}"]`);
|
||||||
|
const text = textarea?.value.trim() || '';
|
||||||
|
if (!text) { textarea?.focus(); return; }
|
||||||
|
this.#complete(stepId, text);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete / undo
|
||||||
|
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)));
|
||||||
|
|
||||||
|
// Add step
|
||||||
|
const titleInput = s.querySelector('#new-step-title');
|
||||||
|
const addBtn = s.querySelector('#add-step-btn');
|
||||||
|
addBtn.addEventListener('click', () => {
|
||||||
|
const title = titleInput.value.trim();
|
||||||
|
if (!title) { titleInput.focus(); return; }
|
||||||
|
this.#addStep(title);
|
||||||
|
titleInput.value = '';
|
||||||
|
});
|
||||||
|
titleInput.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Enter') addBtn.click();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#esc(s) { return (s || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('wo-checklist', WoChecklist);
|
||||||
@@ -0,0 +1,434 @@
|
|||||||
|
import { api } from '../../lib/api.mjs';
|
||||||
|
import { formatDateTime, formatDate } from '../../lib/format.mjs';
|
||||||
|
import './wo-checklist.mjs';
|
||||||
|
import './wo-resource-panel.mjs';
|
||||||
|
import './wo-photo-panel.mjs';
|
||||||
|
import './wo-map.mjs';
|
||||||
|
import './wo-accounting.mjs';
|
||||||
|
import './wo-timeline.mjs';
|
||||||
|
|
||||||
|
const TABS = ['Overview', 'Checklist', 'Resources', 'Photos', 'Accounting', 'Activity'];
|
||||||
|
const STATUS_TRANSITIONS = {
|
||||||
|
draft: ['assigned','scheduled'],
|
||||||
|
assigned: ['scheduled','in_progress'],
|
||||||
|
scheduled: ['in_progress'],
|
||||||
|
in_progress: ['pending_review','closed'],
|
||||||
|
pending_review: ['in_progress','closed'],
|
||||||
|
closed: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
class WoDetail extends HTMLElement {
|
||||||
|
#woId = null;
|
||||||
|
#wo = null;
|
||||||
|
#tab = 'Overview';
|
||||||
|
#loading = true;
|
||||||
|
|
||||||
|
static get observedAttributes() { return ['wo-id']; }
|
||||||
|
attributeChangedCallback(_, __, val) { this.#woId = val ? +val : null; if (this.shadowRoot) this.#load(); }
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
this.attachShadow({ mode: 'open' });
|
||||||
|
if (this.#woId) this.#load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async #load() {
|
||||||
|
this.#loading = true;
|
||||||
|
this.#render();
|
||||||
|
try { this.#wo = await api.get(`/work-orders/${this.#woId}`); } catch { this.#wo = null; }
|
||||||
|
this.#loading = false;
|
||||||
|
this.#render();
|
||||||
|
}
|
||||||
|
|
||||||
|
#render() {
|
||||||
|
const s = this.shadowRoot;
|
||||||
|
s.innerHTML = `<style>${this.#css()}</style>${this.#html()}`;
|
||||||
|
this.#bind();
|
||||||
|
if (window.lucide) lucide.createIcons({ root: s });
|
||||||
|
}
|
||||||
|
|
||||||
|
#css() { return `
|
||||||
|
:host { display: block; }
|
||||||
|
.page-header { display:flex; align-items:center; gap:1rem; margin-bottom:1.25rem; flex-wrap:wrap; }
|
||||||
|
.back-btn { background:none; border:1px solid var(--border); color:var(--text); border-radius:var(--radius); padding:.4rem .75rem; font-size:.813rem; font-weight:500; cursor:pointer; display:flex; align-items:center; gap:.3rem; transition:background .15s; flex-shrink:0; }
|
||||||
|
.back-btn:hover { background:var(--surface-2); }
|
||||||
|
.header-info { flex:1; min-width:0; }
|
||||||
|
.wo-number { font-family:monospace; font-size:.813rem; color:var(--text-muted); margin-bottom:.15rem; }
|
||||||
|
h1 { font-size:1.2rem; font-weight:700; color:var(--text); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||||
|
.header-badges { display:flex; align-items:center; gap:.5rem; margin-top:.35rem; flex-wrap:wrap; }
|
||||||
|
.header-actions { display:flex; gap:.5rem; flex-shrink:0; }
|
||||||
|
.btn { display:inline-flex; align-items:center; gap:.35rem; padding:.45rem .9rem; border:none; border-radius:var(--radius); font-size:.813rem; font-weight:600; cursor:pointer; transition:opacity .15s,background .15s; white-space:nowrap; }
|
||||||
|
.btn-primary { background:var(--teal); color:#fff; }
|
||||||
|
.btn-primary:hover { opacity:.88; }
|
||||||
|
.btn-ghost { background:transparent; border:1px solid var(--border); color:var(--text); }
|
||||||
|
.btn-ghost:hover { background:var(--surface-2); }
|
||||||
|
.meta-strip { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); padding:.9rem 1.25rem; margin-bottom:1rem; display:flex; gap:2rem; flex-wrap:wrap; box-shadow:var(--shadow-sm); }
|
||||||
|
.meta-item { display:flex; flex-direction:column; gap:.15rem; }
|
||||||
|
.meta-label { font-size:.7rem; font-weight:700; color:var(--text-muted); text-transform:uppercase; letter-spacing:.06em; }
|
||||||
|
.meta-value { font-size:.875rem; color:var(--text); font-weight:500; }
|
||||||
|
.tab-bar { display:flex; gap:0; border-bottom:2px solid var(--border); margin-bottom:1rem; overflow-x:auto; }
|
||||||
|
.tab { padding:.65rem 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,border-color .15s; }
|
||||||
|
.tab.active { color:var(--teal); border-bottom-color:var(--teal); font-weight:600; }
|
||||||
|
.tab:hover:not(.active) { color:var(--text); }
|
||||||
|
.tab-content { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); padding:1.25rem; box-shadow:var(--shadow-sm); }
|
||||||
|
.detail-grid { display:grid; grid-template-columns:1fr 1fr; gap:1.25rem; }
|
||||||
|
.detail-field { display:flex; flex-direction:column; gap:.25rem; }
|
||||||
|
.field-label { font-size:.7rem; font-weight:700; color:var(--text-muted); text-transform:uppercase; letter-spacing:.06em; }
|
||||||
|
.field-value { font-size:.875rem; color:var(--text); line-height:1.5; }
|
||||||
|
.field-value.empty { color:var(--text-muted); font-style:italic; }
|
||||||
|
.text-block { background:var(--surface-2); border-radius:var(--radius-sm); padding:.75rem 1rem; font-size:.875rem; line-height:1.6; color:var(--text); white-space:pre-wrap; }
|
||||||
|
.status-menu { position:relative; }
|
||||||
|
.status-dropdown { position:absolute; top:calc(100% + .25rem); right:0; background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); box-shadow:var(--shadow-md); z-index:50; min-width:160px; overflow:hidden; }
|
||||||
|
.status-opt { display:flex; align-items:center; gap:.5rem; padding:.6rem 1rem; font-size:.875rem; cursor:pointer; border:none; background:none; width:100%; text-align:left; transition:background .15s; color:var(--text); }
|
||||||
|
.status-opt:hover { background:var(--surface-2); }
|
||||||
|
.status-dot { width:8px; height:8px; border-radius:50%; flex-shrink:0; }
|
||||||
|
@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() {
|
||||||
|
if (this.#loading) return `
|
||||||
|
<div style="display:flex;align-items:center;justify-content:center;padding:4rem">
|
||||||
|
<ui-spinner size="lg"></ui-spinner>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
if (!this.#wo) return `
|
||||||
|
<ui-empty icon="alert-circle" heading="Work order not found" body="This work order may have been deleted.">
|
||||||
|
<button class="btn btn-ghost" id="back" style="margin-top:.5rem">
|
||||||
|
<i data-lucide="arrow-left" style="width:14px;height:14px"></i> Back to list
|
||||||
|
</button>
|
||||||
|
</ui-empty>`;
|
||||||
|
|
||||||
|
const wo = this.#wo;
|
||||||
|
const transitions = STATUS_TRANSITIONS[wo.status] || [];
|
||||||
|
const STATUS_COLORS = { draft:'var(--status-draft)', assigned:'var(--status-assigned)', scheduled:'var(--status-scheduled)', in_progress:'var(--status-in_progress)', pending_review:'var(--status-pending_review)', closed:'var(--status-closed)' };
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="page-header">
|
||||||
|
<button class="back-btn" id="back"><i data-lucide="chevron-left" style="width:15px;height:15px"></i> Work Orders</button>
|
||||||
|
<div class="header-info">
|
||||||
|
<div class="wo-number">${wo.wo_number}</div>
|
||||||
|
<h1>${this.#esc(wo.title)}</h1>
|
||||||
|
<div class="header-badges">
|
||||||
|
<ui-badge type="status" value="${wo.status}"></ui-badge>
|
||||||
|
<ui-badge type="priority" value="${wo.priority}"></ui-badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button class="btn btn-ghost" id="edit-btn">
|
||||||
|
<i data-lucide="pencil" style="width:14px;height:14px"></i> Edit
|
||||||
|
</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 ? `
|
||||||
|
<div class="status-menu" id="status-menu">
|
||||||
|
<button class="btn btn-primary" id="status-btn">
|
||||||
|
<i data-lucide="refresh-cw" style="width:14px;height:14px"></i> Change Status
|
||||||
|
</button>
|
||||||
|
<div class="status-dropdown" id="status-dropdown" style="display:none">
|
||||||
|
${transitions.map(s => `
|
||||||
|
<button class="status-opt" data-status="${s}">
|
||||||
|
<span class="status-dot" style="background:${STATUS_COLORS[s]}"></span>
|
||||||
|
${s.replace('_',' ')}
|
||||||
|
</button>`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="meta-strip">
|
||||||
|
<div class="meta-item">
|
||||||
|
<span class="meta-label">Site</span>
|
||||||
|
<span class="meta-value">${this.#esc(wo.site_name) || '—'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="meta-item">
|
||||||
|
<span class="meta-label">Scheduled Start</span>
|
||||||
|
<span class="meta-value">${formatDateTime(wo.scheduled_start)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="meta-item">
|
||||||
|
<span class="meta-label">Scheduled End</span>
|
||||||
|
<span class="meta-value">${formatDateTime(wo.scheduled_end)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="meta-item">
|
||||||
|
<span class="meta-label">Created By</span>
|
||||||
|
<span class="meta-value">${this.#esc(wo.created_by) || '—'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="meta-item">
|
||||||
|
<span class="meta-label">Created</span>
|
||||||
|
<span class="meta-value">${formatDate(wo.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
${wo.parent_type ? `<div class="meta-item">
|
||||||
|
<span class="meta-label">Parent</span>
|
||||||
|
<span class="meta-value">${wo.parent_type} #${wo.parent_id}</span>
|
||||||
|
</div>` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-bar">
|
||||||
|
${TABS.map(t => `<button class="tab${this.#tab===t?' active':''}" data-tab="${t}">${t}</button>`).join('')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-content" id="tab-content">
|
||||||
|
${this.#tabContent(wo)}
|
||||||
|
</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) {
|
||||||
|
switch (this.#tab) {
|
||||||
|
case 'Overview': return `
|
||||||
|
<div class="detail-grid">
|
||||||
|
<div class="detail-field" style="grid-column:1/-1">
|
||||||
|
<div class="field-label">Description</div>
|
||||||
|
${wo.description
|
||||||
|
? `<div class="text-block">${this.#esc(wo.description)}</div>`
|
||||||
|
: '<div class="field-value empty">No description provided</div>'}
|
||||||
|
</div>
|
||||||
|
<div class="detail-field" style="grid-column:1/-1">
|
||||||
|
<div class="field-label">Instructions</div>
|
||||||
|
${wo.instructions
|
||||||
|
? `<div class="text-block">${this.#esc(wo.instructions)}</div>`
|
||||||
|
: '<div class="field-value empty">No instructions provided</div>'}
|
||||||
|
</div>
|
||||||
|
<div class="detail-field">
|
||||||
|
<div class="field-label">Address</div>
|
||||||
|
<div class="field-value${wo.address?'':' empty'}">${this.#esc(wo.address) || 'Not set'}</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-field">
|
||||||
|
<div class="field-label">Access Notes</div>
|
||||||
|
${wo.access_notes
|
||||||
|
? `<div class="text-block" style="font-size:.813rem">${this.#esc(wo.access_notes)}</div>`
|
||||||
|
: '<div class="field-value empty">No access notes</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>`;
|
||||||
|
|
||||||
|
case 'Checklist':
|
||||||
|
return `<wo-checklist wo-id="${wo.id}"></wo-checklist>`;
|
||||||
|
|
||||||
|
case 'Resources':
|
||||||
|
return `<wo-resource-panel wo-id="${wo.id}"></wo-resource-panel>`;
|
||||||
|
|
||||||
|
case 'Photos':
|
||||||
|
return `<wo-photo-panel wo-id="${wo.id}"></wo-photo-panel>`;
|
||||||
|
|
||||||
|
case 'Accounting':
|
||||||
|
return `<wo-accounting wo-id="${wo.id}"></wo-accounting>`;
|
||||||
|
|
||||||
|
case 'Activity':
|
||||||
|
return `<wo-timeline wo-id="${wo.id}"></wo-timeline>`;
|
||||||
|
|
||||||
|
default: return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#bind() {
|
||||||
|
const s = this.shadowRoot;
|
||||||
|
s.querySelector('#back')?.addEventListener('click', () =>
|
||||||
|
window.dispatchEvent(new CustomEvent('wo:navigate', { detail: { path: '/work-orders' } })));
|
||||||
|
|
||||||
|
s.querySelector('#edit-btn')?.addEventListener('click', () =>
|
||||||
|
window.dispatchEvent(new CustomEvent('wo:navigate', { detail: { path: `/work-orders/${this.#woId}/edit` } })));
|
||||||
|
|
||||||
|
// Tab switching
|
||||||
|
s.querySelectorAll('.tab').forEach(tab =>
|
||||||
|
tab.addEventListener('click', () => {
|
||||||
|
this.#tab = tab.dataset.tab;
|
||||||
|
s.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab === this.#tab));
|
||||||
|
s.querySelector('#tab-content').innerHTML = this.#tabContent(this.#wo);
|
||||||
|
if (window.lucide) lucide.createIcons({ root: s.querySelector('#tab-content') });
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Status dropdown
|
||||||
|
const statusBtn = s.querySelector('#status-btn');
|
||||||
|
const dropdown = s.querySelector('#status-dropdown');
|
||||||
|
statusBtn?.addEventListener('click', e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
dropdown.style.display = dropdown.style.display === 'none' ? 'block' : 'none';
|
||||||
|
});
|
||||||
|
document.addEventListener('click', () => { if (dropdown) dropdown.style.display = 'none'; }, { once: false });
|
||||||
|
|
||||||
|
s.querySelectorAll('.status-opt').forEach(opt =>
|
||||||
|
opt.addEventListener('click', async () => {
|
||||||
|
dropdown.style.display = 'none';
|
||||||
|
try {
|
||||||
|
await api.put(`/work-orders/${this.#woId}/status`, { status: opt.dataset.status });
|
||||||
|
window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: `Status changed to ${opt.dataset.status.replace('_',' ')}`, type: 'success' } }));
|
||||||
|
this.#load();
|
||||||
|
} catch (err) {
|
||||||
|
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,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||||
|
}
|
||||||
|
customElements.define('wo-detail', WoDetail);
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
import { api } from '../../lib/api.mjs';
|
||||||
|
import { toLocalDatetime } from '../../lib/format.mjs';
|
||||||
|
|
||||||
|
const STATUSES = ['draft','assigned','scheduled','in_progress','pending_review','closed'];
|
||||||
|
const PRI_COLOR = { low:'var(--priority-low)', normal:'var(--priority-normal)', high:'var(--priority-high)', urgent:'var(--priority-urgent)' };
|
||||||
|
|
||||||
|
class WoForm extends HTMLElement {
|
||||||
|
#woId = null;
|
||||||
|
#wo = null;
|
||||||
|
#dirty = false;
|
||||||
|
|
||||||
|
static get observedAttributes() { return ['wo-id']; }
|
||||||
|
attributeChangedCallback(_, __, val) { this.#woId = val ? +val : null; if (this.shadowRoot) this.#load(); }
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
this.attachShadow({ mode: 'open' });
|
||||||
|
this.#render();
|
||||||
|
if (this.#woId) this.#load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async #load() {
|
||||||
|
if (!this.#woId) return;
|
||||||
|
try { this.#wo = await api.get(`/work-orders/${this.#woId}`); } catch { /* ignore */ }
|
||||||
|
this.#render();
|
||||||
|
}
|
||||||
|
|
||||||
|
#render() {
|
||||||
|
const wo = this.#wo || {};
|
||||||
|
const isNew = !this.#woId;
|
||||||
|
const s = this.shadowRoot;
|
||||||
|
s.innerHTML = `<style>${this.#css()}</style>${this.#html(wo, isNew)}`;
|
||||||
|
this.#bind();
|
||||||
|
if (window.lucide) lucide.createIcons({ root: s });
|
||||||
|
}
|
||||||
|
|
||||||
|
#css() { return `
|
||||||
|
:host { display: block; }
|
||||||
|
.page-header { display:flex; align-items:center; gap:1rem; margin-bottom:1.25rem; }
|
||||||
|
.back-btn { background:none; border:1px solid var(--border); color:var(--text); border-radius:var(--radius); padding:.4rem .75rem; font-size:.813rem; font-weight:500; cursor:pointer; display:flex; align-items:center; gap:.3rem; transition:background .15s; }
|
||||||
|
.back-btn:hover { background:var(--surface-2); }
|
||||||
|
h1 { font-size:1.25rem; font-weight:700; color:var(--text); }
|
||||||
|
.layout { display:grid; grid-template-columns:2fr 1fr; gap:1.5rem; align-items:start; }
|
||||||
|
.card { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); padding:1.25rem; display:flex; flex-direction:column; gap:1rem; box-shadow:var(--shadow-sm); }
|
||||||
|
.card-title { font-size:.813rem; font-weight:700; color:var(--text-muted); text-transform:uppercase; letter-spacing:.06em; padding-bottom:.75rem; border-bottom:1px solid var(--border); }
|
||||||
|
label { display:flex; flex-direction:column; gap:.3rem; font-size:.813rem; font-weight:600; color:var(--text); }
|
||||||
|
input, select, textarea { border:1px solid var(--border); border-radius:var(--radius-sm); padding:.5rem .75rem; background:var(--surface); color:var(--text); font-size:.875rem; width:100%; transition:border-color .15s,box-shadow .15s; color-scheme:inherit; }
|
||||||
|
input:focus, select:focus, textarea:focus { outline:none; border-color:var(--teal); box-shadow:0 0 0 3px rgba(10,126,164,.15); }
|
||||||
|
textarea { resize:vertical; min-height:80px; line-height:1.6; font-family:inherit; }
|
||||||
|
.row2 { display:grid; grid-template-columns:1fr 1fr; gap:1rem; }
|
||||||
|
select { appearance:none; cursor:pointer; background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2364748B' stroke-width='2'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E"); background-repeat:no-repeat; background-position:right .6rem center; padding-right:2rem; }
|
||||||
|
.priority-select-wrap { position:relative; }
|
||||||
|
.pri-dot { width:8px; height:8px; border-radius:50%; position:absolute; left:.75rem; top:50%; transform:translateY(-50%); pointer-events:none; }
|
||||||
|
.priority-select { padding-left:1.75rem; }
|
||||||
|
.footer { display:flex; justify-content:flex-end; gap:.75rem; margin-top:.5rem; }
|
||||||
|
.btn { display:inline-flex; align-items:center; gap:.4rem; padding:.5rem 1rem; border:none; border-radius:var(--radius); font-size:.875rem; font-weight:600; cursor:pointer; transition:opacity .15s; }
|
||||||
|
.btn-primary { background:var(--teal); color:#fff; }
|
||||||
|
.btn-primary:hover { opacity:.88; }
|
||||||
|
.btn-ghost { background:transparent; border:1px solid var(--border); color:var(--text); }
|
||||||
|
.btn-ghost:hover { background:var(--surface-2); }
|
||||||
|
.btn:disabled { opacity:.5; cursor:not-allowed; }
|
||||||
|
.err { color:var(--danger); font-size:.813rem; text-align:right; }
|
||||||
|
@media (max-width:768px) { .layout { grid-template-columns:1fr; } .row2 { grid-template-columns:1fr; } }
|
||||||
|
.lp-dialog { border:1px solid var(--border); border-radius:var(--radius-lg); padding:0; box-shadow:var(--shadow-lg); width:440px; max-width:96vw; background:var(--surface); color:var(--text); }
|
||||||
|
.lp-dialog::backdrop { background:rgba(0,0,0,.45); }
|
||||||
|
.lp-header { display:flex; align-items:center; justify-content:space-between; padding:1rem 1.25rem; border-bottom:1px solid var(--border); }
|
||||||
|
.lp-title { font-size:.938rem; font-weight:700; }
|
||||||
|
.lp-close { background:none; border:none; cursor:pointer; color:var(--text-muted); padding:.25rem; display:flex; }
|
||||||
|
.lp-body { padding:1.1rem 1.25rem; display:flex; flex-direction:column; gap:.75rem; }
|
||||||
|
.lp-search { width:100%; border:1px solid var(--border); border-radius:var(--radius-sm); padding:.5rem .75rem; font-size:.875rem; background:var(--surface); color:var(--text); box-sizing:border-box; }
|
||||||
|
.lp-search:focus { outline:none; border-color:var(--teal); box-shadow:0 0 0 3px rgba(10,126,164,.12); }
|
||||||
|
.lp-list { max-height:260px; overflow-y:auto; display:flex; flex-direction:column; gap:.3rem; border:1px solid var(--border); border-radius:var(--radius-sm); padding:.35rem; }
|
||||||
|
.lp-item { padding:.55rem .75rem; border-radius:var(--radius-sm); cursor:pointer; display:flex; flex-direction:column; gap:.15rem; transition:background .12s; border:2px solid transparent; }
|
||||||
|
.lp-item:hover { background:var(--surface-2); }
|
||||||
|
.lp-item.selected { border-color:var(--teal); background:rgba(10,126,164,.06); }
|
||||||
|
.lp-item-name { font-size:.875rem; font-weight:500; color:var(--text); }
|
||||||
|
.lp-item-meta { font-size:.75rem; color:var(--text-muted); }
|
||||||
|
.lp-footer { padding:.875rem 1.25rem; border-top:1px solid var(--border); display:flex; justify-content:flex-end; gap:.5rem; }
|
||||||
|
.lp-empty { text-align:center; padding:1.5rem; color:var(--text-muted); font-size:.875rem; }
|
||||||
|
`; }
|
||||||
|
|
||||||
|
#html(wo, isNew) {
|
||||||
|
const v = (field, fallback = '') => this.#esc(wo[field] ?? fallback);
|
||||||
|
return `
|
||||||
|
<div class="page-header">
|
||||||
|
<button class="back-btn" id="back"><i data-lucide="chevron-left" style="width:15px;height:15px"></i> Back</button>
|
||||||
|
<h1>${isNew ? 'New Work Order' : `Edit ${wo.wo_number || 'Work Order'}`}</h1>
|
||||||
|
</div>
|
||||||
|
<form id="wo-form">
|
||||||
|
<div class="layout">
|
||||||
|
<!-- Left: main fields -->
|
||||||
|
<div style="display:flex;flex-direction:column;gap:1rem;">
|
||||||
|
<div class="card">
|
||||||
|
<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>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>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">Location</div>
|
||||||
|
<label>Site Name<input name="site_name" value="${v('site_name')}" placeholder="e.g. Downtown Office"></label>
|
||||||
|
<label>Address<input name="address" value="${v('address')}" placeholder="Street address"></label>
|
||||||
|
<label>Access Notes<textarea name="access_notes" rows="2" placeholder="Gate codes, road conditions, parking info…">${v('access_notes')}</textarea></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right: metadata -->
|
||||||
|
<div style="display:flex;flex-direction:column;gap:1rem;">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">Status & Priority</div>
|
||||||
|
${!isNew ? `<label>Status
|
||||||
|
<select name="status">
|
||||||
|
${STATUSES.map(s => `<option value="${s}" ${wo.status===s?'selected':''}>${s.replace('_',' ')}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
</label>` : ''}
|
||||||
|
<label>Priority
|
||||||
|
<div class="priority-select-wrap">
|
||||||
|
<span class="pri-dot" id="pri-dot" style="background:${PRI_COLOR[wo.priority||'normal']}"></span>
|
||||||
|
<select name="priority" class="priority-select">
|
||||||
|
${['low','normal','high','urgent'].map(p =>
|
||||||
|
`<option value="${p}" ${(wo.priority||'normal')===p?'selected':''}>${p.charAt(0).toUpperCase()+p.slice(1)}</option>`
|
||||||
|
).join('')}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">Schedule</div>
|
||||||
|
<label>Scheduled Start<input type="datetime-local" name="scheduled_start" value="${toLocalDatetime(wo.scheduled_start)}"></label>
|
||||||
|
<label>Scheduled End<input type="datetime-local" name="scheduled_end" value="${toLocalDatetime(wo.scheduled_end)}"></label>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">Parent</div>
|
||||||
|
<label>Parent Type
|
||||||
|
<select name="parent_type">
|
||||||
|
<option value="">None</option>
|
||||||
|
<option value="project" ${wo.parent_type==='project'?'selected':''}>Project</option>
|
||||||
|
<option value="ticket" ${wo.parent_type==='ticket'?'selected':''}>Trouble Ticket</option>
|
||||||
|
<option value="service_order" ${wo.parent_type==='service_order'?'selected':''}>Service Order</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Parent ID<input name="parent_id" type="number" value="${wo.parent_id ?? ''}" placeholder="Reference ID"></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<div class="err" id="form-err"></div>
|
||||||
|
<button type="button" class="btn btn-ghost" id="cancel-btn">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary" id="save-btn">
|
||||||
|
<i data-lucide="save" style="width:14px;height:14px"></i>
|
||||||
|
${isNew ? 'Create Work Order' : 'Save Changes'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</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() {
|
||||||
|
const s = this.shadowRoot;
|
||||||
|
|
||||||
|
s.querySelector('#back')?.addEventListener('click', () => this.#goBack());
|
||||||
|
s.querySelector('#cancel-btn')?.addEventListener('click', () => this.#goBack());
|
||||||
|
|
||||||
|
// Load Profile dialog
|
||||||
|
this.#bindLoadProfile(s);
|
||||||
|
|
||||||
|
// Update priority dot colour on change
|
||||||
|
s.querySelector('[name="priority"]')?.addEventListener('change', e => {
|
||||||
|
const dot = s.querySelector('#pri-dot');
|
||||||
|
if (dot) dot.style.background = PRI_COLOR[e.target.value] || PRI_COLOR.normal;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dirty tracking
|
||||||
|
s.querySelectorAll('input, select, textarea').forEach(el =>
|
||||||
|
el.addEventListener('input', () => { this.#dirty = true; }));
|
||||||
|
|
||||||
|
s.querySelector('#wo-form')?.addEventListener('submit', async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const val = n => (s.querySelector(`[name="${n}"]`)?.value ?? '').trim();
|
||||||
|
const dt = n => { const v = val(n); return v ? new Date(v).toISOString() : null; };
|
||||||
|
|
||||||
|
if (!val('title')) {
|
||||||
|
s.querySelector('#form-err').textContent = 'Title is required.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
title: val('title'),
|
||||||
|
description: val('description'),
|
||||||
|
instructions: val('instructions'),
|
||||||
|
priority: val('priority') || 'normal',
|
||||||
|
site_name: val('site_name'),
|
||||||
|
address: val('address'),
|
||||||
|
access_notes: val('access_notes'),
|
||||||
|
scheduled_start: dt('scheduled_start'),
|
||||||
|
scheduled_end: dt('scheduled_end'),
|
||||||
|
parent_type: val('parent_type') || null,
|
||||||
|
parent_id: val('parent_id') ? +val('parent_id') : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const btn = s.querySelector('#save-btn');
|
||||||
|
btn.disabled = true;
|
||||||
|
s.querySelector('#form-err').textContent = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (this.#woId) {
|
||||||
|
// Also update status if changed
|
||||||
|
const newStatus = val('status');
|
||||||
|
if (newStatus && this.#wo?.status !== newStatus) {
|
||||||
|
await api.put(`/work-orders/${this.#woId}/status`, { status: newStatus });
|
||||||
|
}
|
||||||
|
await api.put(`/work-orders/${this.#woId}`, body);
|
||||||
|
window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: 'Work order saved', type: 'success' } }));
|
||||||
|
} else {
|
||||||
|
const created = await api.post('/work-orders', body);
|
||||||
|
window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: 'Work order created', type: 'success' } }));
|
||||||
|
this.#dirty = false;
|
||||||
|
window.dispatchEvent(new CustomEvent('wo:navigate', { detail: { path: `/work-orders/${created.id}` } }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.#dirty = false;
|
||||||
|
window.dispatchEvent(new CustomEvent('wo:navigate', { detail: { path: `/work-orders/${this.#woId}` } }));
|
||||||
|
} catch (err) {
|
||||||
|
s.querySelector('#form-err').textContent = err.message;
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#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() {
|
||||||
|
if (this.#dirty && !confirm('You have unsaved changes. Leave anyway?')) return;
|
||||||
|
if (this.#woId) {
|
||||||
|
window.dispatchEvent(new CustomEvent('wo:navigate', { detail: { path: `/work-orders/${this.#woId}` } }));
|
||||||
|
} else {
|
||||||
|
window.dispatchEvent(new CustomEvent('wo:navigate', { detail: { path: '/work-orders' } }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#esc(s) { return (s || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||||
|
}
|
||||||
|
customElements.define('wo-form', WoForm);
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { api } from '../../lib/api.mjs';
|
||||||
|
|
||||||
|
const COLUMNS = [
|
||||||
|
{ key: 'draft', label: 'Draft', color: 'var(--status-draft)' },
|
||||||
|
{ key: 'assigned', label: 'Assigned', color: 'var(--status-assigned)' },
|
||||||
|
{ key: 'scheduled', label: 'Scheduled', color: 'var(--status-scheduled)' },
|
||||||
|
{ key: 'in_progress', label: 'In Progress', color: 'var(--status-in_progress)' },
|
||||||
|
{ key: 'pending_review', label: 'Pending Review', color: 'var(--status-pending_review)' },
|
||||||
|
{ key: 'closed', label: 'Closed', color: 'var(--status-closed)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PRI_COLOR = { low:'var(--priority-low)', normal:'var(--priority-normal)', high:'var(--priority-high)', urgent:'var(--priority-urgent)' };
|
||||||
|
|
||||||
|
class WoKanban extends HTMLElement {
|
||||||
|
#data = [];
|
||||||
|
#dragging = null;
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
this.attachShadow({ mode: 'open' });
|
||||||
|
this.#load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async #load() {
|
||||||
|
try { this.#data = await api.get('/work-orders') ?? []; } catch { this.#data = []; }
|
||||||
|
this.#render();
|
||||||
|
}
|
||||||
|
|
||||||
|
#render() {
|
||||||
|
const s = this.shadowRoot;
|
||||||
|
const byStatus = Object.fromEntries(COLUMNS.map(c => [c.key, this.#data.filter(w => w.status === c.key)]));
|
||||||
|
|
||||||
|
s.innerHTML = `
|
||||||
|
<style>
|
||||||
|
:host { display: block; }
|
||||||
|
.board { display: grid; grid-template-columns: repeat(6, minmax(220px, 1fr)); gap: .75rem; overflow-x: auto; padding-bottom: .5rem; }
|
||||||
|
.col { background: var(--surface-2); border-radius: var(--radius); min-height: 400px; display: flex; flex-direction: column; }
|
||||||
|
.col-header { padding: .75rem 1rem; display: flex; align-items: center; gap: .5rem; border-bottom: 1px solid var(--border); }
|
||||||
|
.col-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
||||||
|
.col-label { font-size: .813rem; font-weight: 700; color: var(--text); }
|
||||||
|
.col-count { margin-left: auto; font-size: .75rem; font-weight: 600; color: var(--text-muted); background: var(--surface); border: 1px solid var(--border); border-radius: 999px; padding: .1rem .45rem; }
|
||||||
|
.col-body { flex: 1; padding: .5rem; display: flex; flex-direction: column; gap: .5rem; overflow-y: auto; max-height: calc(100vh - 220px); }
|
||||||
|
.wo-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: .75rem; cursor: pointer; transition: box-shadow .15s, opacity .15s; }
|
||||||
|
.wo-card:hover { box-shadow: var(--shadow-md); }
|
||||||
|
.wo-card[draggable] { cursor: grab; }
|
||||||
|
.wo-card.dragging { opacity: .4; }
|
||||||
|
.drop-zone { border: 2px dashed var(--teal); border-radius: var(--radius-sm); min-height: 60px; background: rgba(10,126,164,.05); }
|
||||||
|
.wo-num { font-size: .7rem; font-family: monospace; color: var(--text-muted); margin-bottom: .2rem; }
|
||||||
|
.wo-title { font-size: .813rem; font-weight: 600; color: var(--text); line-height: 1.4; margin-bottom: .4rem; }
|
||||||
|
.wo-site { font-size: .75rem; color: var(--text-muted); margin-bottom: .4rem; }
|
||||||
|
.card-footer { display: flex; align-items: center; gap: .4rem; }
|
||||||
|
.pri-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
|
||||||
|
.pri-label { font-size: .7rem; color: var(--text-muted); flex: 1; }
|
||||||
|
.step-prog { font-size: .7rem; color: var(--text-muted); }
|
||||||
|
@media (max-width: 768px) { .board { grid-template-columns: repeat(6, 240px); } }
|
||||||
|
</style>
|
||||||
|
<div class="board">
|
||||||
|
${COLUMNS.map(col => `
|
||||||
|
<div class="col" data-status="${col.key}" id="col-${col.key}">
|
||||||
|
<div class="col-header">
|
||||||
|
<span class="col-dot" style="background:${col.color}"></span>
|
||||||
|
<span class="col-label">${col.label}</span>
|
||||||
|
<span class="col-count">${byStatus[col.key].length}</span>
|
||||||
|
</div>
|
||||||
|
<div class="col-body" data-status="${col.key}">
|
||||||
|
${byStatus[col.key].map(wo => `
|
||||||
|
<div class="wo-card" draggable="true" data-id="${wo.id}" data-status="${wo.status}">
|
||||||
|
<div class="wo-num">${wo.wo_number}</div>
|
||||||
|
<div class="wo-title">${this.#esc(wo.title)}</div>
|
||||||
|
${wo.site_name ? `<div class="wo-site">${this.#esc(wo.site_name)}</div>` : ''}
|
||||||
|
<div class="card-footer">
|
||||||
|
<span class="pri-dot" style="background:${PRI_COLOR[wo.priority]}"></span>
|
||||||
|
<span class="pri-label">${wo.priority}</span>
|
||||||
|
${wo.step_count ? `<span class="step-prog">${wo.steps_done}/${wo.step_count}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>`).join('')}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
this.#bindDragDrop();
|
||||||
|
this.#bindClicks();
|
||||||
|
}
|
||||||
|
|
||||||
|
#bindClicks() {
|
||||||
|
this.shadowRoot.querySelectorAll('.wo-card').forEach(card => {
|
||||||
|
card.addEventListener('click', () =>
|
||||||
|
window.dispatchEvent(new CustomEvent('wo:navigate', { detail: { path: `/work-orders/${card.dataset.id}` } })));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#bindDragDrop() {
|
||||||
|
const s = this.shadowRoot;
|
||||||
|
s.querySelectorAll('.wo-card').forEach(card => {
|
||||||
|
card.addEventListener('dragstart', e => {
|
||||||
|
this.#dragging = card;
|
||||||
|
card.classList.add('dragging');
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
});
|
||||||
|
card.addEventListener('dragend', () => {
|
||||||
|
card.classList.remove('dragging');
|
||||||
|
this.#dragging = null;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
s.querySelectorAll('.col-body').forEach(zone => {
|
||||||
|
zone.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; zone.classList.add('drop-zone'); });
|
||||||
|
zone.addEventListener('dragleave', () => zone.classList.remove('drop-zone'));
|
||||||
|
zone.addEventListener('drop', async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
zone.classList.remove('drop-zone');
|
||||||
|
if (!this.#dragging) return;
|
||||||
|
const newStatus = zone.dataset.status;
|
||||||
|
const id = this.#dragging.dataset.id;
|
||||||
|
if (this.#dragging.dataset.status === newStatus) return;
|
||||||
|
try {
|
||||||
|
await api.put(`/work-orders/${id}/status`, { status: newStatus });
|
||||||
|
await this.#load();
|
||||||
|
} catch (err) {
|
||||||
|
window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: err.message, type: 'error' } }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#esc(s) { return (s || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||||
|
}
|
||||||
|
customElements.define('wo-kanban', WoKanban);
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import { api } from '../../lib/api.mjs';
|
||||||
|
import { formatDateTime } from '../../lib/format.mjs';
|
||||||
|
|
||||||
|
const STATUSES = ['draft','assigned','scheduled','in_progress','pending_review','closed'];
|
||||||
|
const PRIORITIES = ['low','normal','high','urgent'];
|
||||||
|
|
||||||
|
const STATUS_LABEL = { draft:'Draft', assigned:'Assigned', scheduled:'Scheduled', in_progress:'In Progress', pending_review:'Pending Review', closed:'Closed' };
|
||||||
|
const PRI_COLOR = { low:'var(--priority-low)', normal:'var(--priority-normal)', high:'var(--priority-high)', urgent:'var(--priority-urgent)' };
|
||||||
|
|
||||||
|
class WoList extends HTMLElement {
|
||||||
|
#data = [];
|
||||||
|
#loading = true;
|
||||||
|
#view = localStorage.getItem('wo-view') || 'list';
|
||||||
|
#filters = { status: '', search: '', priority: '' };
|
||||||
|
#debounce = null;
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
this.attachShadow({ mode: 'open' });
|
||||||
|
this.#render();
|
||||||
|
this.#load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async #load() {
|
||||||
|
this.#loading = true;
|
||||||
|
this.#render();
|
||||||
|
try {
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
if (this.#filters.status) p.set('status', this.#filters.status);
|
||||||
|
if (this.#filters.search) p.set('search', this.#filters.search);
|
||||||
|
if (this.#filters.priority) p.set('priority', this.#filters.priority);
|
||||||
|
this.#data = await api.get(`/work-orders?${p}`) ?? [];
|
||||||
|
} catch { this.#data = []; }
|
||||||
|
this.#loading = false;
|
||||||
|
this.#render();
|
||||||
|
}
|
||||||
|
|
||||||
|
#render() {
|
||||||
|
const s = this.shadowRoot;
|
||||||
|
s.innerHTML = `<style>${this.#css()}</style>${this.#html()}`;
|
||||||
|
this.#bind();
|
||||||
|
if (window.lucide) lucide.createIcons({ root: s });
|
||||||
|
}
|
||||||
|
|
||||||
|
#css() { return `
|
||||||
|
:host { display: block; }
|
||||||
|
.page-header { display:flex; align-items:center; justify-content:space-between; margin-bottom:1.25rem; }
|
||||||
|
h1 { font-size:1.25rem; font-weight:700; color:var(--text); }
|
||||||
|
.filter-bar { display:flex; align-items:center; gap:.75rem; margin-bottom:1rem; flex-wrap:wrap; }
|
||||||
|
.search-wrap { position:relative; flex:1; min-width:200px; }
|
||||||
|
.search-wrap i { position:absolute; left:.65rem; top:50%; transform:translateY(-50%); color:var(--text-muted); width:15px; height:15px; pointer-events:none; }
|
||||||
|
.search-input { border:1px solid var(--border); border-radius:var(--radius-sm); padding:.45rem .75rem .45rem 2.1rem; background:var(--surface); color:var(--text); font-size:.875rem; width:100%; transition:border-color .15s; }
|
||||||
|
.search-input:focus { outline:none; border-color:var(--teal); box-shadow:0 0 0 3px rgba(10,126,164,.15); }
|
||||||
|
select.filter { border:1px solid var(--border); border-radius:var(--radius-sm); padding:.45rem 2rem .45rem .65rem; background:var(--surface); color:var(--text); font-size:.875rem; cursor:pointer; appearance:none; background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2364748B' stroke-width='2'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E"); background-repeat:no-repeat; background-position:right .6rem center; }
|
||||||
|
.view-toggle { display:flex; border:1px solid var(--border); border-radius:var(--radius-sm); overflow:hidden; }
|
||||||
|
.view-btn { background:var(--surface); border:none; padding:.4rem .6rem; cursor:pointer; color:var(--text-muted); display:flex; transition:background .15s,color .15s; }
|
||||||
|
.view-btn.active { background:var(--teal); color:#fff; }
|
||||||
|
.new-btn { display:inline-flex; align-items:center; gap:.4rem; padding:.5rem 1rem; background:var(--teal); color:#fff; border:none; border-radius:var(--radius); font-size:.875rem; font-weight:600; cursor:pointer; white-space:nowrap; transition:opacity .15s; }
|
||||||
|
.new-btn:hover { opacity:.88; }
|
||||||
|
|
||||||
|
/* Table */
|
||||||
|
table { width:100%; border-collapse:collapse; background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); overflow:hidden; }
|
||||||
|
thead { background:var(--surface-2); }
|
||||||
|
th { text-align:left; padding:.65rem 1rem; font-size:.75rem; font-weight:700; color:var(--text-muted); text-transform:uppercase; letter-spacing:.04em; border-bottom:1px solid var(--border); white-space:nowrap; }
|
||||||
|
td { padding:.75rem 1rem; border-bottom:1px solid var(--border-lt); font-size:.875rem; color:var(--text); vertical-align:middle; }
|
||||||
|
tr:last-child td { border-bottom:none; }
|
||||||
|
tr:hover td { background:var(--surface-2); cursor:pointer; }
|
||||||
|
.wo-num { font-family:var(--font-mono,monospace); font-size:.813rem; color:var(--text-muted); }
|
||||||
|
.wo-title { font-weight:600; }
|
||||||
|
.priority-dot { width:8px; height:8px; border-radius:50%; display:inline-block; margin-right:.35rem; }
|
||||||
|
.progress-bar { height:4px; background:var(--border); border-radius:2px; width:80px; }
|
||||||
|
.progress-fill { height:100%; border-radius:2px; background:var(--teal); transition:width .3s; }
|
||||||
|
.actions { display:flex; gap:.35rem; }
|
||||||
|
.action-btn { background:none; border:none; cursor:pointer; color:var(--text-muted); padding:.3rem; border-radius:4px; display:flex; transition:color .15s,background .15s; }
|
||||||
|
.action-btn:hover { color:var(--text); background:var(--surface-2); }
|
||||||
|
.action-btn.del:hover { color:var(--danger); }
|
||||||
|
|
||||||
|
/* Mobile cards */
|
||||||
|
.card-list { display:none; flex-direction:column; gap:.6rem; }
|
||||||
|
.wo-card { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); padding:.9rem 1rem; cursor:pointer; transition:box-shadow .15s; }
|
||||||
|
.wo-card:hover { box-shadow:var(--shadow-sm); }
|
||||||
|
.card-top { display:flex; align-items:flex-start; justify-content:space-between; gap:.5rem; margin-bottom:.4rem; }
|
||||||
|
.card-meta { font-size:.75rem; color:var(--text-muted); margin-top:.25rem; }
|
||||||
|
|
||||||
|
/* Skeleton */
|
||||||
|
.skeleton { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); overflow:hidden; }
|
||||||
|
.skel-row { display:flex; gap:.75rem; padding:.75rem 1rem; border-bottom:1px solid var(--border-lt); align-items:center; }
|
||||||
|
.skel-row:last-child { border-bottom:none; }
|
||||||
|
.skel { background:linear-gradient(90deg,var(--surface-2) 25%,var(--border-lt) 50%,var(--surface-2) 75%); background-size:200% 100%; animation:shimmer 1.4s infinite; border-radius:4px; }
|
||||||
|
@keyframes shimmer { to { background-position:-200% 0; } }
|
||||||
|
|
||||||
|
@media (max-width:768px) {
|
||||||
|
.filter-bar { gap:.5rem; }
|
||||||
|
table { display:none; }
|
||||||
|
.card-list { display:flex; }
|
||||||
|
}
|
||||||
|
`; }
|
||||||
|
|
||||||
|
#html() {
|
||||||
|
if (this.#loading) return `
|
||||||
|
${this.#headerHTML()}
|
||||||
|
${this.#filterBarHTML()}
|
||||||
|
<div class="skeleton">
|
||||||
|
${[1,2,3,4,5].map(() => `<div class="skel-row">
|
||||||
|
<div class="skel" style="width:80px;height:14px"></div>
|
||||||
|
<div class="skel" style="flex:1;height:14px"></div>
|
||||||
|
<div class="skel" style="width:60px;height:14px"></div>
|
||||||
|
<div class="skel" style="width:70px;height:20px;border-radius:999px"></div>
|
||||||
|
</div>`).join('')}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
if (this.#data.length === 0) return `
|
||||||
|
${this.#headerHTML()}
|
||||||
|
${this.#filterBarHTML()}
|
||||||
|
<ui-empty icon="clipboard-list"
|
||||||
|
heading="No work orders found"
|
||||||
|
body="${this.#hasFilters() ? 'Try adjusting your filters.' : 'Create your first work order to get started.'}">
|
||||||
|
${!this.#hasFilters() ? '<button class="new-btn" id="empty-new"><i data-lucide="plus" style="width:15px;height:15px"></i> New Work Order</button>' : ''}
|
||||||
|
</ui-empty>`;
|
||||||
|
|
||||||
|
return `
|
||||||
|
${this.#headerHTML()}
|
||||||
|
${this.#filterBarHTML()}
|
||||||
|
${this.#view === 'kanban'
|
||||||
|
? '<wo-kanban></wo-kanban>'
|
||||||
|
: `${this.#tableHTML()}${this.#cardListHTML()}`}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
#headerHTML() {
|
||||||
|
return `<div class="page-header">
|
||||||
|
<h1>Work Orders</h1>
|
||||||
|
<button class="new-btn" id="new-wo">
|
||||||
|
<i data-lucide="plus" style="width:15px;height:15px"></i> New Work Order
|
||||||
|
</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
#filterBarHTML() {
|
||||||
|
const viewActive = v => this.#view === v ? ' active' : '';
|
||||||
|
return `<div class="filter-bar">
|
||||||
|
<div class="search-wrap">
|
||||||
|
<i data-lucide="search"></i>
|
||||||
|
<input class="search-input" id="search" type="search" placeholder="Search work orders…" value="${this.#filters.search}">
|
||||||
|
</div>
|
||||||
|
<select class="filter" id="filter-status">
|
||||||
|
<option value="">All statuses</option>
|
||||||
|
${STATUSES.map(s => `<option value="${s}" ${this.#filters.status===s?'selected':''}>${STATUS_LABEL[s]}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
<select class="filter" id="filter-priority">
|
||||||
|
<option value="">All priorities</option>
|
||||||
|
${PRIORITIES.map(p => `<option value="${p}" ${this.#filters.priority===p?'selected':''}>${p.charAt(0).toUpperCase()+p.slice(1)}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
<div class="view-toggle">
|
||||||
|
<button class="view-btn${viewActive('list')}" data-view="list" title="List view"> <i data-lucide="list" style="width:15px;height:15px"></i></button>
|
||||||
|
<button class="view-btn${viewActive('kanban')}" data-view="kanban" title="Kanban view"> <i data-lucide="columns-3" style="width:15px;height:15px"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
#tableHTML() {
|
||||||
|
return `<table>
|
||||||
|
<thead><tr>
|
||||||
|
<th>WO #</th><th>Title</th><th>Site</th>
|
||||||
|
<th>Status</th><th>Priority</th><th>Scheduled</th><th>Steps</th><th></th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
${this.#data.map(wo => `
|
||||||
|
<tr class="wo-row" data-id="${wo.id}">
|
||||||
|
<td class="wo-num">${wo.wo_number}</td>
|
||||||
|
<td class="wo-title">${this.#esc(wo.title)}</td>
|
||||||
|
<td>${this.#esc(wo.site_name) || '<span style="color:var(--text-muted)">—</span>'}</td>
|
||||||
|
<td><ui-badge type="status" value="${wo.status}"></ui-badge></td>
|
||||||
|
<td>
|
||||||
|
<span class="priority-dot" style="background:${PRI_COLOR[wo.priority]}"></span>
|
||||||
|
${wo.priority.charAt(0).toUpperCase()+wo.priority.slice(1)}
|
||||||
|
</td>
|
||||||
|
<td style="color:var(--text-muted)">${formatDateTime(wo.scheduled_start)}</td>
|
||||||
|
<td>
|
||||||
|
<div class="progress-bar"><div class="progress-fill" style="width:${wo.step_count ? Math.round(wo.steps_done/wo.step_count*100) : 0}%"></div></div>
|
||||||
|
<span style="font-size:.75rem;color:var(--text-muted)">${wo.steps_done}/${wo.step_count}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="action-btn edit-btn" data-id="${wo.id}" title="Edit"><i data-lucide="pencil" style="width:14px;height:14px"></i></button>
|
||||||
|
<button class="action-btn del del-btn" data-id="${wo.id}" title="Delete"><i data-lucide="trash-2" style="width:14px;height:14px"></i></button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>`).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
#cardListHTML() {
|
||||||
|
return `<div class="card-list">
|
||||||
|
${this.#data.map(wo => `
|
||||||
|
<div class="wo-card" data-id="${wo.id}">
|
||||||
|
<div class="card-top">
|
||||||
|
<div>
|
||||||
|
<div class="wo-num">${wo.wo_number}</div>
|
||||||
|
<div class="wo-title">${this.#esc(wo.title)}</div>
|
||||||
|
</div>
|
||||||
|
<ui-badge type="status" value="${wo.status}"></ui-badge>
|
||||||
|
</div>
|
||||||
|
<div class="card-meta">${this.#esc(wo.site_name) || '—'} · ${wo.steps_done}/${wo.step_count} steps</div>
|
||||||
|
</div>`).join('')}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
#hasFilters() { return !!(this.#filters.status || this.#filters.search || this.#filters.priority); }
|
||||||
|
#esc(s) { return (s || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||||
|
|
||||||
|
#nav(path) { window.dispatchEvent(new CustomEvent('wo:navigate', { detail: { path } })); }
|
||||||
|
|
||||||
|
#bind() {
|
||||||
|
const s = this.shadowRoot;
|
||||||
|
|
||||||
|
s.querySelector('#new-wo')?.addEventListener('click', () => this.#nav('/work-orders/new'));
|
||||||
|
s.querySelector('#empty-new')?.addEventListener('click', () => this.#nav('/work-orders/new'));
|
||||||
|
|
||||||
|
s.querySelector('#search')?.addEventListener('input', e => {
|
||||||
|
clearTimeout(this.#debounce);
|
||||||
|
this.#debounce = setTimeout(() => { this.#filters.search = e.target.value; this.#load(); }, 350);
|
||||||
|
});
|
||||||
|
s.querySelector('#filter-status')?.addEventListener('change', e => { this.#filters.status = e.target.value; this.#load(); });
|
||||||
|
s.querySelector('#filter-priority')?.addEventListener('change', e => { this.#filters.priority = e.target.value; this.#load(); });
|
||||||
|
|
||||||
|
s.querySelectorAll('.view-btn').forEach(btn =>
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
this.#view = btn.dataset.view;
|
||||||
|
localStorage.setItem('wo-view', this.#view);
|
||||||
|
this.#render();
|
||||||
|
}));
|
||||||
|
|
||||||
|
s.querySelectorAll('.wo-row').forEach(row =>
|
||||||
|
row.addEventListener('click', e => {
|
||||||
|
if (e.target.closest('.actions')) return;
|
||||||
|
this.#nav(`/work-orders/${row.dataset.id}`);
|
||||||
|
}));
|
||||||
|
|
||||||
|
s.querySelectorAll('.wo-card').forEach(card =>
|
||||||
|
card.addEventListener('click', () => this.#nav(`/work-orders/${card.dataset.id}`)));
|
||||||
|
|
||||||
|
s.querySelectorAll('.edit-btn').forEach(btn =>
|
||||||
|
btn.addEventListener('click', e => { e.stopPropagation(); this.#nav(`/work-orders/${btn.dataset.id}/edit`); }));
|
||||||
|
|
||||||
|
s.querySelectorAll('.del-btn').forEach(btn =>
|
||||||
|
btn.addEventListener('click', async e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!confirm('Delete this work order?')) return;
|
||||||
|
try {
|
||||||
|
await api.delete(`/work-orders/${btn.dataset.id}`);
|
||||||
|
window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: 'Work order deleted', type: 'success' } }));
|
||||||
|
this.#load();
|
||||||
|
} catch (err) {
|
||||||
|
window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: err.message, type: 'error' } }));
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
customElements.define('wo-list', WoList);
|
||||||
@@ -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({ root: 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,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
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({ root: 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({ root: 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({ root: lb });
|
||||||
|
lb.querySelector('.lightbox-close').addEventListener('click', () => lb.remove());
|
||||||
|
lb.addEventListener('click', e => { if (e.target === lb) lb.remove(); });
|
||||||
|
lb.querySelector('.lightbox-prev')?.addEventListener('click', e => { e.stopPropagation(); show(i - 1); });
|
||||||
|
lb.querySelector('.lightbox-next')?.addEventListener('click', e => { e.stopPropagation(); show(i + 1); });
|
||||||
|
};
|
||||||
|
show(idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
#esc(s) { return (s || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
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({ root: 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,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('wo-resource-panel', WoResourcePanel);
|
||||||
@@ -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({ root: 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,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('wo-timeline', WoTimeline);
|
||||||
+22
-7
@@ -2,18 +2,33 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Work Orders</title>
|
<title>Work Orders</title>
|
||||||
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='6' fill='%230A7EA4'/><path d='M8 10h16M8 16h16M8 22h10' stroke='white' stroke-width='2.5' stroke-linecap='round'/></svg>">
|
||||||
|
|
||||||
|
<!-- Fonts: Inter (UI) + JetBrains Mono (WO numbers) -->
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||||
|
|
||||||
|
<!-- Icons: Lucide (tree-shakeable, no API key) -->
|
||||||
|
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
|
||||||
|
|
||||||
|
<!-- Leaflet (Phase 2 — maps) -->
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9/dist/leaflet.css">
|
||||||
|
<script src="https://unpkg.com/leaflet@1.9/dist/leaflet.js" defer></script>
|
||||||
|
|
||||||
|
<!-- Chart.js (Phase 4 — dashboard charts) -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js" defer></script>
|
||||||
|
|
||||||
|
<!-- App styles -->
|
||||||
<link rel="stylesheet" href="/styles/reset.css">
|
<link rel="stylesheet" href="/styles/reset.css">
|
||||||
<link rel="stylesheet" href="/styles/global.css">
|
<link rel="stylesheet" href="/styles/global.css">
|
||||||
|
<link rel="stylesheet" href="/styles/typography.css">
|
||||||
<!-- Keycloak JS adapter (served by Keycloak itself) -->
|
<link rel="stylesheet" href="/styles/forms.css">
|
||||||
<script src="http://localhost:8180/js/keycloak.js"></script>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="root"></div>
|
||||||
<p style="padding:2rem;color:#64748b">Connecting to authentication server...</p>
|
|
||||||
</div>
|
|
||||||
<script type="module" src="/app.mjs"></script>
|
<script type="module" src="/app.mjs"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+13
-10
@@ -1,26 +1,29 @@
|
|||||||
|
import { getToken, clearToken } from './auth.mjs';
|
||||||
|
|
||||||
const BASE = '/api';
|
const BASE = '/api';
|
||||||
let _token = '';
|
|
||||||
|
|
||||||
export function setToken(t) { _token = t; }
|
async function request(method, path, body, isFormData = false) {
|
||||||
export function getToken() { return _token; }
|
const token = getToken();
|
||||||
|
const headers = {};
|
||||||
async function request(method, path, body, isForm = false) {
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||||
const headers = { Authorization: `Bearer ${_token}` };
|
if (!isFormData && body !== undefined) headers['Content-Type'] = 'application/json';
|
||||||
if (!isForm && body) headers['Content-Type'] = 'application/json';
|
|
||||||
|
|
||||||
const res = await fetch(BASE + path, {
|
const res = await fetch(BASE + path, {
|
||||||
method,
|
method,
|
||||||
headers,
|
headers,
|
||||||
body: body ? (isForm ? body : JSON.stringify(body)) : undefined,
|
body: body !== undefined
|
||||||
|
? (isFormData ? body : JSON.stringify(body))
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
|
clearToken();
|
||||||
window.dispatchEvent(new CustomEvent('auth:expired'));
|
window.dispatchEvent(new CustomEvent('auth:expired'));
|
||||||
return null;
|
throw new Error('Session expired');
|
||||||
}
|
}
|
||||||
|
|
||||||
const json = await res.json().catch(() => ({}));
|
const json = await res.json().catch(() => ({}));
|
||||||
if (!res.ok) throw new Error(json.error || `HTTP ${res.status}`);
|
if (!res.ok) throw new Error(json.error || `Request failed (${res.status})`);
|
||||||
return json.data;
|
return json.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
const TOKEN_KEY = 'wo_token';
|
||||||
|
const USER_KEY = 'wo_user';
|
||||||
|
|
||||||
|
export function getToken() {
|
||||||
|
return localStorage.getItem(TOKEN_KEY) || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setToken(token) {
|
||||||
|
localStorage.setItem(TOKEN_KEY, token);
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||||
|
localStorage.setItem(USER_KEY, JSON.stringify({
|
||||||
|
id: payload.uid,
|
||||||
|
username: payload.username,
|
||||||
|
email: payload.email,
|
||||||
|
displayName: payload.name,
|
||||||
|
role: payload.role,
|
||||||
|
}));
|
||||||
|
} catch { /* ignore decode errors */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearToken() {
|
||||||
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
localStorage.removeItem(USER_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUser() {
|
||||||
|
const token = getToken();
|
||||||
|
if (!token) return null;
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||||
|
if (payload.exp && payload.exp * 1000 < Date.now()) {
|
||||||
|
clearToken();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const stored = localStorage.getItem(USER_KEY);
|
||||||
|
return stored ? JSON.parse(stored) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROLE_LEVEL = { admin: 4, dispatcher: 3, field_tech: 2, viewer: 1 };
|
||||||
|
|
||||||
|
export function hasRole(minRole) {
|
||||||
|
const user = getUser();
|
||||||
|
if (!user) return false;
|
||||||
|
return (ROLE_LEVEL[user.role] || 0) >= (ROLE_LEVEL[minRole] || 0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
export function formatDate(iso) {
|
||||||
|
if (!iso) return '—';
|
||||||
|
return new Date(iso).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric', month: 'short', day: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateTime(iso) {
|
||||||
|
if (!iso) return '—';
|
||||||
|
return new Date(iso).toLocaleString('en-US', {
|
||||||
|
month: 'short', day: 'numeric', year: 'numeric',
|
||||||
|
hour: 'numeric', minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRelative(iso) {
|
||||||
|
if (!iso) return '—';
|
||||||
|
const diff = Date.now() - new Date(iso).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 formatDate(iso);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPhone(phone) {
|
||||||
|
if (!phone) return '—';
|
||||||
|
const d = phone.replace(/\D/g, '');
|
||||||
|
return d.length === 10
|
||||||
|
? `(${d.slice(0,3)}) ${d.slice(3,6)}-${d.slice(6)}`
|
||||||
|
: phone;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toLocalDatetime(iso) {
|
||||||
|
if (!iso) return '';
|
||||||
|
return new Date(iso).toISOString().slice(0, 16);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
class Router {
|
||||||
|
#routes = [];
|
||||||
|
|
||||||
|
on(pattern, handler) {
|
||||||
|
const regex = new RegExp(
|
||||||
|
'^' + pattern.replace(/:([^/]+)/g, '(?<$1>[^/]+)') + '$'
|
||||||
|
);
|
||||||
|
this.#routes.push({ regex, handler });
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
navigate(path) {
|
||||||
|
location.hash = '#' + path;
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
const dispatch = () => {
|
||||||
|
const path = decodeURIComponent(location.hash.slice(1)) || '/';
|
||||||
|
for (const { regex, handler } of this.#routes) {
|
||||||
|
const m = path.match(regex);
|
||||||
|
if (m) { handler(m.groups || {}); return; }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('hashchange', dispatch);
|
||||||
|
dispatch();
|
||||||
|
}
|
||||||
|
|
||||||
|
get current() {
|
||||||
|
return decodeURIComponent(location.hash.slice(1)) || '/';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const router = new Router();
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export function createStore(initial) {
|
||||||
|
let state = { ...initial };
|
||||||
|
const subscribers = new Set();
|
||||||
|
|
||||||
|
return {
|
||||||
|
get() { return { ...state }; },
|
||||||
|
set(updates) { state = { ...state, ...updates }; subscribers.forEach(fn => fn(state)); },
|
||||||
|
subscribe(fn) { subscribers.add(fn); return () => subscribers.delete(fn); },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
/* Shared form control styles — applied to light DOM forms */
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .35rem;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--weight-semibold);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
input, select, textarea {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: .5rem .75rem;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
width: 100%;
|
||||||
|
transition: border-color .15s, box-shadow .15s;
|
||||||
|
color-scheme: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus, select:focus, textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--teal);
|
||||||
|
box-shadow: 0 0 0 3px rgba(10,126,164,.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
input::placeholder, textarea::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 80px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
cursor: pointer;
|
||||||
|
appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2364748B' stroke-width='2'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right .75rem center;
|
||||||
|
padding-right: 2.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="datetime-local"],
|
||||||
|
input[type="date"],
|
||||||
|
input[type="time"] {
|
||||||
|
color-scheme: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="checkbox"],
|
||||||
|
input[type="radio"] {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
accent-color: var(--teal);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-error {
|
||||||
|
color: var(--danger);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
margin-top: .25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.form-row { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
+241
-126
@@ -1,156 +1,271 @@
|
|||||||
/* ── Light mode (default) ─────────────────────────────────────────────────── */
|
/* ── Design Tokens — Light Mode ──────────────────────────────────────────────── */
|
||||||
:root {
|
:root {
|
||||||
--navy: #0d2137;
|
/* Brand */
|
||||||
--accent: #0a7ea4;
|
--navy: #0D2137;
|
||||||
--accent-lt: #14b8d4;
|
--teal: #0A7EA4;
|
||||||
--surface: #ffffff;
|
--teal-lt: #14B8D4;
|
||||||
--surface-2: #f8fafc;
|
--teal-dk: #075E7A;
|
||||||
--bg: #f0f6fa;
|
|
||||||
--border: #e2ebf0;
|
/* Surfaces */
|
||||||
--text: #1a2e3b;
|
--bg: #F0F6FA;
|
||||||
--muted: #64748b;
|
--surface: #FFFFFF;
|
||||||
--danger: #c0392b;
|
--surface-2: #E8F0F5;
|
||||||
--success: #1d9d6c;
|
--border: #D1DDE6;
|
||||||
--warning: #e07b39;
|
--border-lt: #E8F0F5;
|
||||||
|
|
||||||
|
/* Text */
|
||||||
|
--text: #1A2E3B;
|
||||||
|
--text-muted: #64748B;
|
||||||
|
--text-inv: #FFFFFF;
|
||||||
|
|
||||||
|
/* Semantic */
|
||||||
|
--success: #1D9D6C;
|
||||||
|
--warning: #E07B39;
|
||||||
|
--danger: #C0392B;
|
||||||
|
--info: #0A7EA4;
|
||||||
|
|
||||||
|
/* Shadows */
|
||||||
|
--shadow-sm: 0 1px 3px rgba(0,0,0,.08);
|
||||||
|
--shadow-md: 0 4px 12px rgba(0,0,0,.10);
|
||||||
|
--shadow-lg: 0 8px 24px rgba(0,0,0,.12);
|
||||||
|
|
||||||
|
/* Radius */
|
||||||
|
--radius-sm: 4px;
|
||||||
--radius: 8px;
|
--radius: 8px;
|
||||||
--shadow: 0 2px 8px rgba(0,0,0,.08);
|
--radius-lg: 12px;
|
||||||
|
--radius-xl: 16px;
|
||||||
|
|
||||||
/* Status badge colours — light */
|
/* Sidebar */
|
||||||
--badge-draft-bg: #e2e8f0; --badge-draft-text: #475569;
|
--sidebar-bg: #0D2137;
|
||||||
--badge-assigned-bg: #dbeafe; --badge-assigned-text: #1d4ed8;
|
--sidebar-hover: #153248;
|
||||||
--badge-scheduled-bg: #fef3c7; --badge-scheduled-text: #92400e;
|
--sidebar-active: #0A7EA4;
|
||||||
--badge-in_progress-bg: #d1fae5; --badge-in_progress-text: #065f46;
|
--sidebar-w: 260px;
|
||||||
--badge-pending_review-bg: #ede9fe; --badge-pending_review-text: #5b21b6;
|
--sidebar-collapsed: 64px;
|
||||||
--badge-closed-bg: #f3f4f6; --badge-closed-text: #6b7280;
|
|
||||||
|
/* Status colours */
|
||||||
|
--status-draft: #94A3B8;
|
||||||
|
--status-assigned: #0A7EA4;
|
||||||
|
--status-scheduled: #8B5CF6;
|
||||||
|
--status-in_progress: #E07B39;
|
||||||
|
--status-pending_review: #D97706;
|
||||||
|
--status-closed: #1D9D6C;
|
||||||
|
|
||||||
|
/* Status badge backgrounds (light tint) */
|
||||||
|
--status-draft-bg: #F1F5F9;
|
||||||
|
--status-assigned-bg: #E0F2FE;
|
||||||
|
--status-scheduled-bg: #EDE9FE;
|
||||||
|
--status-in_progress-bg: #FFF7ED;
|
||||||
|
--status-pending_review-bg: #FFFBEB;
|
||||||
|
--status-closed-bg: #DCFCE7;
|
||||||
|
|
||||||
|
/* Priority colours */
|
||||||
|
--priority-low: #64748B;
|
||||||
|
--priority-normal: #0A7EA4;
|
||||||
|
--priority-high: #E07B39;
|
||||||
|
--priority-urgent: #C0392B;
|
||||||
|
|
||||||
font-family: Calibri, 'Segoe UI', system-ui, sans-serif;
|
|
||||||
font-size: 16px;
|
|
||||||
color: var(--text);
|
|
||||||
background: var(--bg);
|
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Dark mode ────────────────────────────────────────────────────────────── */
|
/* ── Dark Mode ───────────────────────────────────────────────────────────────── */
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root {
|
:root { --_dark: 1; }
|
||||||
--navy: #0a1929;
|
}
|
||||||
--accent: #29b6d8;
|
:root[data-theme="dark"] { --_dark: 1; }
|
||||||
--accent-lt: #4dd0e8;
|
|
||||||
--surface: #0f2336;
|
|
||||||
--surface-2: #0c1d2e;
|
|
||||||
--bg: #081828;
|
|
||||||
--border: #1e3a52;
|
|
||||||
--text: #e2eaf2;
|
|
||||||
--muted: #8baabf;
|
|
||||||
--danger: #f87171;
|
|
||||||
--success: #34d399;
|
|
||||||
--warning: #fbbf24;
|
|
||||||
--shadow: 0 2px 12px rgba(0,0,0,.4);
|
|
||||||
|
|
||||||
/* Status badge colours — dark (muted tints so they don't blaze) */
|
@media (prefers-color-scheme: dark) {
|
||||||
--badge-draft-bg: #1e2d3d; --badge-draft-text: #94a3b8;
|
:root:not([data-theme="light"]) {
|
||||||
--badge-assigned-bg: #1e3a5f; --badge-assigned-text: #93c5fd;
|
--navy: #0A1929;
|
||||||
--badge-scheduled-bg: #3d2e00; --badge-scheduled-text: #fde68a;
|
--teal: #29B6D8;
|
||||||
--badge-in_progress-bg: #064e30; --badge-in_progress-text: #6ee7b7;
|
--teal-lt: #4DD0E8;
|
||||||
--badge-pending_review-bg: #2e1f5e; --badge-pending_review-text: #c4b5fd;
|
--teal-dk: #1A8FAD;
|
||||||
--badge-closed-bg: #1c2a38; --badge-closed-text: #6b7280;
|
|
||||||
|
--bg: #081828;
|
||||||
|
--surface: #0F2336;
|
||||||
|
--surface-2: #0C1D2E;
|
||||||
|
--border: #1E3A52;
|
||||||
|
--border-lt: #1A3048;
|
||||||
|
|
||||||
|
--text: #E2EAF2;
|
||||||
|
--text-muted: #8BAABF;
|
||||||
|
--text-inv: #FFFFFF;
|
||||||
|
|
||||||
|
--success: #34D399;
|
||||||
|
--warning: #FBBF24;
|
||||||
|
--danger: #F87171;
|
||||||
|
--info: #29B6D8;
|
||||||
|
|
||||||
|
--shadow-sm: 0 1px 3px rgba(0,0,0,.3);
|
||||||
|
--shadow-md: 0 4px 12px rgba(0,0,0,.4);
|
||||||
|
--shadow-lg: 0 8px 24px rgba(0,0,0,.5);
|
||||||
|
|
||||||
|
--sidebar-bg: #061220;
|
||||||
|
--sidebar-hover: #0D1F32;
|
||||||
|
--sidebar-active: #1A5C78;
|
||||||
|
|
||||||
|
--status-draft-bg: #1E2D3D;
|
||||||
|
--status-assigned-bg: #1E3A5F;
|
||||||
|
--status-scheduled-bg: #2E1F5E;
|
||||||
|
--status-in_progress-bg: #3D2E00;
|
||||||
|
--status-pending_review-bg: #3D2800;
|
||||||
|
--status-closed-bg: #064E30;
|
||||||
|
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
:root[data-theme="dark"] {
|
||||||
|
--navy: #0A1929;
|
||||||
|
--teal: #29B6D8;
|
||||||
|
--teal-lt: #4DD0E8;
|
||||||
|
--bg: #081828;
|
||||||
|
--surface: #0F2336;
|
||||||
|
--surface-2: #0C1D2E;
|
||||||
|
--border: #1E3A52;
|
||||||
|
--border-lt: #1A3048;
|
||||||
|
--text: #E2EAF2;
|
||||||
|
--text-muted: #8BAABF;
|
||||||
|
--shadow-sm: 0 1px 3px rgba(0,0,0,.3);
|
||||||
|
--shadow-md: 0 4px 12px rgba(0,0,0,.4);
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Reset ────────────────────────────────────────────────────────────────── */
|
/* ── Base ────────────────────────────────────────────────────────────────────── */
|
||||||
body { min-height: 100vh; }
|
body {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
a { color: var(--accent); text-decoration: none; }
|
a { color: var(--teal); text-decoration: none; }
|
||||||
a:hover { text-decoration: underline; }
|
a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
/* ── Buttons ──────────────────────────────────────────────────────────────── */
|
/* ── App Layout (Light DOM — no shadow root) ─────────────────────────────────── */
|
||||||
button {
|
app-root {
|
||||||
cursor: pointer;
|
display: grid;
|
||||||
border: none;
|
grid-template-columns: var(--sidebar-w) 1fr;
|
||||||
border-radius: var(--radius);
|
grid-template-rows: 1fr;
|
||||||
padding: .5rem 1rem;
|
min-height: 100vh;
|
||||||
background: var(--accent);
|
|
||||||
color: #fff;
|
|
||||||
font-weight: 600;
|
|
||||||
transition: opacity .15s;
|
|
||||||
}
|
}
|
||||||
button:hover { opacity: .85; }
|
app-root.collapsed {
|
||||||
button.secondary {
|
grid-template-columns: var(--sidebar-collapsed) 1fr;
|
||||||
background: transparent;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
color: var(--text);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Form controls ────────────────────────────────────────────────────────── */
|
.app-shell {
|
||||||
input, select, textarea {
|
display: grid;
|
||||||
border: 1px solid var(--border);
|
grid-template-rows: 56px 1fr;
|
||||||
border-radius: var(--radius);
|
min-height: 100vh;
|
||||||
padding: .5rem .75rem;
|
overflow: hidden;
|
||||||
width: 100%;
|
}
|
||||||
|
|
||||||
|
#main-content {
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1.5rem;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
app-mobile-nav { display: none; }
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
app-root,
|
||||||
|
app-root.collapsed {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: 56px 1fr;
|
||||||
|
}
|
||||||
|
.app-shell { grid-template-rows: 56px 1fr; }
|
||||||
|
app-sidebar { display: none !important; }
|
||||||
|
app-mobile-nav { display: flex; }
|
||||||
|
#main-content { padding: 1rem; padding-bottom: calc(56px + 1rem); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Sidebar (Light DOM) ──────────────────────────────────────────────────────── */
|
||||||
|
app-sidebar {
|
||||||
|
background: var(--sidebar-bg);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: width .2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Topbar (Light DOM) ───────────────────────────────────────────────────────── */
|
||||||
|
app-topbar {
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
color: var(--text);
|
border-bottom: 1px solid var(--border);
|
||||||
transition: border-color .15s;
|
|
||||||
}
|
|
||||||
input:focus, select:focus, textarea:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Native date/time pickers pick up OS colour scheme via color-scheme property */
|
|
||||||
input[type="datetime-local"] { color-scheme: inherit; }
|
|
||||||
|
|
||||||
/* ── Cards ────────────────────────────────────────────────────────────────── */
|
|
||||||
.card {
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
padding: 1.25rem;
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Status badges ────────────────────────────────────────────────────────── */
|
|
||||||
.badge {
|
|
||||||
display: inline-block;
|
|
||||||
padding: .2rem .6rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
font-size: .75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: .04em;
|
|
||||||
}
|
|
||||||
.badge-draft { background: var(--badge-draft-bg); color: var(--badge-draft-text); }
|
|
||||||
.badge-assigned { background: var(--badge-assigned-bg); color: var(--badge-assigned-text); }
|
|
||||||
.badge-scheduled { background: var(--badge-scheduled-bg); color: var(--badge-scheduled-text); }
|
|
||||||
.badge-in_progress { background: var(--badge-in_progress-bg); color: var(--badge-in_progress-text); }
|
|
||||||
.badge-pending_review { background: var(--badge-pending_review-bg); color: var(--badge-pending_review-text); }
|
|
||||||
.badge-closed { background: var(--badge-closed-bg); color: var(--badge-closed-text); }
|
|
||||||
|
|
||||||
/* ── Layout ───────────────────────────────────────────────────────────────── */
|
|
||||||
#app { max-width: 1200px; margin: 0 auto; padding: 0 1rem; }
|
|
||||||
|
|
||||||
/* ── Nav ──────────────────────────────────────────────────────────────────── */
|
|
||||||
nav {
|
|
||||||
background: var(--navy);
|
|
||||||
color: #fff;
|
|
||||||
padding: .75rem 1.5rem;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1.5rem;
|
padding: 0 1.25rem;
|
||||||
border-bottom: 1px solid rgba(255,255,255,.06);
|
height: 56px;
|
||||||
|
gap: 1rem;
|
||||||
|
z-index: 10;
|
||||||
}
|
}
|
||||||
nav .brand { font-weight: 700; font-size: 1.1rem; color: #fff; }
|
|
||||||
nav a { color: rgba(255,255,255,.8); font-size: .9rem; }
|
|
||||||
nav a:hover { color: #fff; text-decoration: none; }
|
|
||||||
nav .spacer { flex: 1; }
|
|
||||||
nav .user-info { font-size: .85rem; color: rgba(255,255,255,.6); }
|
|
||||||
|
|
||||||
/* ── Page header ──────────────────────────────────────────────────────────── */
|
/* ── Mobile Nav (Light DOM) ───────────────────────────────────────────────────── */
|
||||||
|
app-mobile-nav {
|
||||||
|
background: var(--surface);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 56px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-around;
|
||||||
|
z-index: 100;
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Page Header ──────────────────────────────────────────────────────────────── */
|
||||||
.page-header {
|
.page-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 1.5rem 0 1rem;
|
margin-bottom: 1.25rem;
|
||||||
}
|
}
|
||||||
.page-header h1 { font-size: 1.4rem; }
|
.page-header h1 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Cards ────────────────────────────────────────────────────────────────────── */
|
||||||
|
.card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Buttons ──────────────────────────────────────────────────────────────────── */
|
||||||
|
button, .btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .4rem;
|
||||||
|
padding: .5rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: .875rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity .15s, background .15s;
|
||||||
|
background: var(--teal);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
button:hover, .btn:hover { opacity: .88; }
|
||||||
|
button.ghost, .btn-ghost {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
button.danger, .btn-danger {
|
||||||
|
background: var(--danger);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
button:disabled { opacity: .5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* ── Utility ──────────────────────────────────────────────────────────────────── */
|
||||||
|
.text-muted { color: var(--text-muted); }
|
||||||
|
.text-sm { font-size: .813rem; }
|
||||||
|
.mono { font-family: var(--font-mono); }
|
||||||
|
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
html { -webkit-text-size-adjust: 100%; }
|
html { -webkit-text-size-adjust: 100%; }
|
||||||
body { line-height: 1.5; -webkit-font-smoothing: antialiased; }
|
body { line-height: 1.5; -webkit-font-smoothing: antialiased; }
|
||||||
img, svg { display: block; max-width: 100%; }
|
img, picture, video, canvas, svg { display: block; max-width: 100%; }
|
||||||
input, button, textarea, select { font: inherit; }
|
input, button, textarea, select { font: inherit; }
|
||||||
p, h1, h2, h3, h4, h5, h6 { overflow-wrap: break-word; }
|
p, h1, h2, h3, h4, h5, h6 { overflow-wrap: break-word; }
|
||||||
|
ul, ol { list-style: none; }
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
:root {
|
||||||
|
--font-body: 'Inter', 'Segoe UI', system-ui, sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', 'Cascadia Code', 'Consolas', monospace;
|
||||||
|
|
||||||
|
--text-xs: 0.70rem;
|
||||||
|
--text-sm: 0.813rem;
|
||||||
|
--text-base: 0.938rem;
|
||||||
|
--text-md: 1.063rem;
|
||||||
|
--text-lg: 1.25rem;
|
||||||
|
--text-xl: 1.5rem;
|
||||||
|
--text-2xl: 2rem;
|
||||||
|
|
||||||
|
--weight-normal: 400;
|
||||||
|
--weight-medium: 500;
|
||||||
|
--weight-semibold: 600;
|
||||||
|
--weight-bold: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 { font-size: var(--text-xl); font-weight: var(--weight-bold); }
|
||||||
|
h2 { font-size: var(--text-lg); font-weight: var(--weight-semibold); }
|
||||||
|
h3 { font-size: var(--text-md); font-weight: var(--weight-semibold); }
|
||||||
|
h4 { font-size: var(--text-base); font-weight: var(--weight-medium); }
|
||||||
|
|
||||||
|
.text-xs { font-size: var(--text-xs); }
|
||||||
|
.text-sm { font-size: var(--text-sm); }
|
||||||
|
.text-base { font-size: var(--text-base); }
|
||||||
|
.text-lg { font-size: var(--text-lg); }
|
||||||
|
.font-mono { font-family: var(--font-mono); }
|
||||||
|
.font-medium { font-weight: var(--weight-medium); }
|
||||||
|
.font-semibold { font-weight: var(--weight-semibold); }
|
||||||
|
.font-bold { font-weight: var(--weight-bold); }
|
||||||
Reference in New Issue
Block a user