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 = `
+
+
+
+
+
+
+
+
${initials}
+
+
${user?.displayName || user?.username || 'User'}
+
${user?.role || ''}
+
+
+
`;
+
+ 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 = `
+
+ `;
+
+ 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 `
+
+
+
+
+
+ ${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 `
+
+ `;
+ }
+
+ #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 => `
+
+
+
+ ${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 `
+
+ | WO # | Title | Site |
+ Status | Priority | Scheduled | Steps | |
+
+
+ ${this.#data.map(wo => `
+
+ | ${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}
+ |
+
+
+
+
+
+ |
+
`).join('')}
+
+
`;
+ }
+
+ #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); }