From e132c7a5802387dbb2658f56732c0e3ca2b53c19 Mon Sep 17 00:00:00 2001 From: Thomas Nilles Date: Sat, 16 May 2026 18:54:23 -0400 Subject: [PATCH] Add user database migration, core reusable components, and layout structure --- cmd/server/main.go | 28 +- go.mod | 2 +- internal/api/handlers/auth.go | 105 +++++- internal/api/middleware.go | 195 +++------- internal/api/router.go | 13 +- internal/config/config.go | 29 +- internal/model/models.go | 53 ++- internal/repository/migrations/002_users.sql | 16 + web/app.mjs | 200 ++++++---- web/components/layout/app-mobile-nav.mjs | 41 +++ web/components/layout/app-root.mjs | 26 ++ web/components/layout/app-sidebar.mjs | 141 +++++++ web/components/layout/app-topbar.mjs | 104 ++++++ web/components/shared/ui-badge.mjs | 52 +++ web/components/shared/ui-button.mjs | 47 +++ web/components/shared/ui-dialog.mjs | 56 +++ web/components/shared/ui-empty.mjs | 35 ++ web/components/shared/ui-spinner.mjs | 21 ++ web/components/shared/ui-toast.mjs | 51 +++ web/components/work-orders/wo-detail.mjs | 250 +++++++++++++ web/components/work-orders/wo-form.mjs | 221 +++++++++++ web/components/work-orders/wo-kanban.mjs | 127 +++++++ web/components/work-orders/wo-list.mjs | 259 +++++++++++++ web/index.html | 28 +- web/lib/api.mjs | 23 +- web/lib/auth.mjs | 49 +++ web/lib/format.mjs | 40 ++ web/lib/router.mjs | 33 ++ web/lib/store.mjs | 10 + web/styles/forms.css | 82 +++++ web/styles/global.css | 369 ++++++++++++------- web/styles/reset.css | 3 +- web/styles/typography.css | 37 ++ 33 files changed, 2348 insertions(+), 398 deletions(-) create mode 100644 internal/repository/migrations/002_users.sql create mode 100644 web/components/layout/app-mobile-nav.mjs create mode 100644 web/components/layout/app-root.mjs create mode 100644 web/components/layout/app-sidebar.mjs create mode 100644 web/components/layout/app-topbar.mjs create mode 100644 web/components/shared/ui-badge.mjs create mode 100644 web/components/shared/ui-button.mjs create mode 100644 web/components/shared/ui-dialog.mjs create mode 100644 web/components/shared/ui-empty.mjs create mode 100644 web/components/shared/ui-spinner.mjs create mode 100644 web/components/shared/ui-toast.mjs create mode 100644 web/components/work-orders/wo-detail.mjs create mode 100644 web/components/work-orders/wo-form.mjs create mode 100644 web/components/work-orders/wo-kanban.mjs create mode 100644 web/components/work-orders/wo-list.mjs create mode 100644 web/lib/auth.mjs create mode 100644 web/lib/format.mjs create mode 100644 web/lib/router.mjs create mode 100644 web/lib/store.mjs create mode 100644 web/styles/forms.css create mode 100644 web/styles/typography.css diff --git a/cmd/server/main.go b/cmd/server/main.go index d4e93d9..d9ee986 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -5,14 +5,15 @@ import ( "net/http" "os" + "github.com/jmoiron/sqlx" "github.com/joho/godotenv" + "golang.org/x/crypto/bcrypt" "workorders/internal/api" "workorders/internal/config" "workorders/internal/repository" ) func main() { - // Load .env if present (ignored in production containers where env is injected) if _, err := os.Stat(".env"); err == nil { if err := godotenv.Load(); err != nil { log.Printf("warning: could not load .env: %v", err) @@ -33,8 +34,10 @@ func main() { log.Fatalf("migrations: %v", err) } - log.Printf("fetching JWKS from %s", cfg.JWKSUrl) - api.InitJWKS(cfg.JWKSUrl) + log.Printf("seeding default admin...") + if err := seedAdmin(db, cfg.AdminPassword); err != nil { + log.Printf("warning: seed admin: %v", err) + } r := api.NewRouter(cfg, db) @@ -43,3 +46,22 @@ func main() { 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 +} diff --git a/go.mod b/go.mod index 8c43e23..63c26b0 100644 --- a/go.mod +++ b/go.mod @@ -9,11 +9,11 @@ require ( github.com/jmoiron/sqlx v1.3.5 github.com/joho/godotenv v1.5.1 github.com/microsoft/go-mssqldb v1.7.2 + golang.org/x/crypto v0.18.0 ) require ( github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // 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 ) diff --git a/internal/api/handlers/auth.go b/internal/api/handlers/auth.go index 753f89f..dea4c04 100644 --- a/internal/api/handlers/auth.go +++ b/internal/api/handlers/auth.go @@ -1,7 +1,110 @@ 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) { 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, + } +} diff --git a/internal/api/middleware.go b/internal/api/middleware.go index c60030d..eb1df20 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -2,162 +2,68 @@ package api import ( "context" - "crypto/rsa" - "encoding/base64" - "encoding/json" "fmt" - "log" - "math/big" "net/http" "strings" - "sync" - "time" "github.com/golang-jwt/jwt/v5" "workorders/internal/model" ) -// jwksCache caches the public keys from Keycloak -type jwksCache struct { - mu sync.RWMutex - keys map[string]*rsa.PublicKey - fetchAt time.Time - url string +var roleLevel = map[string]int{ + "admin": 4, "dispatcher": 3, "field_tech": 2, "viewer": 1, } -var cache = &jwksCache{} - -func InitJWKS(url string) { - 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) { - auth := r.Header.Get("Authorization") - if !strings.HasPrefix(auth, "Bearer ") { - jsonError(w, "unauthorized", http.StatusUnauthorized) - return - } - raw := strings.TrimPrefix(auth, "Bearer ") - - token, err := jwt.Parse(raw, func(t *jwt.Token) (any, error) { - if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok { - return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) +// JWTAuth validates a locally-issued HMAC-signed JWT in the Authorization header. +func JWTAuth(secret string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + auth := r.Header.Get("Authorization") + if !strings.HasPrefix(auth, "Bearer ") { + jsonError(w, "unauthorized", http.StatusUnauthorized) + return } - kid, _ := t.Header["kid"].(string) - return cache.get(kid) - }, jwt.WithExpirationRequired()) + raw := strings.TrimPrefix(auth, "Bearer ") - if err != nil || !token.Valid { - jsonError(w, "invalid token", http.StatusUnauthorized) - return - } - - claims, ok := token.Claims.(jwt.MapClaims) - if !ok { - jsonError(w, "invalid claims", http.StatusUnauthorized) - return - } - - user := model.UserClaims{ - Sub: stringClaim(claims, "sub"), - Email: stringClaim(claims, "email"), - Name: stringClaim(claims, "name"), - } - 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) - } + var claims model.LocalClaims + _, 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 []byte(secret), nil + }, jwt.WithExpirationRequired()) - ctx := context.WithValue(r.Context(), model.CtxUserKey, user) - next.ServeHTTP(w, r.WithContext(ctx)) - }) + if err != nil { + jsonError(w, "invalid token", http.StatusUnauthorized) + return + } + + user := model.UserClaims{ + UserID: claims.UserID, + Username: claims.Username, + Email: claims.Email, + DisplayName: claims.DisplayName, + Role: claims.Role, + } + ctx := context.WithValue(r.Context(), model.CtxUserKey, user) + 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 { @@ -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) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) diff --git a/internal/api/router.go b/internal/api/router.go index 3a9669b..4f4ecf3 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -20,9 +20,16 @@ func NewRouter(cfg *config.Config, db *sqlx.DB) http.Handler { // Serve frontend static files r.Handle("/*", http.FileServer(http.Dir("./web"))) - // Protected API + // 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.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) r.Get("/api/work-orders", wo.List) @@ -59,8 +66,6 @@ func NewRouter(cfg *config.Config, db *sqlx.DB) http.Handler { 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) }) return r diff --git a/internal/config/config.go b/internal/config/config.go index d26dca3..3dfe0bb 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,25 +3,24 @@ package config import "os" type Config struct { - Addr string - DBDSN string - JWTSecret string - UploadPath string - BaseURL string - OIDCIssuer string - JWKSUrl string - AppEnv string + Addr string + DBDSN string + JWTSecret string + AdminPassword string + UploadPath string + BaseURL string + AppEnv string } func Load() *Config { return &Config{ - Addr: env("ADDR", ":8080"), - DBDSN: env("DB_DSN", ""), - UploadPath: env("UPLOAD_PATH", "./uploads"), - BaseURL: env("BASE_URL", "http://localhost:8080"), - 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"), + Addr: env("ADDR", ":9080"), + DBDSN: env("DB_DSN", ""), + JWTSecret: env("JWT_SECRET", "change-me-in-production"), + AdminPassword: env("ADMIN_PASSWORD", "admin123"), + UploadPath: env("UPLOAD_PATH", "./uploads"), + BaseURL: env("BASE_URL", "http://localhost:9080"), + AppEnv: env("APP_ENV", "development"), } } diff --git a/internal/model/models.go b/internal/model/models.go index b60f5c4..1a549b6 100644 --- a/internal/model/models.go +++ b/internal/model/models.go @@ -1,6 +1,12 @@ package model -import "time" +import ( + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// ── Work Order ──────────────────────────────────────────────────────────────── type WorkOrder struct { ID int `db:"id" json:"id"` @@ -41,6 +47,8 @@ type WorkOrderListItem struct { PhotoCount int `db:"photo_count" json:"photo_count"` } +// ── Steps / Resources / Attachments / Accounting ───────────────────────────── + type Step struct { ID int `db:"id" json:"id"` WOID int `db:"wo_id" json:"wo_id"` @@ -90,6 +98,8 @@ type AccountingCode struct { Description string `db:"description" json:"description"` } +// ── Resource Registry ───────────────────────────────────────────────────────── + type RegistryPerson struct { ID int `db:"id" json:"id"` Name string `db:"name" json:"name"` @@ -106,10 +116,10 @@ type RegistryVehicle struct { } type RegistryEquipment struct { - ID int `db:"id" json:"id"` - Name string `db:"name" json:"name"` + ID int `db:"id" json:"id"` + Name string `db:"name" json:"name"` AssetTag string `db:"asset_tag" json:"asset_tag"` - Category string `db:"category" json:"category"` + Category string `db:"category" json:"category"` } type RegistryMaterial struct { @@ -119,11 +129,38 @@ type RegistryMaterial struct { PartNumber string `db:"part_number" json:"part_number"` } +// ── 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 { - Sub string - Email string - Name string - Roles []string + UserID int `json:"user_id"` + Username string `json:"username"` + Email string `json:"email"` + DisplayName string `json:"display_name"` + Role string `json:"role"` } type CtxKey string diff --git a/internal/repository/migrations/002_users.sql b/internal/repository/migrations/002_users.sql new file mode 100644 index 0000000..d7d0b74 --- /dev/null +++ b/internal/repository/migrations/002_users.sql @@ -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); diff --git a/web/app.mjs b/web/app.mjs index 41dcfde..459e00e 100644 --- a/web/app.mjs +++ b/web/app.mjs @@ -1,84 +1,136 @@ -import { setToken } from './lib/api.mjs'; -import './components/wo-list.mjs'; -import './components/wo-form.mjs'; +// ── Register all custom elements ────────────────────────────────────────────── +import './components/shared/ui-badge.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'; -// ── Keycloak init ───────────────────────────────────────────────────────────── -const keycloak = new Keycloak({ - url: window.KEYCLOAK_URL || 'http://localhost:8180', - realm: window.KEYCLOAK_REALM || 'workorders', - clientId: window.KEYCLOAK_CLIENT_ID || 'workorders-app', -}); +import { getUser, setToken, clearToken } from './lib/auth.mjs'; +import { api } from './lib/api.mjs'; +import { router } from './lib/router.mjs'; +import { showToast } from './components/shared/ui-toast.mjs'; -keycloak.init({ - onLoad: 'login-required', - pkceMethod: 'S256', - checkLoginIframe: false, -}) - .then(authenticated => { - if (!authenticated) { keycloak.login(); return; } - setToken(keycloak.token); +const root = document.getElementById('root'); - // Refresh token before it expires - setInterval(async () => { - try { await keycloak.updateToken(60); setToken(keycloak.token); } - catch { keycloak.login(); } - }, 30_000); +window.addEventListener('auth:expired', () => { clearToken(); showLoginPage(); }); +window.addEventListener('auth:logout', () => { clearToken(); showLoginPage(); }); - window.addEventListener('auth:expired', () => keycloak.login()); - renderApp(keycloak); - }) - .catch(() => document.getElementById('app').innerHTML = - '

Failed to connect to authentication server.

'); - -// ── 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 = ` - Work Orders - - ${userName} - `; - 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(); }); +const user = getUser(); +if (user) { + startApp(); +} else { + showLoginPage(); } -function showList() { - const app = document.getElementById('app'); - app.innerHTML = ` - - `; - app.querySelector('#new-wo').addEventListener('click', showCreate); +// ── Login page ──────────────────────────────────────────────────────────────── +function showLoginPage() { + root.innerHTML = ` + +
+ +
`; + + if (window.lucide) lucide.createIcons({ nodes: [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'; + } + }); } -function showCreate() { - const app = document.getElementById('app'); - app.innerHTML = ` - -
`; -} +// ── Main app ────────────────────────────────────────────────────────────────── +function startApp() { + root.innerHTML = ''; + const appRoot = root.querySelector('app-root'); -function showDetail(id) { - const app = document.getElementById('app'); - app.innerHTML = ` - -
`; - app.querySelector('#back').addEventListener('click', showList); + router + .on('/', () => appRoot.setPage('')) + .on('/work-orders', () => appRoot.setPage('')) + .on('/work-orders/new', () => appRoot.setPage('')) + .on('/work-orders/:id/edit', ({ id }) => appRoot.setPage(``)) + .on('/work-orders/:id', ({ id }) => appRoot.setPage(``)) + .on('/registry/people', () => appRoot.setPage('

People registry — Phase 2

')) + .on('/registry/vehicles', () => appRoot.setPage('

Vehicles registry — Phase 2

')) + .on('/registry/equipment', () => appRoot.setPage('

Equipment registry — Phase 2

')) + .on('/registry/materials', () => appRoot.setPage('

Materials registry — Phase 2

')) + .on('/reports', () => appRoot.setPage('

Reports — Phase 3

')) + .on('/users', () => appRoot.setPage('

User management — Phase 3

')) + .on('/settings', () => appRoot.setPage('

Settings — Phase 4

')) + .start(); + + // Global navigation events from WO components + window.addEventListener('wo:navigate', e => router.navigate(e.detail.path)); + window.addEventListener('wo:toast', e => showToast(e.detail.message, e.detail.type)); } diff --git a/web/components/layout/app-mobile-nav.mjs b/web/components/layout/app-mobile-nav.mjs new file mode 100644 index 0000000..24d6334 --- /dev/null +++ b/web/components/layout/app-mobile-nav.mjs @@ -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 = ` + + ${TABS.map(t => { + const active = t.href === '/' ? path === '/' : path.startsWith(t.href); + return ` + + ${t.label} + `; + }).join('')}`; + if (window.lucide) lucide.createIcons({ nodes: [this] }); + } +} +customElements.define('app-mobile-nav', AppMobileNav); diff --git a/web/components/layout/app-root.mjs b/web/components/layout/app-root.mjs new file mode 100644 index 0000000..85fecec --- /dev/null +++ b/web/components/layout/app-root.mjs @@ -0,0 +1,26 @@ +class AppRoot extends HTMLElement { + connectedCallback() { + this.innerHTML = ` + +
+ +
+
+ `; + + // 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({ nodes: [main] }); + } + } +} +customElements.define('app-root', AppRoot); diff --git a/web/components/layout/app-sidebar.mjs b/web/components/layout/app-sidebar.mjs new file mode 100644 index 0000000..af58ad7 --- /dev/null +++ b/web/components/layout/app-sidebar.mjs @@ -0,0 +1,141 @@ +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: '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 ` + + + ${item.label} + `; + } + + #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 = ` + + +
+ +
+ Work Orders +
+ +
+ + + +
+
${initials}
+ + +
`; + + if (window.lucide) lucide.createIcons({ nodes: [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); diff --git a/web/components/layout/app-topbar.mjs b/web/components/layout/app-topbar.mjs new file mode 100644 index 0000000..014968f --- /dev/null +++ b/web/components/layout/app-topbar.mjs @@ -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 = ` + + + + + + +
+ +
+ + ${this.#menuOpen ? ` + ` : ''}`; + + if (window.lucide) lucide.createIcons({ nodes: [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); diff --git a/web/components/shared/ui-badge.mjs b/web/components/shared/ui-badge.mjs new file mode 100644 index 0000000..c1e62e8 --- /dev/null +++ b/web/components/shared/ui-badge.mjs @@ -0,0 +1,52 @@ +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' }; + +class UiBadge extends HTMLElement { + connectedCallback() { this.#render(); } + static get observedAttributes() { return ['type', 'value']; } + attributeChangedCallback() { this.#render(); } + + #render() { + const type = this.getAttribute('type') || 'status'; + const value = this.getAttribute('value') || ''; + const label = type === 'priority' + ? (PRIORITY_LABELS[value] || value) + : (STATUS_LABELS[value] || value.replace('_', ' ')); + + this.attachShadow({ mode: 'open' }); + this.shadowRoot.innerHTML = ` + + + + ${label} + `; + } +} + +customElements.define('ui-badge', UiBadge); diff --git a/web/components/shared/ui-button.mjs b/web/components/shared/ui-button.mjs new file mode 100644 index 0000000..e0d1481 --- /dev/null +++ b/web/components/shared/ui-button.mjs @@ -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 = ` + + `; + } +} +customElements.define('ui-button', UiButton); diff --git a/web/components/shared/ui-dialog.mjs b/web/components/shared/ui-dialog.mjs new file mode 100644 index 0000000..2ac8733 --- /dev/null +++ b/web/components/shared/ui-dialog.mjs @@ -0,0 +1,56 @@ +class UiDialog extends HTMLElement { + connectedCallback() { + this.attachShadow({ mode: 'open' }); + this.shadowRoot.innerHTML = ` + + +
+

${this.getAttribute('title') || ''}

+ +
+
+
`; + + 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({ nodes: [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); diff --git a/web/components/shared/ui-empty.mjs b/web/components/shared/ui-empty.mjs new file mode 100644 index 0000000..fe9ee52 --- /dev/null +++ b/web/components/shared/ui-empty.mjs @@ -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 = ` + +
+
+ +
+

${heading}

+ ${body ? `

${body}

` : ''} + +
`; + if (window.lucide) lucide.createIcons({ nodes: [this.shadowRoot] }); + } +} +customElements.define('ui-empty', UiEmpty); diff --git a/web/components/shared/ui-spinner.mjs b/web/components/shared/ui-spinner.mjs new file mode 100644 index 0000000..cbec8ae --- /dev/null +++ b/web/components/shared/ui-spinner.mjs @@ -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 = ` + +
`; + } +} +customElements.define('ui-spinner', UiSpinner); diff --git a/web/components/shared/ui-toast.mjs b/web/components/shared/ui-toast.mjs new file mode 100644 index 0000000..d0d3c86 --- /dev/null +++ b/web/components/shared/ui-toast.mjs @@ -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 = `${message}`; + this.appendChild(toast); + if (window.lucide) lucide.createIcons({ nodes: [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); +} diff --git a/web/components/work-orders/wo-detail.mjs b/web/components/work-orders/wo-detail.mjs new file mode 100644 index 0000000..da14b80 --- /dev/null +++ b/web/components/work-orders/wo-detail.mjs @@ -0,0 +1,250 @@ +import { api } from '../../lib/api.mjs'; +import { formatDateTime, formatDate } from '../../lib/format.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 = `${this.#html()}`; + this.#bind(); + if (window.lucide) lucide.createIcons({ nodes: [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; } + .phase-badge { display:inline-flex; align-items:center; gap:.3rem; padding:.2rem .6rem; border-radius:999px; font-size:.7rem; font-weight:700; background:var(--surface-2); color:var(--text-muted); } + .status-menu { position:relative; } + .status-dropdown { position:absolute; top:calc(100% + .25rem); right:0; background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); box-shadow:var(--shadow-md); z-index:50; min-width:160px; overflow:hidden; } + .status-opt { display:flex; align-items:center; gap:.5rem; padding:.6rem 1rem; font-size:.875rem; cursor:pointer; border:none; background:none; width:100%; text-align:left; transition:background .15s; color:var(--text); } + .status-opt:hover { background:var(--surface-2); } + .status-dot { width:8px; height:8px; border-radius:50%; flex-shrink:0; } + .placeholder { text-align:center; padding:3rem; color:var(--text-muted); } + .placeholder i { margin-bottom:.75rem; } + @media (max-width:768px) { .detail-grid { grid-template-columns:1fr; } .meta-strip { gap:1rem; } } + `; } + + #html() { + if (this.#loading) return ` +
+ +
`; + + if (!this.#wo) return ` + + + `; + + 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 ` + + +
+
+ Site + ${this.#esc(wo.site_name) || '—'} +
+
+ Scheduled Start + ${formatDateTime(wo.scheduled_start)} +
+
+ Scheduled End + ${formatDateTime(wo.scheduled_end)} +
+
+ Created By + ${this.#esc(wo.created_by) || '—'} +
+
+ Created + ${formatDate(wo.created_at)} +
+ ${wo.parent_type ? `
+ Parent + ${wo.parent_type} #${wo.parent_id} +
` : ''} +
+ +
+ ${TABS.map(t => ``).join('')} +
+ +
+ ${this.#tabContent(wo)} +
`; + } + + #tabContent(wo) { + switch (this.#tab) { + case 'Overview': return ` +
+
+
Description
+ ${wo.description + ? `
${this.#esc(wo.description)}
` + : '
No description provided
'} +
+
+
Instructions
+ ${wo.instructions + ? `
${this.#esc(wo.instructions)}
` + : '
No instructions provided
'} +
+
+
Address
+
${this.#esc(wo.address) || 'Not set'}
+
+
+
Access Notes
+ ${wo.access_notes + ? `
${this.#esc(wo.access_notes)}
` + : '
No access notes
'} +
+
`; + case 'Checklist': + case 'Resources': + case 'Photos': + case 'Accounting': + case 'Activity': + return `
+ +

${this.#tab} — Coming in Phase 2

+

This section will be available in the next phase of development.

+
`; + 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({ nodes: [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' } })); + } + })); + } + + #esc(s) { return (s || '').replace(/&/g,'&').replace(//g,'>'); } +} +customElements.define('wo-detail', WoDetail); diff --git a/web/components/work-orders/wo-form.mjs b/web/components/work-orders/wo-form.mjs new file mode 100644 index 0000000..a7a59ae --- /dev/null +++ b/web/components/work-orders/wo-form.mjs @@ -0,0 +1,221 @@ +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 = `${this.#html(wo, isNew)}`; + this.#bind(); + if (window.lucide) lucide.createIcons({ nodes: [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; } } + `; } + + #html(wo, isNew) { + const v = (field, fallback = '') => this.#esc(wo[field] ?? fallback); + return ` + +
+
+ +
+
+
Details
+ + + +
+
+
Location
+ + + +
+
+ + +
+
+
Status & Priority
+ ${!isNew ? `` : ''} + +
+
+
Schedule
+ + +
+
+
Parent
+ + +
+
+
+ + +
`; + } + + #bind() { + const s = this.shadowRoot; + + s.querySelector('#back')?.addEventListener('click', () => this.#goBack()); + s.querySelector('#cancel-btn')?.addEventListener('click', () => this.#goBack()); + + // 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; + } + }); + } + + #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,'"'); } +} +customElements.define('wo-form', WoForm); diff --git a/web/components/work-orders/wo-kanban.mjs b/web/components/work-orders/wo-kanban.mjs new file mode 100644 index 0000000..8fd964b --- /dev/null +++ b/web/components/work-orders/wo-kanban.mjs @@ -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 = ` + +
+ ${COLUMNS.map(col => ` +
+
+ + ${col.label} + ${byStatus[col.key].length} +
+
+ ${byStatus[col.key].map(wo => ` +
+
${wo.wo_number}
+
${this.#esc(wo.title)}
+ ${wo.site_name ? `
${this.#esc(wo.site_name)}
` : ''} + +
`).join('')} +
+
`).join('')} +
`; + + 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,'>'); } +} +customElements.define('wo-kanban', WoKanban); diff --git a/web/components/work-orders/wo-list.mjs b/web/components/work-orders/wo-list.mjs new file mode 100644 index 0000000..1eccee8 --- /dev/null +++ b/web/components/work-orders/wo-list.mjs @@ -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 = `${this.#html()}`; + this.#bind(); + if (window.lucide) lucide.createIcons({ nodes: [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()} +
+ ${[1,2,3,4,5].map(() => `
+
+
+
+
+
`).join('')} +
`; + + if (this.#data.length === 0) return ` + ${this.#headerHTML()} + ${this.#filterBarHTML()} + + ${!this.#hasFilters() ? '' : ''} + `; + + return ` + ${this.#headerHTML()} + ${this.#filterBarHTML()} + ${this.#view === 'kanban' + ? '' + : `${this.#tableHTML()}${this.#cardListHTML()}`}`; + } + + #headerHTML() { + return ``; + } + + #filterBarHTML() { + const viewActive = v => this.#view === v ? ' active' : ''; + return `
+
+ + +
+ + +
+ + +
+
`; + } + + #tableHTML() { + return ` + + + + + + ${this.#data.map(wo => ` + + + + + + + + + + `).join('')} + +
WO #TitleSiteStatusPriorityScheduledSteps
${wo.wo_number}${this.#esc(wo.title)}${this.#esc(wo.site_name) || ''} + + ${wo.priority.charAt(0).toUpperCase()+wo.priority.slice(1)} + ${formatDateTime(wo.scheduled_start)} +
+ ${wo.steps_done}/${wo.step_count} +
+
+ + +
+
`; + } + + #cardListHTML() { + return `
+ ${this.#data.map(wo => ` +
+
+
+
${wo.wo_number}
+
${this.#esc(wo.title)}
+
+ +
+
${this.#esc(wo.site_name) || '—'} · ${wo.steps_done}/${wo.step_count} steps
+
`).join('')} +
`; + } + + #hasFilters() { return !!(this.#filters.status || this.#filters.search || this.#filters.priority); } + #esc(s) { return (s || '').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); diff --git a/web/index.html b/web/index.html index 0723416..34e25cd 100644 --- a/web/index.html +++ b/web/index.html @@ -2,18 +2,32 @@ - + Work Orders + + + + + + + + + + + + + + + + + - - - + + -
-

Connecting to authentication server...

-
+
diff --git a/web/lib/api.mjs b/web/lib/api.mjs index 83d81a6..5f2b5f7 100644 --- a/web/lib/api.mjs +++ b/web/lib/api.mjs @@ -1,26 +1,29 @@ +import { getToken, clearToken } from './auth.mjs'; + const BASE = '/api'; -let _token = ''; -export function setToken(t) { _token = t; } -export function getToken() { return _token; } - -async function request(method, path, body, isForm = false) { - const headers = { Authorization: `Bearer ${_token}` }; - if (!isForm && body) headers['Content-Type'] = 'application/json'; +async function request(method, path, body, isFormData = false) { + const token = getToken(); + const headers = {}; + if (token) headers['Authorization'] = `Bearer ${token}`; + if (!isFormData && body !== undefined) headers['Content-Type'] = 'application/json'; const res = await fetch(BASE + path, { method, headers, - body: body ? (isForm ? body : JSON.stringify(body)) : undefined, + body: body !== undefined + ? (isFormData ? body : JSON.stringify(body)) + : undefined, }); if (res.status === 401) { + clearToken(); window.dispatchEvent(new CustomEvent('auth:expired')); - return null; + throw new Error('Session expired'); } 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; } diff --git a/web/lib/auth.mjs b/web/lib/auth.mjs new file mode 100644 index 0000000..901d5cf --- /dev/null +++ b/web/lib/auth.mjs @@ -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); +} diff --git a/web/lib/format.mjs b/web/lib/format.mjs new file mode 100644 index 0000000..0dc038c --- /dev/null +++ b/web/lib/format.mjs @@ -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); +} diff --git a/web/lib/router.mjs b/web/lib/router.mjs new file mode 100644 index 0000000..14cf708 --- /dev/null +++ b/web/lib/router.mjs @@ -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(); diff --git a/web/lib/store.mjs b/web/lib/store.mjs new file mode 100644 index 0000000..5537bfc --- /dev/null +++ b/web/lib/store.mjs @@ -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); }, + }; +} diff --git a/web/styles/forms.css b/web/styles/forms.css new file mode 100644 index 0000000..1613ad9 --- /dev/null +++ b/web/styles/forms.css @@ -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; } +} diff --git a/web/styles/global.css b/web/styles/global.css index b3c6428..cee0454 100644 --- a/web/styles/global.css +++ b/web/styles/global.css @@ -1,156 +1,271 @@ -/* ── Light mode (default) ─────────────────────────────────────────────────── */ +/* ── Design Tokens — Light Mode ──────────────────────────────────────────────── */ :root { - --navy: #0d2137; - --accent: #0a7ea4; - --accent-lt: #14b8d4; - --surface: #ffffff; - --surface-2: #f8fafc; - --bg: #f0f6fa; - --border: #e2ebf0; - --text: #1a2e3b; - --muted: #64748b; - --danger: #c0392b; - --success: #1d9d6c; - --warning: #e07b39; - --radius: 8px; - --shadow: 0 2px 8px rgba(0,0,0,.08); + /* Brand */ + --navy: #0D2137; + --teal: #0A7EA4; + --teal-lt: #14B8D4; + --teal-dk: #075E7A; - /* Status badge colours — light */ - --badge-draft-bg: #e2e8f0; --badge-draft-text: #475569; - --badge-assigned-bg: #dbeafe; --badge-assigned-text: #1d4ed8; - --badge-scheduled-bg: #fef3c7; --badge-scheduled-text: #92400e; - --badge-in_progress-bg: #d1fae5; --badge-in_progress-text: #065f46; - --badge-pending_review-bg: #ede9fe; --badge-pending_review-text: #5b21b6; - --badge-closed-bg: #f3f4f6; --badge-closed-text: #6b7280; + /* Surfaces */ + --bg: #F0F6FA; + --surface: #FFFFFF; + --surface-2: #E8F0F5; + --border: #D1DDE6; + --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-lg: 12px; + --radius-xl: 16px; + + /* Sidebar */ + --sidebar-bg: #0D2137; + --sidebar-hover: #153248; + --sidebar-active: #0A7EA4; + --sidebar-w: 260px; + --sidebar-collapsed: 64px; + + /* 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; } -/* ── Dark mode ────────────────────────────────────────────────────────────── */ +/* ── Dark Mode ───────────────────────────────────────────────────────────────── */ @media (prefers-color-scheme: dark) { - :root { - --navy: #0a1929; - --accent: #29b6d8; - --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); + :root { --_dark: 1; } +} +:root[data-theme="dark"] { --_dark: 1; } - /* Status badge colours — dark (muted tints so they don't blaze) */ - --badge-draft-bg: #1e2d3d; --badge-draft-text: #94a3b8; - --badge-assigned-bg: #1e3a5f; --badge-assigned-text: #93c5fd; - --badge-scheduled-bg: #3d2e00; --badge-scheduled-text: #fde68a; - --badge-in_progress-bg: #064e30; --badge-in_progress-text: #6ee7b7; - --badge-pending_review-bg: #2e1f5e; --badge-pending_review-text: #c4b5fd; - --badge-closed-bg: #1c2a38; --badge-closed-text: #6b7280; +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --navy: #0A1929; + --teal: #29B6D8; + --teal-lt: #4DD0E8; + --teal-dk: #1A8FAD; + + --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; } } +: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 ────────────────────────────────────────────────────────────────── */ -body { min-height: 100vh; } +/* ── Base ────────────────────────────────────────────────────────────────────── */ +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; } -/* ── Buttons ──────────────────────────────────────────────────────────────── */ -button { - cursor: pointer; - border: none; - border-radius: var(--radius); - padding: .5rem 1rem; - background: var(--accent); - color: #fff; - font-weight: 600; - transition: opacity .15s; +/* ── App Layout (Light DOM — no shadow root) ─────────────────────────────────── */ +app-root { + display: grid; + grid-template-columns: var(--sidebar-w) 1fr; + grid-template-rows: 1fr; + min-height: 100vh; } -button:hover { opacity: .85; } -button.secondary { - background: transparent; - border: 1px solid var(--border); - color: var(--text); +app-root.collapsed { + grid-template-columns: var(--sidebar-collapsed) 1fr; } -/* ── Form controls ────────────────────────────────────────────────────────── */ -input, select, textarea { - border: 1px solid var(--border); - border-radius: var(--radius); - padding: .5rem .75rem; - width: 100%; +.app-shell { + display: grid; + grid-template-rows: 56px 1fr; + min-height: 100vh; + overflow: hidden; +} + +#main-content { + overflow-y: auto; + padding: 1.5rem; + background: var(--bg); +} + +app-mobile-nav { display: none; } + +@media (max-width: 768px) { + app-root { + grid-template-columns: 1fr; + grid-template-rows: 56px 1fr 56px; + } + .app-shell { grid-template-rows: 1fr; } + app-sidebar { display: none; } + app-mobile-nav { display: flex; } + app-topbar { display: none; } + #main-content { padding: 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); - color: var(--text); - 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; + border-bottom: 1px solid var(--border); display: flex; align-items: center; - gap: 1.5rem; - border-bottom: 1px solid rgba(255,255,255,.06); + padding: 0 1.25rem; + 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 { display: flex; align-items: center; 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; } diff --git a/web/styles/reset.css b/web/styles/reset.css index 8247323..1810435 100644 --- a/web/styles/reset.css +++ b/web/styles/reset.css @@ -1,6 +1,7 @@ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } html { -webkit-text-size-adjust: 100%; } 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; } p, h1, h2, h3, h4, h5, h6 { overflow-wrap: break-word; } +ul, ol { list-style: none; } diff --git a/web/styles/typography.css b/web/styles/typography.css new file mode 100644 index 0000000..5fa7745 --- /dev/null +++ b/web/styles/typography.css @@ -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); }