Initialize work order management system with database schema, API handlers, web client, and Docker configuration.

This commit is contained in:
2026-05-16 16:15:53 -04:00
parent c135722339
commit f904431ec3
28 changed files with 2171 additions and 0 deletions
+50
View File
@@ -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)
}
+107
View File
@@ -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})
}
+7
View File
@@ -0,0 +1,7 @@
package handlers
import "net/http"
func Me(w http.ResponseWriter, r *http.Request) {
respond(w, http.StatusOK, userFromCtx(r))
}
+36
View File
@@ -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
}
+36
View File
@@ -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)
}
+74
View File
@@ -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})
}
+104
View File
@@ -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})
}
+171
View File
@@ -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)
}