Initialize work order management system with database schema, API handlers, web client, and Docker configuration.
This commit is contained in:
+17
@@ -0,0 +1,17 @@
|
|||||||
|
# ── Build stage ───────────────────────────────────────────────────────────────
|
||||||
|
FROM golang:1.23-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
RUN apk add --no-cache git
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -o workorders ./cmd/server
|
||||||
|
|
||||||
|
# ── Runtime stage ─────────────────────────────────────────────────────────────
|
||||||
|
FROM alpine:3.20
|
||||||
|
WORKDIR /app
|
||||||
|
RUN apk add --no-cache tzdata ca-certificates curl
|
||||||
|
COPY --from=builder /app/workorders .
|
||||||
|
COPY web/ ./web/
|
||||||
|
EXPOSE 8080
|
||||||
|
CMD ["./workorders"]
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
"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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := config.Load()
|
||||||
|
|
||||||
|
log.Printf("connecting to database...")
|
||||||
|
db, err := repository.Connect(cfg.DBDSN)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("database connect: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
log.Printf("running migrations...")
|
||||||
|
if err := repository.RunMigrations(db); err != nil {
|
||||||
|
log.Fatalf("migrations: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("fetching JWKS from %s", cfg.JWKSUrl)
|
||||||
|
api.InitJWKS(cfg.JWKSUrl)
|
||||||
|
|
||||||
|
r := api.NewRouter(cfg, db)
|
||||||
|
|
||||||
|
log.Printf("listening on %s", cfg.Addr)
|
||||||
|
if err := http.ListenAndServe(cfg.Addr, r); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
services:
|
||||||
|
|
||||||
|
mssql:
|
||||||
|
image: mcr.microsoft.com/mssql/server:2022-latest
|
||||||
|
environment:
|
||||||
|
SA_PASSWORD: ${SA_PASSWORD}
|
||||||
|
ACCEPT_EULA: "Y"
|
||||||
|
MSSQL_PID: Developer
|
||||||
|
ports:
|
||||||
|
- "1433:1433"
|
||||||
|
volumes:
|
||||||
|
- mssql_data:/var/opt/mssql
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "/opt/mssql-tools18/bin/sqlcmd", "-S", "localhost", "-U", "sa", "-P", "${SA_PASSWORD}", "-Q", "SELECT 1", "-No", "-l", "5"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 12
|
||||||
|
start_period: 40s
|
||||||
|
|
||||||
|
keycloak:
|
||||||
|
image: quay.io/keycloak/keycloak:24.0
|
||||||
|
command: start-dev --import-realm --http-port=8180
|
||||||
|
environment:
|
||||||
|
KEYCLOAK_ADMIN: ${KC_ADMIN}
|
||||||
|
KEYCLOAK_ADMIN_PASSWORD: ${KC_ADMIN_PASSWORD}
|
||||||
|
KC_DB: dev-file
|
||||||
|
KC_HTTP_PORT: "8180"
|
||||||
|
KC_HOSTNAME_STRICT: "false"
|
||||||
|
KC_HOSTNAME_STRICT_HTTPS: "false"
|
||||||
|
KC_HTTP_ENABLED: "true"
|
||||||
|
KC_HEALTH_ENABLED: "true"
|
||||||
|
ports:
|
||||||
|
- "8180:8180"
|
||||||
|
volumes:
|
||||||
|
- ./keycloak:/opt/keycloak/data/import
|
||||||
|
- keycloak_data:/opt/keycloak/data
|
||||||
|
healthcheck:
|
||||||
|
# Keycloak image has no curl/wget; use bash TCP to confirm port is open
|
||||||
|
test: ["CMD-SHELL", "bash -c 'exec 3<>/dev/tcp/localhost/8180' 2>/dev/null && echo ok || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 18
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
api:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "9080:9080"
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
DB_DSN: "sqlserver://sa:${SA_PASSWORD}@mssql:1433?database=workorders"
|
||||||
|
OIDC_ISSUER: "http://localhost:8180/realms/workorders"
|
||||||
|
JWKS_URL: "http://keycloak:8180/realms/workorders/protocol/openid-connect/certs"
|
||||||
|
extra_hosts:
|
||||||
|
- "localhost:host-gateway"
|
||||||
|
volumes:
|
||||||
|
- uploads:/uploads
|
||||||
|
depends_on:
|
||||||
|
mssql:
|
||||||
|
condition: service_healthy
|
||||||
|
keycloak:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: on-failure
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mssql_data:
|
||||||
|
keycloak_data:
|
||||||
|
uploads:
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
module workorders
|
||||||
|
|
||||||
|
go 1.23
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/go-chi/chi/v5 v5.1.0
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/jmoiron/sqlx v1.3.5
|
||||||
|
github.com/joho/godotenv v1.5.1
|
||||||
|
github.com/microsoft/go-mssqldb v1.7.2
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.9.1 h1:lGlwhPtrX6EVml1hO0ivjkUxsSyl4dsiw9qcA1k/3IQ=
|
||||||
|
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.9.1/go.mod h1:RKUqNu35KJYcVG/fqTRqmuXJZYNhYkBrnC/hX7yGbTA=
|
||||||
|
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.5.1 h1:sO0/P7g68FrryJzljemN+6GTssUXdANk6aJ7T1ZxnsQ=
|
||||||
|
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.5.1/go.mod h1:h8hyGFDsU5HMivxiS2iYFZsgDbU9OnnJ163x5UGVKYo=
|
||||||
|
github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.1 h1:6oNBlSdi1QqM1PNW7FPA6xOGA5UNsXnkaYZz9vdPGhA=
|
||||||
|
github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.1/go.mod h1:s4kgfzA0covAXNicZHDMN58jExvcng2mC/DepXiF1EI=
|
||||||
|
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.1 h1:MyVTgWR8qd/Jw1Le0NZebGBUCLbtak3bJ3z1OlqZBpw=
|
||||||
|
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.1/go.mod h1:GpPjLhVR9dnUoJMyHWSPy71xY9/lcmpzIPZXmF0FCVY=
|
||||||
|
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0 h1:D3occbWoio4EBLkbkevetNMAVX197GkzbUMtqjGWn80=
|
||||||
|
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0/go.mod h1:bTSOgj05NGRuHHhQwAdPnYr9TOdNmKlZTgGLL6nyAdI=
|
||||||
|
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.1 h1:DzHpqpoJVaCgOUdVHxE8QB52S6NiVdDQvGlny1qvPqA=
|
||||||
|
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.1/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
|
||||||
|
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||||
|
github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
|
||||||
|
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
|
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
|
||||||
|
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
|
||||||
|
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
|
||||||
|
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g=
|
||||||
|
github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ=
|
||||||
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
|
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
|
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||||
|
github.com/lib/pq v1.2.0 h1:LXpIM/LZ5xGFhOpXAQUIMM1HdyqzVYM13zNdjCEEcA0=
|
||||||
|
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
||||||
|
github.com/microsoft/go-mssqldb v1.7.2 h1:CHkFJiObW7ItKTJfHo1QX7QBBD1iV+mn1eOyRP3b/PA=
|
||||||
|
github.com/microsoft/go-mssqldb v1.7.2/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA=
|
||||||
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||||
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||||
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc=
|
||||||
|
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
|
||||||
|
golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
|
||||||
|
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
||||||
|
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
|
||||||
|
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||||
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
"workorders/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AccountingHandler struct{ db *sqlx.DB }
|
||||||
|
|
||||||
|
func NewAccountingHandler(db *sqlx.DB) *AccountingHandler { return &AccountingHandler{db: db} }
|
||||||
|
|
||||||
|
func (h *AccountingHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||||
|
woID, err := intParam(r, "id")
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var codes []model.AccountingCode
|
||||||
|
h.db.Select(&codes, `SELECT * FROM wo_accounting WHERE wo_id=@p1`, woID)
|
||||||
|
respond(w, http.StatusOK, codes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AccountingHandler) Upsert(w http.ResponseWriter, r *http.Request) {
|
||||||
|
woID, err := intParam(r, "id")
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var codes []model.AccountingCode
|
||||||
|
if err := decode(r, &codes); err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "invalid body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, c := range codes {
|
||||||
|
h.db.Exec(`
|
||||||
|
MERGE wo_accounting AS t
|
||||||
|
USING (SELECT @p1 AS wo_id, @p2 AS code_type) AS s
|
||||||
|
ON t.wo_id=s.wo_id AND t.code_type=s.code_type
|
||||||
|
WHEN MATCHED THEN UPDATE SET code_value=@p3,description=@p4
|
||||||
|
WHEN NOT MATCHED THEN INSERT (wo_id,code_type,code_value,description)
|
||||||
|
VALUES (@p1,@p2,@p3,@p4);`,
|
||||||
|
woID, c.CodeType, c.CodeValue, c.Description,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
var result []model.AccountingCode
|
||||||
|
h.db.Select(&result, `SELECT * FROM wo_accounting WHERE wo_id=@p1`, woID)
|
||||||
|
respond(w, http.StatusOK, result)
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
"workorders/internal/config"
|
||||||
|
"workorders/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AttachmentHandler struct {
|
||||||
|
db *sqlx.DB
|
||||||
|
cfg *config.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAttachmentHandler(db *sqlx.DB, cfg *config.Config) *AttachmentHandler {
|
||||||
|
return &AttachmentHandler{db: db, cfg: cfg}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AttachmentHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||||
|
woID, err := intParam(r, "id")
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var atts []model.Attachment
|
||||||
|
if err := h.db.Select(&atts, `SELECT * FROM wo_attachments WHERE wo_id=@p1 ORDER BY uploaded_at`, woID); err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i := range atts {
|
||||||
|
atts[i].URL = "/uploads/" + atts[i].FilePath
|
||||||
|
}
|
||||||
|
respond(w, http.StatusOK, atts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AttachmentHandler) Upload(w http.ResponseWriter, r *http.Request) {
|
||||||
|
woID, err := intParam(r, "id")
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user := userFromCtx(r)
|
||||||
|
|
||||||
|
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "parse form failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file, header, err := r.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "missing file")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
ext := filepath.Ext(header.Filename)
|
||||||
|
fname := uuid.New().String() + ext
|
||||||
|
relPath := fmt.Sprintf("%d/%s", woID, fname)
|
||||||
|
absPath := filepath.Join(h.cfg.UploadPath, relPath)
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(absPath), 0755); err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "storage error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dst, err := os.Create(absPath)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "storage error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer dst.Close()
|
||||||
|
size, _ := io.Copy(dst, file)
|
||||||
|
|
||||||
|
var aid int
|
||||||
|
err = h.db.QueryRow(`
|
||||||
|
INSERT INTO wo_attachments (wo_id,file_name,file_path,file_type,file_size,caption,phase,uploaded_by)
|
||||||
|
OUTPUT INSERTED.id VALUES (@p1,@p2,@p3,@p4,@p5,@p6,@p7,@p8)`,
|
||||||
|
woID, header.Filename, relPath, header.Header.Get("Content-Type"),
|
||||||
|
size, r.FormValue("caption"), r.FormValue("phase"), user.Email,
|
||||||
|
).Scan(&aid)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond(w, http.StatusCreated, map[string]any{
|
||||||
|
"id": aid,
|
||||||
|
"url": "/uploads/" + relPath,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AttachmentHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
aid, err := intParam(r, "aid")
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "invalid aid")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var att model.Attachment
|
||||||
|
if err := h.db.Get(&att, `SELECT * FROM wo_attachments WHERE id=@p1`, aid); err == nil {
|
||||||
|
os.Remove(filepath.Join(h.cfg.UploadPath, att.FilePath))
|
||||||
|
}
|
||||||
|
h.db.Exec(`DELETE FROM wo_attachments WHERE id=@p1`, aid)
|
||||||
|
respond(w, http.StatusOK, map[string]any{"deleted": aid})
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
func Me(w http.ResponseWriter, r *http.Request) {
|
||||||
|
respond(w, http.StatusOK, userFromCtx(r))
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"workorders/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func respond(w http.ResponseWriter, code int, data any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(code)
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"data": data})
|
||||||
|
}
|
||||||
|
|
||||||
|
func respondError(w http.ResponseWriter, code int, msg string) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(code)
|
||||||
|
fmt.Fprintf(w, `{"error":%q}`, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func intParam(r *http.Request, key string) (int, error) {
|
||||||
|
return strconv.Atoi(chi.URLParam(r, key))
|
||||||
|
}
|
||||||
|
|
||||||
|
func decode(r *http.Request, v any) error {
|
||||||
|
return json.NewDecoder(r.Body).Decode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func userFromCtx(r *http.Request) model.UserClaims {
|
||||||
|
u, _ := r.Context().Value(model.CtxUserKey).(model.UserClaims)
|
||||||
|
return u
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
"workorders/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RegistryHandler struct{ db *sqlx.DB }
|
||||||
|
|
||||||
|
func NewRegistryHandler(db *sqlx.DB) *RegistryHandler { return &RegistryHandler{db: db} }
|
||||||
|
|
||||||
|
func (h *RegistryHandler) People(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var rows []model.RegistryPerson
|
||||||
|
h.db.Select(&rows, `SELECT id,name,role,email,phone FROM resource_people WHERE active=1 ORDER BY name`)
|
||||||
|
respond(w, http.StatusOK, rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *RegistryHandler) Vehicles(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var rows []model.RegistryVehicle
|
||||||
|
h.db.Select(&rows, `SELECT id,unit_number,description,vehicle_type FROM resource_vehicles WHERE active=1 ORDER BY unit_number`)
|
||||||
|
respond(w, http.StatusOK, rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *RegistryHandler) Equipment(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var rows []model.RegistryEquipment
|
||||||
|
h.db.Select(&rows, `SELECT id,name,asset_tag,category FROM resource_equipment WHERE active=1 ORDER BY name`)
|
||||||
|
respond(w, http.StatusOK, rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *RegistryHandler) Materials(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var rows []model.RegistryMaterial
|
||||||
|
h.db.Select(&rows, `SELECT id,name,unit,part_number FROM resource_materials WHERE active=1 ORDER BY name`)
|
||||||
|
respond(w, http.StatusOK, rows)
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
"workorders/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ResourceHandler struct{ db *sqlx.DB }
|
||||||
|
|
||||||
|
func NewResourceHandler(db *sqlx.DB) *ResourceHandler { return &ResourceHandler{db: db} }
|
||||||
|
|
||||||
|
func (h *ResourceHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||||
|
woID, err := intParam(r, "id")
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var rows []model.AssignedResource
|
||||||
|
err = h.db.Select(&rows, `
|
||||||
|
SELECT r.id, r.wo_id, r.resource_type, r.resource_id, r.quantity, r.notes, r.assigned_at,
|
||||||
|
ISNULL(p.name, ISNULL(v.unit_number, ISNULL(e.name, m.name))) AS name
|
||||||
|
FROM wo_resources r
|
||||||
|
LEFT JOIN resource_people p ON r.resource_type='person' AND r.resource_id=p.id
|
||||||
|
LEFT JOIN resource_vehicles v ON r.resource_type='vehicle' AND r.resource_id=v.id
|
||||||
|
LEFT JOIN resource_equipment e ON r.resource_type='equipment' AND r.resource_id=e.id
|
||||||
|
LEFT JOIN resource_materials m ON r.resource_type='material' AND r.resource_id=m.id
|
||||||
|
WHERE r.wo_id=@p1`, woID)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond(w, http.StatusOK, rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ResourceHandler) Assign(w http.ResponseWriter, r *http.Request) {
|
||||||
|
woID, err := intParam(r, "id")
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
ResourceType string `json:"resource_type"`
|
||||||
|
ResourceID int `json:"resource_id"`
|
||||||
|
Quantity *float64 `json:"quantity"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
}
|
||||||
|
if err := decode(r, &body); err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "invalid body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var rid int
|
||||||
|
err = h.db.QueryRow(`
|
||||||
|
INSERT INTO wo_resources (wo_id,resource_type,resource_id,quantity,notes)
|
||||||
|
OUTPUT INSERTED.id VALUES (@p1,@p2,@p3,@p4,@p5)`,
|
||||||
|
woID, body.ResourceType, body.ResourceID, body.Quantity, body.Notes,
|
||||||
|
).Scan(&rid)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond(w, http.StatusCreated, map[string]any{"id": rid})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ResourceHandler) Remove(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rid, err := intParam(r, "rid")
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "invalid rid")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.db.Exec(`DELETE FROM wo_resources WHERE id=@p1`, rid)
|
||||||
|
respond(w, http.StatusOK, map[string]any{"deleted": rid})
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
"workorders/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type StepHandler struct{ db *sqlx.DB }
|
||||||
|
|
||||||
|
func NewStepHandler(db *sqlx.DB) *StepHandler { return &StepHandler{db: db} }
|
||||||
|
|
||||||
|
func (h *StepHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||||
|
woID, err := intParam(r, "id")
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var steps []model.Step
|
||||||
|
if err := h.db.Select(&steps,
|
||||||
|
`SELECT * FROM wo_steps WHERE wo_id=@p1 ORDER BY step_order`, woID); err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond(w, http.StatusOK, steps)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *StepHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||||
|
woID, err := intParam(r, "id")
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body model.Step
|
||||||
|
if err := decode(r, &body); err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "invalid body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var maxOrder int
|
||||||
|
h.db.QueryRow(`SELECT ISNULL(MAX(step_order),0) FROM wo_steps WHERE wo_id=@p1`, woID).Scan(&maxOrder)
|
||||||
|
|
||||||
|
var sid int
|
||||||
|
err = h.db.QueryRow(`
|
||||||
|
INSERT INTO wo_steps (wo_id,step_order,title,description,required)
|
||||||
|
OUTPUT INSERTED.id VALUES (@p1,@p2,@p3,@p4,@p5)`,
|
||||||
|
woID, maxOrder+1, body.Title, body.Description, body.Required,
|
||||||
|
).Scan(&sid)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var step model.Step
|
||||||
|
h.db.Get(&step, `SELECT * FROM wo_steps WHERE id=@p1`, sid)
|
||||||
|
respond(w, http.StatusCreated, step)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *StepHandler) Update(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sid, err := intParam(r, "sid")
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "invalid sid")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body model.Step
|
||||||
|
if err := decode(r, &body); err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "invalid body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.db.Exec(`UPDATE wo_steps SET title=@p1,description=@p2,required=@p3 WHERE id=@p4`,
|
||||||
|
body.Title, body.Description, body.Required, sid)
|
||||||
|
var step model.Step
|
||||||
|
h.db.Get(&step, `SELECT * FROM wo_steps WHERE id=@p1`, sid)
|
||||||
|
respond(w, http.StatusOK, step)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *StepHandler) Complete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sid, err := intParam(r, "sid")
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "invalid sid")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user := userFromCtx(r)
|
||||||
|
var body struct {
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
}
|
||||||
|
decode(r, &body)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
h.db.Exec(`UPDATE wo_steps SET completed=1,completed_by=@p1,completed_at=@p2,notes=@p3 WHERE id=@p4`,
|
||||||
|
user.Email, now, body.Notes, sid)
|
||||||
|
var step model.Step
|
||||||
|
h.db.Get(&step, `SELECT * FROM wo_steps WHERE id=@p1`, sid)
|
||||||
|
respond(w, http.StatusOK, step)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *StepHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sid, err := intParam(r, "sid")
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "invalid sid")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.db.Exec(`DELETE FROM wo_steps WHERE id=@p1`, sid)
|
||||||
|
respond(w, http.StatusOK, map[string]any{"deleted": sid})
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
"workorders/internal/config"
|
||||||
|
"workorders/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WorkOrderHandler struct {
|
||||||
|
db *sqlx.DB
|
||||||
|
cfg *config.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWorkOrderHandler(db *sqlx.DB, cfg *config.Config) *WorkOrderHandler {
|
||||||
|
return &WorkOrderHandler{db: db, cfg: cfg}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WorkOrderHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||||
|
status := r.URL.Query().Get("status")
|
||||||
|
search := r.URL.Query().Get("search")
|
||||||
|
if search != "" {
|
||||||
|
search = "%" + search + "%"
|
||||||
|
}
|
||||||
|
|
||||||
|
query := `
|
||||||
|
SELECT
|
||||||
|
wo.id, wo.wo_number, wo.title, wo.status, wo.priority,
|
||||||
|
wo.site_name, wo.scheduled_start,
|
||||||
|
COUNT(s.id) AS step_count,
|
||||||
|
ISNULL(SUM(CAST(s.completed AS INT)),0) AS steps_done,
|
||||||
|
COUNT(a.id) AS photo_count
|
||||||
|
FROM work_orders wo
|
||||||
|
LEFT JOIN wo_steps s ON s.wo_id = wo.id
|
||||||
|
LEFT JOIN wo_attachments a ON a.wo_id = wo.id
|
||||||
|
WHERE (@p1 = '' OR wo.status = @p1)
|
||||||
|
AND (@p2 = '' OR wo.title LIKE @p2 OR wo.wo_number LIKE @p2 OR wo.site_name LIKE @p2)
|
||||||
|
GROUP BY wo.id, wo.wo_number, wo.title, wo.status,
|
||||||
|
wo.priority, wo.site_name, wo.scheduled_start
|
||||||
|
ORDER BY wo.scheduled_start DESC, wo.id DESC`
|
||||||
|
|
||||||
|
rows := []model.WorkOrderListItem{}
|
||||||
|
if err := h.db.Select(&rows, query, status, search); err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond(w, http.StatusOK, rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WorkOrderHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := intParam(r, "id")
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var wo model.WorkOrder
|
||||||
|
if err = h.db.Get(&wo, `SELECT * FROM work_orders WHERE id = @p1`, id); err != nil {
|
||||||
|
respondError(w, http.StatusNotFound, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond(w, http.StatusOK, wo)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WorkOrderHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := userFromCtx(r)
|
||||||
|
var body model.WorkOrder
|
||||||
|
if err := decode(r, &body); err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "invalid body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Priority == "" {
|
||||||
|
body.Priority = "normal"
|
||||||
|
}
|
||||||
|
|
||||||
|
var id int
|
||||||
|
err := h.db.QueryRow(`
|
||||||
|
INSERT INTO work_orders
|
||||||
|
(title, description, instructions, status, priority,
|
||||||
|
scheduled_start, scheduled_end, site_name, address, lat, lng,
|
||||||
|
access_notes, parent_type, parent_id, created_by, updated_at)
|
||||||
|
OUTPUT INSERTED.id
|
||||||
|
VALUES (@p1,@p2,@p3,'draft',@p4,@p5,@p6,@p7,@p8,@p9,@p10,@p11,@p12,@p13,@p14,GETUTCDATE())`,
|
||||||
|
body.Title, body.Description, body.Instructions, body.Priority,
|
||||||
|
body.ScheduledStart, body.ScheduledEnd, body.SiteName, body.Address,
|
||||||
|
body.Lat, body.Lng, body.AccessNotes, body.ParentType, body.ParentID,
|
||||||
|
user.Email,
|
||||||
|
).Scan(&id)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var wo model.WorkOrder
|
||||||
|
h.db.Get(&wo, `SELECT * FROM work_orders WHERE id = @p1`, id)
|
||||||
|
respond(w, http.StatusCreated, wo)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WorkOrderHandler) Update(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := intParam(r, "id")
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body model.WorkOrder
|
||||||
|
if err := decode(r, &body); err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "invalid body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, err = h.db.Exec(`
|
||||||
|
UPDATE work_orders SET
|
||||||
|
title=@p1, description=@p2, instructions=@p3, priority=@p4,
|
||||||
|
scheduled_start=@p5, scheduled_end=@p6, site_name=@p7, address=@p8,
|
||||||
|
lat=@p9, lng=@p10, access_notes=@p11, updated_at=GETUTCDATE()
|
||||||
|
WHERE id=@p12`,
|
||||||
|
body.Title, body.Description, body.Instructions, body.Priority,
|
||||||
|
body.ScheduledStart, body.ScheduledEnd, body.SiteName, body.Address,
|
||||||
|
body.Lat, body.Lng, body.AccessNotes, id,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var wo model.WorkOrder
|
||||||
|
h.db.Get(&wo, `SELECT * FROM work_orders WHERE id = @p1`, id)
|
||||||
|
respond(w, http.StatusOK, wo)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WorkOrderHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := intParam(r, "id")
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.db.Exec(`DELETE FROM work_orders WHERE id = @p1`, id)
|
||||||
|
respond(w, http.StatusOK, map[string]any{"deleted": id})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WorkOrderHandler) UpdateStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := intParam(r, "id")
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user := userFromCtx(r)
|
||||||
|
var body struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
if err := decode(r, &body); err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "invalid body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
_, err = h.db.Exec(`
|
||||||
|
UPDATE work_orders SET
|
||||||
|
status=@p1, updated_at=GETUTCDATE(),
|
||||||
|
actual_start = CASE WHEN @p1='in_progress' AND actual_start IS NULL THEN @p2 ELSE actual_start END,
|
||||||
|
actual_end = CASE WHEN @p1='closed' THEN @p2 ELSE actual_end END,
|
||||||
|
closed_at = CASE WHEN @p1='closed' THEN @p2 ELSE closed_at END,
|
||||||
|
closed_by = CASE WHEN @p1='closed' THEN @p3 ELSE closed_by END
|
||||||
|
WHERE id=@p4`,
|
||||||
|
body.Status, now, user.Email, id,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var wo model.WorkOrder
|
||||||
|
h.db.Get(&wo, `SELECT * FROM work_orders WHERE id = @p1`, id)
|
||||||
|
respond(w, http.StatusOK, wo)
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
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 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"])
|
||||||
|
}
|
||||||
|
kid, _ := t.Header["kid"].(string)
|
||||||
|
return cache.get(kid)
|
||||||
|
}, jwt.WithExpirationRequired())
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.WithValue(r.Context(), model.CtxUserKey, user)
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func CORS(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||||
|
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
|
||||||
|
if r.Method == http.MethodOptions {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
fmt.Fprintf(w, `{"error":%q}`, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func UserFromCtx(r *http.Request) model.UserClaims {
|
||||||
|
u, _ := r.Context().Value(model.CtxUserKey).(model.UserClaims)
|
||||||
|
return u
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
"workorders/internal/api/handlers"
|
||||||
|
"workorders/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewRouter(cfg *config.Config, db *sqlx.DB) http.Handler {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(middleware.Logger)
|
||||||
|
r.Use(middleware.Recoverer)
|
||||||
|
r.Use(middleware.RealIP)
|
||||||
|
r.Use(CORS)
|
||||||
|
|
||||||
|
// Serve frontend static files
|
||||||
|
r.Handle("/*", http.FileServer(http.Dir("./web")))
|
||||||
|
|
||||||
|
// Protected API
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(OIDCAuth)
|
||||||
|
|
||||||
|
wo := handlers.NewWorkOrderHandler(db, cfg)
|
||||||
|
r.Get("/api/work-orders", wo.List)
|
||||||
|
r.Post("/api/work-orders", wo.Create)
|
||||||
|
r.Get("/api/work-orders/{id}", wo.Get)
|
||||||
|
r.Put("/api/work-orders/{id}", wo.Update)
|
||||||
|
r.Delete("/api/work-orders/{id}", wo.Delete)
|
||||||
|
r.Put("/api/work-orders/{id}/status", wo.UpdateStatus)
|
||||||
|
|
||||||
|
step := handlers.NewStepHandler(db)
|
||||||
|
r.Get("/api/work-orders/{id}/steps", step.List)
|
||||||
|
r.Post("/api/work-orders/{id}/steps", step.Create)
|
||||||
|
r.Put("/api/work-orders/{id}/steps/{sid}", step.Update)
|
||||||
|
r.Post("/api/work-orders/{id}/steps/{sid}/complete", step.Complete)
|
||||||
|
r.Delete("/api/work-orders/{id}/steps/{sid}", step.Delete)
|
||||||
|
|
||||||
|
res := handlers.NewResourceHandler(db)
|
||||||
|
r.Get("/api/work-orders/{id}/resources", res.List)
|
||||||
|
r.Post("/api/work-orders/{id}/resources", res.Assign)
|
||||||
|
r.Delete("/api/work-orders/{id}/resources/{rid}", res.Remove)
|
||||||
|
|
||||||
|
att := handlers.NewAttachmentHandler(db, cfg)
|
||||||
|
r.Get("/api/work-orders/{id}/attachments", att.List)
|
||||||
|
r.Post("/api/work-orders/{id}/attachments", att.Upload)
|
||||||
|
r.Delete("/api/work-orders/{id}/attachments/{aid}", att.Delete)
|
||||||
|
r.Handle("/uploads/*", http.StripPrefix("/uploads/", http.FileServer(http.Dir(cfg.UploadPath))))
|
||||||
|
|
||||||
|
acc := handlers.NewAccountingHandler(db)
|
||||||
|
r.Get("/api/work-orders/{id}/accounting", acc.Get)
|
||||||
|
r.Put("/api/work-orders/{id}/accounting", acc.Upsert)
|
||||||
|
|
||||||
|
reg := handlers.NewRegistryHandler(db)
|
||||||
|
r.Get("/api/registry/people", reg.People)
|
||||||
|
r.Get("/api/registry/vehicles", reg.Vehicles)
|
||||||
|
r.Get("/api/registry/equipment", reg.Equipment)
|
||||||
|
r.Get("/api/registry/materials", reg.Materials)
|
||||||
|
|
||||||
|
r.Get("/api/me", handlers.Me)
|
||||||
|
})
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import "os"
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Addr string
|
||||||
|
DBDSN string
|
||||||
|
JWTSecret string
|
||||||
|
UploadPath string
|
||||||
|
BaseURL string
|
||||||
|
OIDCIssuer string
|
||||||
|
JWKSUrl 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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func env(key, fallback string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type WorkOrder struct {
|
||||||
|
ID int `db:"id" json:"id"`
|
||||||
|
WONumber string `db:"wo_number" json:"wo_number"`
|
||||||
|
Title string `db:"title" json:"title"`
|
||||||
|
Description string `db:"description" json:"description"`
|
||||||
|
Instructions string `db:"instructions" json:"instructions"`
|
||||||
|
Status string `db:"status" json:"status"`
|
||||||
|
Priority string `db:"priority" json:"priority"`
|
||||||
|
ScheduledStart *time.Time `db:"scheduled_start" json:"scheduled_start"`
|
||||||
|
ScheduledEnd *time.Time `db:"scheduled_end" json:"scheduled_end"`
|
||||||
|
ActualStart *time.Time `db:"actual_start" json:"actual_start"`
|
||||||
|
ActualEnd *time.Time `db:"actual_end" json:"actual_end"`
|
||||||
|
SiteName string `db:"site_name" json:"site_name"`
|
||||||
|
Address string `db:"address" json:"address"`
|
||||||
|
Lat *float64 `db:"lat" json:"lat"`
|
||||||
|
Lng *float64 `db:"lng" json:"lng"`
|
||||||
|
AccessNotes string `db:"access_notes" json:"access_notes"`
|
||||||
|
ParentType *string `db:"parent_type" json:"parent_type"`
|
||||||
|
ParentID *int `db:"parent_id" json:"parent_id"`
|
||||||
|
CreatedBy string `db:"created_by" json:"created_by"`
|
||||||
|
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||||
|
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||||
|
ClosedAt *time.Time `db:"closed_at" json:"closed_at"`
|
||||||
|
ClosedBy *string `db:"closed_by" json:"closed_by"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkOrderListItem struct {
|
||||||
|
ID int `db:"id" json:"id"`
|
||||||
|
WONumber string `db:"wo_number" json:"wo_number"`
|
||||||
|
Title string `db:"title" json:"title"`
|
||||||
|
Status string `db:"status" json:"status"`
|
||||||
|
Priority string `db:"priority" json:"priority"`
|
||||||
|
SiteName string `db:"site_name" json:"site_name"`
|
||||||
|
ScheduledStart *time.Time `db:"scheduled_start" json:"scheduled_start"`
|
||||||
|
StepCount int `db:"step_count" json:"step_count"`
|
||||||
|
StepsDone int `db:"steps_done" json:"steps_done"`
|
||||||
|
PhotoCount int `db:"photo_count" json:"photo_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Step struct {
|
||||||
|
ID int `db:"id" json:"id"`
|
||||||
|
WOID int `db:"wo_id" json:"wo_id"`
|
||||||
|
StepOrder int `db:"step_order" json:"step_order"`
|
||||||
|
Title string `db:"title" json:"title"`
|
||||||
|
Description string `db:"description" json:"description"`
|
||||||
|
Required bool `db:"required" json:"required"`
|
||||||
|
Completed bool `db:"completed" json:"completed"`
|
||||||
|
CompletedBy *string `db:"completed_by" json:"completed_by"`
|
||||||
|
CompletedAt *time.Time `db:"completed_at" json:"completed_at"`
|
||||||
|
Notes string `db:"notes" json:"notes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AssignedResource struct {
|
||||||
|
ID int `db:"id" json:"id"`
|
||||||
|
WOID int `db:"wo_id" json:"wo_id"`
|
||||||
|
ResourceType string `db:"resource_type" json:"resource_type"`
|
||||||
|
ResourceID int `db:"resource_id" json:"resource_id"`
|
||||||
|
Name string `db:"name" json:"name"`
|
||||||
|
Quantity *float64 `db:"quantity" json:"quantity"`
|
||||||
|
Notes string `db:"notes" json:"notes"`
|
||||||
|
AssignedAt time.Time `db:"assigned_at" json:"assigned_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Attachment struct {
|
||||||
|
ID int `db:"id" json:"id"`
|
||||||
|
WOID int `db:"wo_id" json:"wo_id"`
|
||||||
|
StepID *int `db:"step_id" json:"step_id"`
|
||||||
|
FileName string `db:"file_name" json:"file_name"`
|
||||||
|
FilePath string `db:"file_path" json:"file_path"`
|
||||||
|
FileType string `db:"file_type" json:"file_type"`
|
||||||
|
FileSize int64 `db:"file_size" json:"file_size"`
|
||||||
|
Caption string `db:"caption" json:"caption"`
|
||||||
|
Phase string `db:"phase" json:"phase"`
|
||||||
|
Lat *float64 `db:"lat" json:"lat"`
|
||||||
|
Lng *float64 `db:"lng" json:"lng"`
|
||||||
|
UploadedBy string `db:"uploaded_by" json:"uploaded_by"`
|
||||||
|
UploadedAt time.Time `db:"uploaded_at" json:"uploaded_at"`
|
||||||
|
URL string `db:"-" json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AccountingCode struct {
|
||||||
|
ID int `db:"id" json:"id"`
|
||||||
|
WOID int `db:"wo_id" json:"wo_id"`
|
||||||
|
CodeType string `db:"code_type" json:"code_type"`
|
||||||
|
CodeValue string `db:"code_value" json:"code_value"`
|
||||||
|
Description string `db:"description" json:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RegistryPerson struct {
|
||||||
|
ID int `db:"id" json:"id"`
|
||||||
|
Name string `db:"name" json:"name"`
|
||||||
|
Role string `db:"role" json:"role"`
|
||||||
|
Email string `db:"email" json:"email"`
|
||||||
|
Phone string `db:"phone" json:"phone"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RegistryVehicle struct {
|
||||||
|
ID int `db:"id" json:"id"`
|
||||||
|
UnitNumber string `db:"unit_number" json:"unit_number"`
|
||||||
|
Description string `db:"description" json:"description"`
|
||||||
|
VehicleType string `db:"vehicle_type" json:"vehicle_type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RegistryEquipment struct {
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RegistryMaterial struct {
|
||||||
|
ID int `db:"id" json:"id"`
|
||||||
|
Name string `db:"name" json:"name"`
|
||||||
|
Unit string `db:"unit" json:"unit"`
|
||||||
|
PartNumber string `db:"part_number" json:"part_number"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserClaims struct {
|
||||||
|
Sub string
|
||||||
|
Email string
|
||||||
|
Name string
|
||||||
|
Roles []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type CtxKey string
|
||||||
|
|
||||||
|
const CtxUserKey CtxKey = "user"
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
_ "github.com/microsoft/go-mssqldb"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed migrations/*.sql
|
||||||
|
var migrations embed.FS
|
||||||
|
|
||||||
|
func Connect(dsn string) (*sqlx.DB, error) {
|
||||||
|
// Extract base DSN without the database name for initial connection to master
|
||||||
|
masterDSN := strings.Replace(dsn, "database=workorders", "database=master", 1)
|
||||||
|
|
||||||
|
// Retry loop — MSSQL takes time to start
|
||||||
|
var db *sqlx.DB
|
||||||
|
var err error
|
||||||
|
for i := range 20 {
|
||||||
|
db, err = sqlx.Open("sqlserver", masterDSN)
|
||||||
|
if err == nil {
|
||||||
|
if pingErr := db.PingContext(context.Background()); pingErr == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
db.Close()
|
||||||
|
}
|
||||||
|
log.Printf("waiting for MSSQL (%d/20)...", i+1)
|
||||||
|
time.Sleep(3 * time.Second)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("connect to master: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the workorders database if it doesn't exist
|
||||||
|
_, err = db.Exec(`
|
||||||
|
IF NOT EXISTS (SELECT name FROM sys.databases WHERE name = 'workorders')
|
||||||
|
CREATE DATABASE workorders;
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create database: %w", err)
|
||||||
|
}
|
||||||
|
db.Close()
|
||||||
|
|
||||||
|
// Reconnect to the workorders database
|
||||||
|
db, err = sqlx.Open("sqlserver", dsn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("connect to workorders: %w", err)
|
||||||
|
}
|
||||||
|
if err = db.PingContext(context.Background()); err != nil {
|
||||||
|
return nil, fmt.Errorf("ping workorders: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db.SetMaxOpenConns(25)
|
||||||
|
db.SetMaxIdleConns(5)
|
||||||
|
db.SetConnMaxLifetime(5 * time.Minute)
|
||||||
|
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func RunMigrations(db *sqlx.DB) error {
|
||||||
|
// Create migrations tracking table
|
||||||
|
_, err := db.Exec(`
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT * FROM sys.tables WHERE name = 'schema_migrations'
|
||||||
|
)
|
||||||
|
CREATE TABLE schema_migrations (
|
||||||
|
filename NVARCHAR(255) PRIMARY KEY,
|
||||||
|
applied_at DATETIME2 NOT NULL DEFAULT GETUTCDATE()
|
||||||
|
);
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create schema_migrations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := migrations.ReadDir("migrations")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read migrations dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !strings.HasSuffix(entry.Name(), ".sql") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var count int
|
||||||
|
_ = db.QueryRow(`SELECT COUNT(1) FROM schema_migrations WHERE filename = @p1`, entry.Name()).Scan(&count)
|
||||||
|
if count > 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := migrations.ReadFile("migrations/" + entry.Name())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read %s: %w", entry.Name(), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split on GO statements for multi-batch SQL
|
||||||
|
statements := splitSQL(string(content))
|
||||||
|
for _, stmt := range statements {
|
||||||
|
stmt = strings.TrimSpace(stmt)
|
||||||
|
if stmt == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(stmt); err != nil {
|
||||||
|
return fmt.Errorf("migration %s: %w", entry.Name(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = db.Exec(`INSERT INTO schema_migrations (filename) VALUES (@p1)`, entry.Name())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("record migration %s: %w", entry.Name(), err)
|
||||||
|
}
|
||||||
|
log.Printf("applied migration: %s", entry.Name())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitSQL(sql string) []string {
|
||||||
|
var stmts []string
|
||||||
|
for _, part := range strings.Split(sql, "\nGO") {
|
||||||
|
if s := strings.TrimSpace(part); s != "" {
|
||||||
|
stmts = append(stmts, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(stmts) == 0 {
|
||||||
|
stmts = []string{sql}
|
||||||
|
}
|
||||||
|
return stmts
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
-- Run once: create database if needed (executed against master)
|
||||||
|
-- The Go migration runner handles this separately.
|
||||||
|
|
||||||
|
-- ── Work Orders ───────────────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE work_orders (
|
||||||
|
id INT IDENTITY PRIMARY KEY,
|
||||||
|
wo_number AS ('WO-' + RIGHT('000000' + CAST(id AS VARCHAR), 6)) PERSISTED,
|
||||||
|
title NVARCHAR(200) NOT NULL,
|
||||||
|
description NVARCHAR(MAX),
|
||||||
|
instructions NVARCHAR(MAX),
|
||||||
|
status NVARCHAR(30) NOT NULL DEFAULT 'draft',
|
||||||
|
priority NVARCHAR(10) NOT NULL DEFAULT 'normal',
|
||||||
|
scheduled_start DATETIME2,
|
||||||
|
scheduled_end DATETIME2,
|
||||||
|
actual_start DATETIME2,
|
||||||
|
actual_end DATETIME2,
|
||||||
|
site_name NVARCHAR(200),
|
||||||
|
address NVARCHAR(400),
|
||||||
|
lat DECIMAL(10,7),
|
||||||
|
lng DECIMAL(10,7),
|
||||||
|
access_notes NVARCHAR(MAX),
|
||||||
|
parent_type NVARCHAR(50),
|
||||||
|
parent_id INT,
|
||||||
|
created_by NVARCHAR(200) NOT NULL DEFAULT 'system',
|
||||||
|
created_at DATETIME2 NOT NULL DEFAULT GETUTCDATE(),
|
||||||
|
updated_at DATETIME2 NOT NULL DEFAULT GETUTCDATE(),
|
||||||
|
closed_at DATETIME2,
|
||||||
|
closed_by NVARCHAR(200),
|
||||||
|
CONSTRAINT chk_wo_status CHECK (status IN ('draft','assigned','scheduled','in_progress','pending_review','closed')),
|
||||||
|
CONSTRAINT chk_wo_priority CHECK (priority IN ('low','normal','high','urgent'))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ── Resource Registry ─────────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE resource_people (
|
||||||
|
id INT IDENTITY PRIMARY KEY,
|
||||||
|
name NVARCHAR(100) NOT NULL,
|
||||||
|
role NVARCHAR(100),
|
||||||
|
email NVARCHAR(200),
|
||||||
|
phone NVARCHAR(30),
|
||||||
|
active BIT NOT NULL DEFAULT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE resource_vehicles (
|
||||||
|
id INT IDENTITY PRIMARY KEY,
|
||||||
|
unit_number NVARCHAR(50) NOT NULL,
|
||||||
|
description NVARCHAR(200),
|
||||||
|
vehicle_type NVARCHAR(100),
|
||||||
|
active BIT NOT NULL DEFAULT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE resource_equipment (
|
||||||
|
id INT IDENTITY PRIMARY KEY,
|
||||||
|
name NVARCHAR(200) NOT NULL,
|
||||||
|
asset_tag NVARCHAR(100),
|
||||||
|
category NVARCHAR(100),
|
||||||
|
active BIT NOT NULL DEFAULT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE resource_materials (
|
||||||
|
id INT IDENTITY PRIMARY KEY,
|
||||||
|
name NVARCHAR(200) NOT NULL,
|
||||||
|
unit NVARCHAR(30),
|
||||||
|
part_number NVARCHAR(100),
|
||||||
|
active BIT NOT NULL DEFAULT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ── Work Order Resources ───────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE wo_resources (
|
||||||
|
id INT IDENTITY PRIMARY KEY,
|
||||||
|
wo_id INT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE,
|
||||||
|
resource_type NVARCHAR(20) NOT NULL,
|
||||||
|
resource_id INT NOT NULL,
|
||||||
|
quantity DECIMAL(10,2),
|
||||||
|
notes NVARCHAR(500),
|
||||||
|
assigned_at DATETIME2 NOT NULL DEFAULT GETUTCDATE(),
|
||||||
|
CONSTRAINT chk_resource_type CHECK (resource_type IN ('person','vehicle','equipment','material'))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ── Checklist Steps ───────────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE wo_steps (
|
||||||
|
id INT IDENTITY PRIMARY KEY,
|
||||||
|
wo_id INT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE,
|
||||||
|
step_order INT NOT NULL,
|
||||||
|
title NVARCHAR(200) NOT NULL,
|
||||||
|
description NVARCHAR(MAX),
|
||||||
|
required BIT NOT NULL DEFAULT 1,
|
||||||
|
completed BIT NOT NULL DEFAULT 0,
|
||||||
|
completed_by NVARCHAR(200),
|
||||||
|
completed_at DATETIME2,
|
||||||
|
notes NVARCHAR(MAX)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ── Attachments ───────────────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE wo_attachments (
|
||||||
|
id INT IDENTITY PRIMARY KEY,
|
||||||
|
wo_id INT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE,
|
||||||
|
step_id INT REFERENCES wo_steps(id),
|
||||||
|
file_name NVARCHAR(500) NOT NULL,
|
||||||
|
file_path NVARCHAR(1000) NOT NULL,
|
||||||
|
file_type NVARCHAR(100),
|
||||||
|
file_size BIGINT,
|
||||||
|
caption NVARCHAR(500),
|
||||||
|
phase NVARCHAR(20),
|
||||||
|
lat DECIMAL(10,7),
|
||||||
|
lng DECIMAL(10,7),
|
||||||
|
uploaded_by NVARCHAR(200) NOT NULL,
|
||||||
|
uploaded_at DATETIME2 NOT NULL DEFAULT GETUTCDATE()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ── Accounting Codes ──────────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE wo_accounting (
|
||||||
|
id INT IDENTITY PRIMARY KEY,
|
||||||
|
wo_id INT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE,
|
||||||
|
code_type NVARCHAR(50) NOT NULL,
|
||||||
|
code_value NVARCHAR(200) NOT NULL,
|
||||||
|
description NVARCHAR(500),
|
||||||
|
CONSTRAINT uq_wo_accounting UNIQUE (wo_id, code_type)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ── Audit Log ─────────────────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE wo_audit_log (
|
||||||
|
id INT IDENTITY PRIMARY KEY,
|
||||||
|
wo_id INT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE,
|
||||||
|
action NVARCHAR(100) NOT NULL,
|
||||||
|
old_value NVARCHAR(MAX),
|
||||||
|
new_value NVARCHAR(MAX),
|
||||||
|
performed_by NVARCHAR(200) NOT NULL,
|
||||||
|
performed_at DATETIME2 NOT NULL DEFAULT GETUTCDATE()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ── Indexes ───────────────────────────────────────────────────────────────────
|
||||||
|
CREATE INDEX ix_wo_status ON work_orders (status);
|
||||||
|
CREATE INDEX ix_wo_parent ON work_orders (parent_type, parent_id);
|
||||||
|
CREATE INDEX ix_wo_scheduled ON work_orders (scheduled_start);
|
||||||
|
CREATE INDEX ix_resources ON wo_resources (wo_id, resource_type);
|
||||||
|
CREATE INDEX ix_attachments ON wo_attachments (wo_id);
|
||||||
|
|
||||||
|
-- ── Seed registry data ────────────────────────────────────────────────────────
|
||||||
|
INSERT INTO resource_people (name, role, email, phone) VALUES
|
||||||
|
('Alice Johnson', 'Lead Technician', 'alice@example.com', '555-0101'),
|
||||||
|
('Bob Smith', 'Field Tech', 'bob@example.com', '555-0102'),
|
||||||
|
('Carol White', 'Supervisor', 'carol@example.com', '555-0103');
|
||||||
|
|
||||||
|
INSERT INTO resource_vehicles (unit_number, description, vehicle_type) VALUES
|
||||||
|
('TRK-001', 'Ford F-250 Service Truck', 'Pickup'),
|
||||||
|
('VAN-002', 'Transit Connect Work Van', 'Van'),
|
||||||
|
('SUV-003', 'Chevy Tahoe Supervisor', 'SUV');
|
||||||
|
|
||||||
|
INSERT INTO resource_equipment (name, asset_tag, category) VALUES
|
||||||
|
('Bucket Truck', 'EQ-1001', 'Heavy Equipment'),
|
||||||
|
('Portable Generator', 'EQ-1002', 'Power'),
|
||||||
|
('Cable Puller', 'EQ-1003', 'Tools');
|
||||||
|
|
||||||
|
INSERT INTO resource_materials (name, unit, part_number) VALUES
|
||||||
|
('Wire 12 AWG', 'ft', 'WR-12AWG'),
|
||||||
|
('Conduit 1"', 'ft', 'CD-1IN'),
|
||||||
|
('Junction Box', 'each', 'JB-100');
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
{
|
||||||
|
"id": "workorders",
|
||||||
|
"realm": "workorders",
|
||||||
|
"displayName": "Work Orders",
|
||||||
|
"enabled": true,
|
||||||
|
"registrationAllowed": false,
|
||||||
|
"resetPasswordAllowed": true,
|
||||||
|
"rememberMe": true,
|
||||||
|
"verifyEmail": false,
|
||||||
|
"loginWithEmailAllowed": true,
|
||||||
|
"sslRequired": "none",
|
||||||
|
"clients": [
|
||||||
|
{
|
||||||
|
"clientId": "workorders-app",
|
||||||
|
"name": "Work Orders App",
|
||||||
|
"enabled": true,
|
||||||
|
"publicClient": true,
|
||||||
|
"standardFlowEnabled": true,
|
||||||
|
"implicitFlowEnabled": false,
|
||||||
|
"directAccessGrantsEnabled": false,
|
||||||
|
"serviceAccountsEnabled": false,
|
||||||
|
"redirectUris": [
|
||||||
|
"http://localhost:9080/*",
|
||||||
|
"http://localhost:8081/*"
|
||||||
|
],
|
||||||
|
"webOrigins": [
|
||||||
|
"http://localhost:9080",
|
||||||
|
"http://localhost:8081"
|
||||||
|
],
|
||||||
|
"attributes": {
|
||||||
|
"pkce.code.challenge.method": "S256",
|
||||||
|
"post.logout.redirect.uris": "http://localhost:9080/*"
|
||||||
|
},
|
||||||
|
"protocol": "openid-connect"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"roles": {
|
||||||
|
"realm": [
|
||||||
|
{ "name": "admin", "description": "Full administrative access" },
|
||||||
|
{ "name": "dispatcher", "description": "Create and assign work orders" },
|
||||||
|
{ "name": "field_tech", "description": "Complete steps and upload photos" },
|
||||||
|
{ "name": "viewer", "description": "Read-only access" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"users": [
|
||||||
|
{
|
||||||
|
"username": "admin",
|
||||||
|
"email": "admin@workorders.local",
|
||||||
|
"firstName": "Admin",
|
||||||
|
"lastName": "User",
|
||||||
|
"enabled": true,
|
||||||
|
"emailVerified": true,
|
||||||
|
"credentials": [{ "type": "password", "value": "admin123", "temporary": false }],
|
||||||
|
"realmRoles": ["admin"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "dispatcher",
|
||||||
|
"email": "dispatcher@workorders.local",
|
||||||
|
"firstName": "Dispatcher",
|
||||||
|
"lastName": "User",
|
||||||
|
"enabled": true,
|
||||||
|
"emailVerified": true,
|
||||||
|
"credentials": [{ "type": "password", "value": "dispatcher123", "temporary": false }],
|
||||||
|
"realmRoles": ["dispatcher"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "tech1",
|
||||||
|
"email": "tech1@workorders.local",
|
||||||
|
"firstName": "Tech",
|
||||||
|
"lastName": "One",
|
||||||
|
"enabled": true,
|
||||||
|
"emailVerified": true,
|
||||||
|
"credentials": [{ "type": "password", "value": "tech123", "temporary": false }],
|
||||||
|
"realmRoles": ["field_tech"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "viewer",
|
||||||
|
"email": "viewer@workorders.local",
|
||||||
|
"firstName": "View",
|
||||||
|
"lastName": "Only",
|
||||||
|
"enabled": true,
|
||||||
|
"emailVerified": true,
|
||||||
|
"credentials": [{ "type": "password", "value": "viewer123", "temporary": false }],
|
||||||
|
"realmRoles": ["viewer"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
-- Run once: create database if needed (executed against master)
|
||||||
|
-- The Go migration runner handles this separately.
|
||||||
|
|
||||||
|
-- ── Work Orders ───────────────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE work_orders (
|
||||||
|
id INT IDENTITY PRIMARY KEY,
|
||||||
|
wo_number AS ('WO-' + RIGHT('000000' + CAST(id AS VARCHAR), 6)) PERSISTED,
|
||||||
|
title NVARCHAR(200) NOT NULL,
|
||||||
|
description NVARCHAR(MAX),
|
||||||
|
instructions NVARCHAR(MAX),
|
||||||
|
status NVARCHAR(30) NOT NULL DEFAULT 'draft',
|
||||||
|
priority NVARCHAR(10) NOT NULL DEFAULT 'normal',
|
||||||
|
scheduled_start DATETIME2,
|
||||||
|
scheduled_end DATETIME2,
|
||||||
|
actual_start DATETIME2,
|
||||||
|
actual_end DATETIME2,
|
||||||
|
site_name NVARCHAR(200),
|
||||||
|
address NVARCHAR(400),
|
||||||
|
lat DECIMAL(10,7),
|
||||||
|
lng DECIMAL(10,7),
|
||||||
|
access_notes NVARCHAR(MAX),
|
||||||
|
parent_type NVARCHAR(50),
|
||||||
|
parent_id INT,
|
||||||
|
created_by NVARCHAR(200) NOT NULL DEFAULT 'system',
|
||||||
|
created_at DATETIME2 NOT NULL DEFAULT GETUTCDATE(),
|
||||||
|
updated_at DATETIME2 NOT NULL DEFAULT GETUTCDATE(),
|
||||||
|
closed_at DATETIME2,
|
||||||
|
closed_by NVARCHAR(200),
|
||||||
|
CONSTRAINT chk_wo_status CHECK (status IN ('draft','assigned','scheduled','in_progress','pending_review','closed')),
|
||||||
|
CONSTRAINT chk_wo_priority CHECK (priority IN ('low','normal','high','urgent'))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ── Resource Registry ─────────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE resource_people (
|
||||||
|
id INT IDENTITY PRIMARY KEY,
|
||||||
|
name NVARCHAR(100) NOT NULL,
|
||||||
|
role NVARCHAR(100),
|
||||||
|
email NVARCHAR(200),
|
||||||
|
phone NVARCHAR(30),
|
||||||
|
active BIT NOT NULL DEFAULT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE resource_vehicles (
|
||||||
|
id INT IDENTITY PRIMARY KEY,
|
||||||
|
unit_number NVARCHAR(50) NOT NULL,
|
||||||
|
description NVARCHAR(200),
|
||||||
|
vehicle_type NVARCHAR(100),
|
||||||
|
active BIT NOT NULL DEFAULT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE resource_equipment (
|
||||||
|
id INT IDENTITY PRIMARY KEY,
|
||||||
|
name NVARCHAR(200) NOT NULL,
|
||||||
|
asset_tag NVARCHAR(100),
|
||||||
|
category NVARCHAR(100),
|
||||||
|
active BIT NOT NULL DEFAULT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE resource_materials (
|
||||||
|
id INT IDENTITY PRIMARY KEY,
|
||||||
|
name NVARCHAR(200) NOT NULL,
|
||||||
|
unit NVARCHAR(30),
|
||||||
|
part_number NVARCHAR(100),
|
||||||
|
active BIT NOT NULL DEFAULT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ── Work Order Resources ───────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE wo_resources (
|
||||||
|
id INT IDENTITY PRIMARY KEY,
|
||||||
|
wo_id INT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE,
|
||||||
|
resource_type NVARCHAR(20) NOT NULL,
|
||||||
|
resource_id INT NOT NULL,
|
||||||
|
quantity DECIMAL(10,2),
|
||||||
|
notes NVARCHAR(500),
|
||||||
|
assigned_at DATETIME2 NOT NULL DEFAULT GETUTCDATE(),
|
||||||
|
CONSTRAINT chk_resource_type CHECK (resource_type IN ('person','vehicle','equipment','material'))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ── Checklist Steps ───────────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE wo_steps (
|
||||||
|
id INT IDENTITY PRIMARY KEY,
|
||||||
|
wo_id INT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE,
|
||||||
|
step_order INT NOT NULL,
|
||||||
|
title NVARCHAR(200) NOT NULL,
|
||||||
|
description NVARCHAR(MAX),
|
||||||
|
required BIT NOT NULL DEFAULT 1,
|
||||||
|
completed BIT NOT NULL DEFAULT 0,
|
||||||
|
completed_by NVARCHAR(200),
|
||||||
|
completed_at DATETIME2,
|
||||||
|
notes NVARCHAR(MAX)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ── Attachments ───────────────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE wo_attachments (
|
||||||
|
id INT IDENTITY PRIMARY KEY,
|
||||||
|
wo_id INT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE,
|
||||||
|
step_id INT REFERENCES wo_steps(id),
|
||||||
|
file_name NVARCHAR(500) NOT NULL,
|
||||||
|
file_path NVARCHAR(1000) NOT NULL,
|
||||||
|
file_type NVARCHAR(100),
|
||||||
|
file_size BIGINT,
|
||||||
|
caption NVARCHAR(500),
|
||||||
|
phase NVARCHAR(20),
|
||||||
|
lat DECIMAL(10,7),
|
||||||
|
lng DECIMAL(10,7),
|
||||||
|
uploaded_by NVARCHAR(200) NOT NULL,
|
||||||
|
uploaded_at DATETIME2 NOT NULL DEFAULT GETUTCDATE()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ── Accounting Codes ──────────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE wo_accounting (
|
||||||
|
id INT IDENTITY PRIMARY KEY,
|
||||||
|
wo_id INT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE,
|
||||||
|
code_type NVARCHAR(50) NOT NULL,
|
||||||
|
code_value NVARCHAR(200) NOT NULL,
|
||||||
|
description NVARCHAR(500),
|
||||||
|
CONSTRAINT uq_wo_accounting UNIQUE (wo_id, code_type)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ── Audit Log ─────────────────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE wo_audit_log (
|
||||||
|
id INT IDENTITY PRIMARY KEY,
|
||||||
|
wo_id INT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE,
|
||||||
|
action NVARCHAR(100) NOT NULL,
|
||||||
|
old_value NVARCHAR(MAX),
|
||||||
|
new_value NVARCHAR(MAX),
|
||||||
|
performed_by NVARCHAR(200) NOT NULL,
|
||||||
|
performed_at DATETIME2 NOT NULL DEFAULT GETUTCDATE()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ── Indexes ───────────────────────────────────────────────────────────────────
|
||||||
|
CREATE INDEX ix_wo_status ON work_orders (status);
|
||||||
|
CREATE INDEX ix_wo_parent ON work_orders (parent_type, parent_id);
|
||||||
|
CREATE INDEX ix_wo_scheduled ON work_orders (scheduled_start);
|
||||||
|
CREATE INDEX ix_resources ON wo_resources (wo_id, resource_type);
|
||||||
|
CREATE INDEX ix_attachments ON wo_attachments (wo_id);
|
||||||
|
|
||||||
|
-- ── Seed registry data ────────────────────────────────────────────────────────
|
||||||
|
INSERT INTO resource_people (name, role, email, phone) VALUES
|
||||||
|
('Alice Johnson', 'Lead Technician', 'alice@example.com', '555-0101'),
|
||||||
|
('Bob Smith', 'Field Tech', 'bob@example.com', '555-0102'),
|
||||||
|
('Carol White', 'Supervisor', 'carol@example.com', '555-0103');
|
||||||
|
|
||||||
|
INSERT INTO resource_vehicles (unit_number, description, vehicle_type) VALUES
|
||||||
|
('TRK-001', 'Ford F-250 Service Truck', 'Pickup'),
|
||||||
|
('VAN-002', 'Transit Connect Work Van', 'Van'),
|
||||||
|
('SUV-003', 'Chevy Tahoe Supervisor', 'SUV');
|
||||||
|
|
||||||
|
INSERT INTO resource_equipment (name, asset_tag, category) VALUES
|
||||||
|
('Bucket Truck', 'EQ-1001', 'Heavy Equipment'),
|
||||||
|
('Portable Generator', 'EQ-1002', 'Power'),
|
||||||
|
('Cable Puller', 'EQ-1003', 'Tools');
|
||||||
|
|
||||||
|
INSERT INTO resource_materials (name, unit, part_number) VALUES
|
||||||
|
('Wire 12 AWG', 'ft', 'WR-12AWG'),
|
||||||
|
('Conduit 1"', 'ft', 'CD-1IN'),
|
||||||
|
('Junction Box', 'each', 'JB-100');
|
||||||
+84
@@ -0,0 +1,84 @@
|
|||||||
|
import { setToken } from './lib/api.mjs';
|
||||||
|
import './components/wo-list.mjs';
|
||||||
|
import './components/wo-form.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',
|
||||||
|
});
|
||||||
|
|
||||||
|
keycloak.init({
|
||||||
|
onLoad: 'login-required',
|
||||||
|
pkceMethod: 'S256',
|
||||||
|
checkLoginIframe: false,
|
||||||
|
})
|
||||||
|
.then(authenticated => {
|
||||||
|
if (!authenticated) { keycloak.login(); return; }
|
||||||
|
setToken(keycloak.token);
|
||||||
|
|
||||||
|
// Refresh token before it expires
|
||||||
|
setInterval(async () => {
|
||||||
|
try { await keycloak.updateToken(60); setToken(keycloak.token); }
|
||||||
|
catch { keycloak.login(); }
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
window.addEventListener('auth:expired', () => keycloak.login());
|
||||||
|
renderApp(keycloak);
|
||||||
|
})
|
||||||
|
.catch(() => document.getElementById('app').innerHTML =
|
||||||
|
'<p style="color:red;padding:2rem">Failed to connect to authentication server.</p>');
|
||||||
|
|
||||||
|
// ── App shell ─────────────────────────────────────────────────────────────────
|
||||||
|
function renderApp(kc) {
|
||||||
|
const app = document.getElementById('app');
|
||||||
|
const userName = kc.tokenParsed?.name || kc.tokenParsed?.preferred_username || 'User';
|
||||||
|
|
||||||
|
// Nav
|
||||||
|
const nav = document.createElement('nav');
|
||||||
|
nav.innerHTML = `
|
||||||
|
<span class="brand">Work Orders</span>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<span class="user-info">${userName}</span>
|
||||||
|
<button id="logout" style="background:transparent;border:1px solid rgba(255,255,255,.4);color:#fff;padding:.3rem .8rem;font-size:.85rem">Logout</button>`;
|
||||||
|
document.body.insertBefore(nav, app);
|
||||||
|
nav.querySelector('#logout').addEventListener('click', () => kc.logout());
|
||||||
|
|
||||||
|
showList();
|
||||||
|
|
||||||
|
app.addEventListener('wo:select', e => showDetail(e.detail.id));
|
||||||
|
app.addEventListener('wo:cancel', () => showList());
|
||||||
|
app.addEventListener('wo:saved', () => showList());
|
||||||
|
|
||||||
|
document.addEventListener('keydown', e => { if (e.key === 'Escape') showList(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function showList() {
|
||||||
|
const app = document.getElementById('app');
|
||||||
|
app.innerHTML = `
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>Work Orders</h1>
|
||||||
|
<button id="new-wo">+ New Work Order</button>
|
||||||
|
</div>
|
||||||
|
<wo-list id="wo-list"></wo-list>`;
|
||||||
|
app.querySelector('#new-wo').addEventListener('click', showCreate);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCreate() {
|
||||||
|
const app = document.getElementById('app');
|
||||||
|
app.innerHTML = `
|
||||||
|
<div class="page-header"><h1>New Work Order</h1></div>
|
||||||
|
<div class="card"><wo-form></wo-form></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showDetail(id) {
|
||||||
|
const app = document.getElementById('app');
|
||||||
|
app.innerHTML = `
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>Edit Work Order</h1>
|
||||||
|
<button id="back" class="secondary">← Back</button>
|
||||||
|
</div>
|
||||||
|
<div class="card"><wo-form wo-id="${id}"></wo-form></div>`;
|
||||||
|
app.querySelector('#back').addEventListener('click', showList);
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { api } from '../lib/api.mjs';
|
||||||
|
|
||||||
|
class WoForm extends HTMLElement {
|
||||||
|
#woId = null;
|
||||||
|
#wo = null;
|
||||||
|
|
||||||
|
static get observedAttributes() { return ['wo-id']; }
|
||||||
|
attributeChangedCallback(_, __, val) { this.#woId = val ? +val : null; this.#load(); }
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
this.attachShadow({ mode: 'open' });
|
||||||
|
this.#render();
|
||||||
|
if (this.#woId) this.#load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async #load() {
|
||||||
|
if (!this.#woId) return;
|
||||||
|
this.#wo = await api.get(`/work-orders/${this.#woId}`);
|
||||||
|
this.#render();
|
||||||
|
}
|
||||||
|
|
||||||
|
#render() {
|
||||||
|
const wo = this.#wo ?? {};
|
||||||
|
this.shadowRoot.innerHTML = `
|
||||||
|
<style>
|
||||||
|
:host { display: block; }
|
||||||
|
form { display: grid; gap: 1rem; }
|
||||||
|
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
|
||||||
|
label { display: flex; flex-direction: column; gap: .3rem; font-size: .9rem; font-weight: 600; color: #1a2e3b; }
|
||||||
|
input, select, textarea {
|
||||||
|
border: 1px solid #e2ebf0; border-radius: 8px;
|
||||||
|
padding: .5rem .75rem; font-size: .95rem; background: #fff; color: #1a2e3b;
|
||||||
|
}
|
||||||
|
input:focus, select:focus, textarea:focus { outline: none; border-color: #0a7ea4; }
|
||||||
|
textarea { resize: vertical; min-height: 80px; }
|
||||||
|
.actions { display: flex; gap: .75rem; justify-content: flex-end; }
|
||||||
|
button {
|
||||||
|
padding: .5rem 1.25rem; border: none; border-radius: 8px;
|
||||||
|
font-weight: 600; cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-primary { background: #0a7ea4; color: #fff; }
|
||||||
|
.btn-cancel { background: transparent; border: 1px solid #e2ebf0; color: #1a2e3b; }
|
||||||
|
.error { color: #c0392b; font-size: .85rem; }
|
||||||
|
</style>
|
||||||
|
<form>
|
||||||
|
<label>Title *<input name="title" required value="${wo.title || ''}"></label>
|
||||||
|
<div class="row">
|
||||||
|
<label>Priority
|
||||||
|
<select name="priority">
|
||||||
|
${['low','normal','high','urgent'].map(p =>
|
||||||
|
`<option value="${p}" ${wo.priority===p?'selected':''}>${p}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Site Name<input name="site_name" value="${wo.site_name || ''}"></label>
|
||||||
|
</div>
|
||||||
|
<label>Address<input name="address" value="${wo.address || ''}"></label>
|
||||||
|
<div class="row">
|
||||||
|
<label>Scheduled Start<input type="datetime-local" name="scheduled_start"
|
||||||
|
value="${wo.scheduled_start ? wo.scheduled_start.slice(0,16) : ''}"></label>
|
||||||
|
<label>Scheduled End<input type="datetime-local" name="scheduled_end"
|
||||||
|
value="${wo.scheduled_end ? wo.scheduled_end.slice(0,16) : ''}"></label>
|
||||||
|
</div>
|
||||||
|
<label>Description<textarea name="description">${wo.description || ''}</textarea></label>
|
||||||
|
<label>Instructions<textarea name="instructions">${wo.instructions || ''}</textarea></label>
|
||||||
|
<label>Access Notes<textarea name="access_notes">${wo.access_notes || ''}</textarea></label>
|
||||||
|
<div class="error" id="err"></div>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" class="btn-cancel" id="cancel">Cancel</button>
|
||||||
|
<button type="submit" class="btn-primary">${this.#woId ? 'Save Changes' : 'Create Work Order'}</button>
|
||||||
|
</div>
|
||||||
|
</form>`;
|
||||||
|
|
||||||
|
this.shadowRoot.querySelector('#cancel').addEventListener('click', () =>
|
||||||
|
this.dispatchEvent(new CustomEvent('wo:cancel', { bubbles: true, composed: true })));
|
||||||
|
|
||||||
|
this.shadowRoot.querySelector('form').addEventListener('submit', async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Read fields directly — Shadow DOM + FormData has unreliable browser support
|
||||||
|
const val = name => (this.shadowRoot.querySelector(`[name="${name}"]`)?.value ?? '').trim();
|
||||||
|
// datetime-local gives "YYYY-MM-DDTHH:MM"; convert to full ISO so Go can parse it
|
||||||
|
const dt = name => { const v = val(name); return v ? new Date(v).toISOString() : null; };
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
title: val('title'),
|
||||||
|
priority: val('priority') || 'normal',
|
||||||
|
site_name: val('site_name'),
|
||||||
|
address: val('address'),
|
||||||
|
scheduled_start: dt('scheduled_start'),
|
||||||
|
scheduled_end: dt('scheduled_end'),
|
||||||
|
description: val('description'),
|
||||||
|
instructions: val('instructions'),
|
||||||
|
access_notes: val('access_notes'),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!body.title) {
|
||||||
|
this.shadowRoot.querySelector('#err').textContent = 'Title is required.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.shadowRoot.querySelector('#err').textContent = '';
|
||||||
|
try {
|
||||||
|
const wo = this.#woId
|
||||||
|
? await api.put(`/work-orders/${this.#woId}`, body)
|
||||||
|
: await api.post('/work-orders', body);
|
||||||
|
this.dispatchEvent(new CustomEvent('wo:saved',
|
||||||
|
{ detail: { wo }, bubbles: true, composed: true }));
|
||||||
|
} catch (err) {
|
||||||
|
this.shadowRoot.querySelector('#err').textContent = err.message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('wo-form', WoForm);
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { api } from '../lib/api.mjs';
|
||||||
|
|
||||||
|
class WoList extends HTMLElement {
|
||||||
|
#data = [];
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
this.attachShadow({ mode: 'open' });
|
||||||
|
this.#load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async #load() {
|
||||||
|
const status = this.getAttribute('filter-status') || '';
|
||||||
|
this.#data = await api.get(`/work-orders?status=${status}`) ?? [];
|
||||||
|
this.#render();
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh() { this.#load(); }
|
||||||
|
|
||||||
|
#render() {
|
||||||
|
this.shadowRoot.innerHTML = `
|
||||||
|
<style>
|
||||||
|
:host { display: block; }
|
||||||
|
.empty { color: #64748b; text-align: center; padding: 3rem; }
|
||||||
|
.grid { display: grid; gap: .75rem; }
|
||||||
|
.card {
|
||||||
|
background: #fff; border: 1px solid #e2ebf0;
|
||||||
|
border-radius: 8px; padding: 1rem 1.25rem;
|
||||||
|
cursor: pointer; display: grid;
|
||||||
|
grid-template-columns: 1fr auto; gap: .25rem 1rem;
|
||||||
|
transition: box-shadow .15s;
|
||||||
|
}
|
||||||
|
.card:hover { box-shadow: 0 4px 12px rgba(0,0,0,.08); }
|
||||||
|
.wo-num { font-size: .75rem; color: #64748b; }
|
||||||
|
.title { font-weight: 600; }
|
||||||
|
.meta { font-size: .8rem; color: #64748b; }
|
||||||
|
.badge { display:inline-block; padding:.2rem .6rem; border-radius:999px;
|
||||||
|
font-size:.7rem; font-weight:700; text-transform:uppercase;
|
||||||
|
align-self: start; margin-top:.25rem; }
|
||||||
|
.draft { background:#e2e8f0; color:#475569; }
|
||||||
|
.assigned { background:#dbeafe; color:#1d4ed8; }
|
||||||
|
.scheduled { background:#fef3c7; color:#92400e; }
|
||||||
|
.in_progress { background:#d1fae5; color:#065f46; }
|
||||||
|
.pending_review { background:#ede9fe; color:#5b21b6; }
|
||||||
|
.closed { background:#f3f4f6; color:#6b7280; }
|
||||||
|
</style>
|
||||||
|
${this.#data.length === 0
|
||||||
|
? '<div class="empty">No work orders found.</div>'
|
||||||
|
: `<div class="grid">${this.#data.map(wo => `
|
||||||
|
<div class="card" data-id="${wo.id}">
|
||||||
|
<div>
|
||||||
|
<div class="wo-num">${wo.wo_number}</div>
|
||||||
|
<div class="title">${wo.title}</div>
|
||||||
|
<div class="meta">${wo.site_name || '—'} · ${wo.steps_done}/${wo.step_count} steps</div>
|
||||||
|
</div>
|
||||||
|
<span class="badge ${wo.status}">${wo.status.replace('_',' ')}</span>
|
||||||
|
</div>`).join('')}
|
||||||
|
</div>`}`;
|
||||||
|
|
||||||
|
this.shadowRoot.querySelectorAll('.card').forEach(c =>
|
||||||
|
c.addEventListener('click', () =>
|
||||||
|
this.dispatchEvent(new CustomEvent('wo:select',
|
||||||
|
{ detail: { id: +c.dataset.id }, bubbles: true, composed: true }))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('wo-list', WoList);
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Work Orders</title>
|
||||||
|
<link rel="stylesheet" href="/styles/reset.css">
|
||||||
|
<link rel="stylesheet" href="/styles/global.css">
|
||||||
|
|
||||||
|
<!-- Keycloak JS adapter (served by Keycloak itself) -->
|
||||||
|
<script src="http://localhost:8180/js/keycloak.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<p style="padding:2rem;color:#64748b">Connecting to authentication server...</p>
|
||||||
|
</div>
|
||||||
|
<script type="module" src="/app.mjs"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
const res = await fetch(BASE + path, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body ? (isForm ? body : JSON.stringify(body)) : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
window.dispatchEvent(new CustomEvent('auth:expired'));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(json.error || `HTTP ${res.status}`);
|
||||||
|
return json.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
get: (path) => request('GET', path),
|
||||||
|
post: (path, body) => request('POST', path, body),
|
||||||
|
put: (path, body) => request('PUT', path, body),
|
||||||
|
delete: (path) => request('DELETE', path),
|
||||||
|
upload: (path, form) => request('POST', path, form, true),
|
||||||
|
};
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
:root {
|
||||||
|
--navy: #0d2137;
|
||||||
|
--accent: #0a7ea4;
|
||||||
|
--accent-lt: #14b8d4;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--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);
|
||||||
|
|
||||||
|
font-family: Calibri, 'Segoe UI', system-ui, sans-serif;
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
body { min-height: 100vh; }
|
||||||
|
|
||||||
|
a { color: var(--accent); text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: .5rem 1rem;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: opacity .15s;
|
||||||
|
}
|
||||||
|
button:hover { opacity: .85; }
|
||||||
|
button.secondary {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
input, select, textarea {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: .5rem .75rem;
|
||||||
|
width: 100%;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
transition: border-color .15s;
|
||||||
|
}
|
||||||
|
input:focus, select:focus, textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1.25rem;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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: #e2e8f0; color: #475569; }
|
||||||
|
.badge-assigned { background: #dbeafe; color: #1d4ed8; }
|
||||||
|
.badge-scheduled { background: #fef3c7; color: #92400e; }
|
||||||
|
.badge-in_progress { background: #d1fae5; color: #065f46; }
|
||||||
|
.badge-pending_review { background: #ede9fe; color: #5b21b6; }
|
||||||
|
.badge-closed { background: #f3f4f6; color: #6b7280; }
|
||||||
|
|
||||||
|
#app { max-width: 1200px; margin: 0 auto; padding: 0 1rem; }
|
||||||
|
|
||||||
|
nav {
|
||||||
|
background: var(--navy);
|
||||||
|
color: #fff;
|
||||||
|
padding: .75rem 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
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,.7); }
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 1.5rem 0 1rem;
|
||||||
|
}
|
||||||
|
.page-header h1 { font-size: 1.4rem; }
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
*, *::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%; }
|
||||||
|
input, button, textarea, select { font: inherit; }
|
||||||
|
p, h1, h2, h3, h4, h5, h6 { overflow-wrap: break-word; }
|
||||||
Reference in New Issue
Block a user