Add migration scripts, activity handler, and registry components for equipment, materials, and people

This commit is contained in:
2026-05-17 10:11:56 -04:00
parent fb67c76f45
commit 17e05cb61d
28 changed files with 3777 additions and 34 deletions
+42
View File
@@ -0,0 +1,42 @@
package handlers
import (
"net/http"
"github.com/jmoiron/sqlx"
"workorders/internal/model"
)
type ActivityHandler struct{ db *sqlx.DB }
func NewActivityHandler(db *sqlx.DB) *ActivityHandler { return &ActivityHandler{db: db} }
func (h *ActivityHandler) List(w http.ResponseWriter, r *http.Request) {
woID, err := intParam(r, "id")
if err != nil {
respondError(w, http.StatusBadRequest, "invalid id")
return
}
var rows []model.AuditEntry
err = h.db.Select(&rows, `
SELECT
a.id,
a.action,
ISNULL(a.old_value,'') AS old_value,
ISNULL(a.new_value,'') AS new_value,
ISNULL(u.display_name, ISNULL(u.username, CAST(a.performed_by AS NVARCHAR(100)))) AS performed_by,
a.performed_at
FROM wo_audit_log a
LEFT JOIN users u ON u.id = a.performed_by
WHERE a.wo_id = @p1
ORDER BY a.performed_at DESC`,
woID)
if err != nil {
respondError(w, http.StatusInternalServerError, err.Error())
return
}
if rows == nil {
rows = []model.AuditEntry{}
}
respond(w, http.StatusOK, rows)
}
+285
View File
@@ -0,0 +1,285 @@
package handlers
import (
"encoding/json"
"net/http"
"github.com/jmoiron/sqlx"
"workorders/internal/model"
)
type ProfileHandler struct{ db *sqlx.DB }
func NewProfileHandler(db *sqlx.DB) *ProfileHandler { return &ProfileHandler{db: db} }
const profileSelectCols = `
p.id, p.name, p.description, p.category, p.default_priority,
p.default_duration_hours, p.default_instructions, p.active,
p.created_at, p.updated_at,
COUNT(s.id) AS step_count`
const profileGroupBy = `
GROUP BY p.id, p.name, p.description, p.category, p.default_priority,
p.default_duration_hours, p.default_instructions, p.active,
p.created_at, p.updated_at`
// ── Profile CRUD ──────────────────────────────────────────────────────────────
func (h *ProfileHandler) List(w http.ResponseWriter, r *http.Request) {
search := r.URL.Query().Get("search")
all := r.URL.Query().Get("all") == "1"
pat := "%" + search + "%"
base := `SELECT ` + profileSelectCols + `
FROM wo_profiles p
LEFT JOIN wo_profile_steps s ON s.profile_id = p.id`
var rows []model.Profile
var err error
switch {
case all && search != "":
err = h.db.Select(&rows, base+`
WHERE (p.name LIKE @p1 OR p.category LIKE @p1)`+profileGroupBy+` ORDER BY p.name`, pat)
case all:
err = h.db.Select(&rows, base+profileGroupBy+` ORDER BY p.name`)
case search != "":
err = h.db.Select(&rows, base+`
WHERE p.active=1 AND (p.name LIKE @p1 OR p.category LIKE @p1)`+profileGroupBy+` ORDER BY p.name`, pat)
default:
err = h.db.Select(&rows, base+`
WHERE p.active=1`+profileGroupBy+` ORDER BY p.name`)
}
if err != nil {
respondError(w, http.StatusInternalServerError, err.Error())
return
}
if rows == nil {
rows = []model.Profile{}
}
respond(w, http.StatusOK, rows)
}
func (h *ProfileHandler) Get(w http.ResponseWriter, r *http.Request) {
id, err := intParam(r, "id")
if err != nil {
respondError(w, http.StatusBadRequest, "invalid id")
return
}
h.writeProfile(w, id)
}
func (h *ProfileHandler) Create(w http.ResponseWriter, r *http.Request) {
var body model.Profile
if err := decode(r, &body); err != nil || body.Name == "" {
respondError(w, http.StatusBadRequest, "name required")
return
}
if body.DefaultPriority == "" {
body.DefaultPriority = "normal"
}
var id int
err := h.db.QueryRow(`
INSERT INTO wo_profiles (name, description, category, default_priority, default_duration_hours, default_instructions)
OUTPUT INSERTED.id
VALUES (@p1, @p2, @p3, @p4, @p5, @p6)`,
body.Name, body.Description, body.Category, body.DefaultPriority,
body.DefaultDurationHours, body.DefaultInstructions,
).Scan(&id)
if err != nil {
respondError(w, http.StatusInternalServerError, err.Error())
return
}
h.writeProfile(w, id)
}
func (h *ProfileHandler) Update(w http.ResponseWriter, r *http.Request) {
id, err := intParam(r, "id")
if err != nil {
respondError(w, http.StatusBadRequest, "invalid id")
return
}
var body model.Profile
if err := decode(r, &body); err != nil || body.Name == "" {
respondError(w, http.StatusBadRequest, "name required")
return
}
if body.DefaultPriority == "" {
body.DefaultPriority = "normal"
}
_, err = h.db.Exec(`
UPDATE wo_profiles SET
name=@p1, description=@p2, category=@p3, default_priority=@p4,
default_duration_hours=@p5, default_instructions=@p6, active=@p7,
updated_at=GETUTCDATE()
WHERE id=@p8`,
body.Name, body.Description, body.Category, body.DefaultPriority,
body.DefaultDurationHours, body.DefaultInstructions, body.Active, id)
if err != nil {
respondError(w, http.StatusInternalServerError, err.Error())
return
}
h.writeProfile(w, id)
}
func (h *ProfileHandler) Delete(w http.ResponseWriter, r *http.Request) {
id, err := intParam(r, "id")
if err != nil {
respondError(w, http.StatusBadRequest, "invalid id")
return
}
h.db.Exec(`UPDATE wo_profiles SET active=0, updated_at=GETUTCDATE() WHERE id=@p1`, id)
respond(w, http.StatusOK, map[string]any{"deactivated": id})
}
// writeProfile fetches a single profile with its steps and writes it to w.
func (h *ProfileHandler) writeProfile(w http.ResponseWriter, id int) {
var p model.Profile
err := h.db.Get(&p, `SELECT `+profileSelectCols+`
FROM wo_profiles p
LEFT JOIN wo_profile_steps s ON s.profile_id = p.id
WHERE p.id = @p1`+profileGroupBy, id)
if err != nil {
respondError(w, http.StatusNotFound, "profile not found")
return
}
if err := h.db.Select(&p.Steps,
`SELECT * FROM wo_profile_steps WHERE profile_id=@p1 ORDER BY step_order`, id); err != nil {
p.Steps = []model.ProfileStep{}
}
respond(w, http.StatusOK, p)
}
// ── Profile Steps ─────────────────────────────────────────────────────────────
func (h *ProfileHandler) ListSteps(w http.ResponseWriter, r *http.Request) {
id, err := intParam(r, "id")
if err != nil {
respondError(w, http.StatusBadRequest, "invalid id")
return
}
var steps []model.ProfileStep
if err := h.db.Select(&steps,
`SELECT * FROM wo_profile_steps WHERE profile_id=@p1 ORDER BY step_order`, id); err != nil {
steps = []model.ProfileStep{}
}
respond(w, http.StatusOK, steps)
}
func (h *ProfileHandler) CreateStep(w http.ResponseWriter, r *http.Request) {
profileID, err := intParam(r, "id")
if err != nil {
respondError(w, http.StatusBadRequest, "invalid id")
return
}
var body model.ProfileStep
if err := decode(r, &body); err != nil || body.Title == "" {
respondError(w, http.StatusBadRequest, "title required")
return
}
var maxOrder int
h.db.QueryRow(`SELECT ISNULL(MAX(step_order),0) FROM wo_profile_steps WHERE profile_id=@p1`, profileID).Scan(&maxOrder)
var sid int
err = h.db.QueryRow(`
INSERT INTO wo_profile_steps (profile_id, step_order, title, description, required)
OUTPUT INSERTED.id VALUES (@p1, @p2, @p3, @p4, @p5)`,
profileID, maxOrder+1, body.Title, body.Description, body.Required,
).Scan(&sid)
if err != nil {
respondError(w, http.StatusInternalServerError, err.Error())
return
}
var step model.ProfileStep
h.db.Get(&step, `SELECT * FROM wo_profile_steps WHERE id=@p1`, sid)
respond(w, http.StatusCreated, step)
}
func (h *ProfileHandler) UpdateStep(w http.ResponseWriter, r *http.Request) {
sid, err := intParam(r, "sid")
if err != nil {
respondError(w, http.StatusBadRequest, "invalid sid")
return
}
var body model.ProfileStep
if err := decode(r, &body); err != nil || body.Title == "" {
respondError(w, http.StatusBadRequest, "title required")
return
}
h.db.Exec(`UPDATE wo_profile_steps SET title=@p1, description=@p2, required=@p3, step_order=@p4 WHERE id=@p5`,
body.Title, body.Description, body.Required, body.StepOrder, sid)
var step model.ProfileStep
h.db.Get(&step, `SELECT * FROM wo_profile_steps WHERE id=@p1`, sid)
respond(w, http.StatusOK, step)
}
func (h *ProfileHandler) DeleteStep(w http.ResponseWriter, r *http.Request) {
sid, err := intParam(r, "sid")
if err != nil {
respondError(w, http.StatusBadRequest, "invalid sid")
return
}
h.db.Exec(`DELETE FROM wo_profile_steps WHERE id=@p1`, sid)
respond(w, http.StatusOK, map[string]any{"deleted": sid})
}
// ── Apply Profile to Work Order ───────────────────────────────────────────────
func (h *ProfileHandler) Apply(w http.ResponseWriter, r *http.Request) {
woID, err := intParam(r, "id")
if err != nil {
respondError(w, http.StatusBadRequest, "invalid wo id")
return
}
profileID, err := intParam(r, "profileId")
if err != nil {
respondError(w, http.StatusBadRequest, "invalid profile id")
return
}
var body struct {
Mode string `json:"mode"` // "append" | "replace"
}
json.NewDecoder(r.Body).Decode(&body)
if body.Mode != "replace" {
body.Mode = "append"
}
var p model.Profile
if err := h.db.Get(&p, `SELECT * FROM wo_profiles WHERE id=@p1 AND active=1`, profileID); err != nil {
respondError(w, http.StatusNotFound, "profile not found")
return
}
var steps []model.ProfileStep
h.db.Select(&steps, `SELECT * FROM wo_profile_steps WHERE profile_id=@p1 ORDER BY step_order`, profileID)
if body.Mode == "replace" {
h.db.Exec(`DELETE FROM wo_steps WHERE wo_id=@p1`, woID)
}
var maxOrder int
h.db.QueryRow(`SELECT ISNULL(MAX(step_order),0) FROM wo_steps WHERE wo_id=@p1`, woID).Scan(&maxOrder)
for _, s := range steps {
h.db.Exec(`
INSERT INTO wo_steps (wo_id, step_order, title, description, required)
VALUES (@p1, @p2, @p3, @p4, @p5)`,
woID, maxOrder+s.StepOrder, s.Title, s.Description, s.Required)
}
// Fill instructions if blank; update priority only on draft WOs
h.db.Exec(`
UPDATE work_orders SET
instructions = CASE WHEN (instructions IS NULL OR instructions = '') THEN @p1 ELSE instructions END,
priority = CASE WHEN status = 'draft' THEN @p2 ELSE priority END,
updated_at = GETUTCDATE()
WHERE id = @p3`,
p.DefaultInstructions, p.DefaultPriority, woID)
respond(w, http.StatusOK, map[string]any{
"applied": profileID,
"steps_added": len(steps),
"mode": body.Mode,
})
}
+280 -8
View File
@@ -11,26 +11,298 @@ 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) {
// ── People ────────────────────────────────────────────────────────────────────
func (h *RegistryHandler) ListPeople(w http.ResponseWriter, r *http.Request) {
search := r.URL.Query().Get("search")
all := r.URL.Query().Get("all") == "1"
var rows []model.RegistryPerson
h.db.Select(&rows, `SELECT id,name,role,email,phone FROM resource_people WHERE active=1 ORDER BY name`)
var err error
pat := "%" + search + "%"
if all && search != "" {
err = h.db.Select(&rows, `SELECT id,name,role,email,phone,active FROM resource_people WHERE (name LIKE @p1 OR email LIKE @p1 OR role LIKE @p1) ORDER BY name`, pat)
} else if all {
err = h.db.Select(&rows, `SELECT id,name,role,email,phone,active FROM resource_people ORDER BY name`)
} else if search != "" {
err = h.db.Select(&rows, `SELECT id,name,role,email,phone,active FROM resource_people WHERE active=1 AND (name LIKE @p1 OR email LIKE @p1 OR role LIKE @p1) ORDER BY name`, pat)
} else {
err = h.db.Select(&rows, `SELECT id,name,role,email,phone,active FROM resource_people WHERE active=1 ORDER BY name`)
}
if err != nil {
respondError(w, http.StatusInternalServerError, err.Error())
return
}
if rows == nil {
rows = []model.RegistryPerson{}
}
respond(w, http.StatusOK, rows)
}
func (h *RegistryHandler) Vehicles(w http.ResponseWriter, r *http.Request) {
func (h *RegistryHandler) CreatePerson(w http.ResponseWriter, r *http.Request) {
var body model.RegistryPerson
if err := decode(r, &body); err != nil || body.Name == "" {
respondError(w, http.StatusBadRequest, "name required")
return
}
var id int
err := h.db.QueryRow(`INSERT INTO resource_people (name,role,email,phone) OUTPUT INSERTED.id VALUES (@p1,@p2,@p3,@p4)`,
body.Name, body.Role, body.Email, body.Phone).Scan(&id)
if err != nil {
respondError(w, http.StatusInternalServerError, err.Error())
return
}
var person model.RegistryPerson
h.db.Get(&person, `SELECT id,name,role,email,phone,active FROM resource_people WHERE id=@p1`, id)
respond(w, http.StatusCreated, person)
}
func (h *RegistryHandler) UpdatePerson(w http.ResponseWriter, r *http.Request) {
id, err := intParam(r, "id")
if err != nil {
respondError(w, http.StatusBadRequest, "invalid id")
return
}
var body model.RegistryPerson
if err := decode(r, &body); err != nil || body.Name == "" {
respondError(w, http.StatusBadRequest, "name required")
return
}
h.db.Exec(`UPDATE resource_people SET name=@p1,role=@p2,email=@p3,phone=@p4,active=@p5 WHERE id=@p6`,
body.Name, body.Role, body.Email, body.Phone, body.Active, id)
var person model.RegistryPerson
h.db.Get(&person, `SELECT id,name,role,email,phone,active FROM resource_people WHERE id=@p1`, id)
respond(w, http.StatusOK, person)
}
func (h *RegistryHandler) DeletePerson(w http.ResponseWriter, r *http.Request) {
id, err := intParam(r, "id")
if err != nil {
respondError(w, http.StatusBadRequest, "invalid id")
return
}
h.db.Exec(`UPDATE resource_people SET active=0 WHERE id=@p1`, id)
respond(w, http.StatusOK, map[string]any{"deactivated": id})
}
// ── Vehicles ──────────────────────────────────────────────────────────────────
func (h *RegistryHandler) ListVehicles(w http.ResponseWriter, r *http.Request) {
search := r.URL.Query().Get("search")
all := r.URL.Query().Get("all") == "1"
var rows []model.RegistryVehicle
h.db.Select(&rows, `SELECT id,unit_number,description,vehicle_type FROM resource_vehicles WHERE active=1 ORDER BY unit_number`)
var err error
pat := "%" + search + "%"
if all && search != "" {
err = h.db.Select(&rows, `SELECT id,unit_number,description,vehicle_type,active FROM resource_vehicles WHERE (unit_number LIKE @p1 OR description LIKE @p1 OR vehicle_type LIKE @p1) ORDER BY unit_number`, pat)
} else if all {
err = h.db.Select(&rows, `SELECT id,unit_number,description,vehicle_type,active FROM resource_vehicles ORDER BY unit_number`)
} else if search != "" {
err = h.db.Select(&rows, `SELECT id,unit_number,description,vehicle_type,active FROM resource_vehicles WHERE active=1 AND (unit_number LIKE @p1 OR description LIKE @p1) ORDER BY unit_number`, pat)
} else {
err = h.db.Select(&rows, `SELECT id,unit_number,description,vehicle_type,active FROM resource_vehicles WHERE active=1 ORDER BY unit_number`)
}
if err != nil {
respondError(w, http.StatusInternalServerError, err.Error())
return
}
if rows == nil {
rows = []model.RegistryVehicle{}
}
respond(w, http.StatusOK, rows)
}
func (h *RegistryHandler) Equipment(w http.ResponseWriter, r *http.Request) {
func (h *RegistryHandler) CreateVehicle(w http.ResponseWriter, r *http.Request) {
var body model.RegistryVehicle
if err := decode(r, &body); err != nil || body.UnitNumber == "" {
respondError(w, http.StatusBadRequest, "unit_number required")
return
}
var id int
err := h.db.QueryRow(`INSERT INTO resource_vehicles (unit_number,description,vehicle_type) OUTPUT INSERTED.id VALUES (@p1,@p2,@p3)`,
body.UnitNumber, body.Description, body.VehicleType).Scan(&id)
if err != nil {
respondError(w, http.StatusInternalServerError, err.Error())
return
}
var v model.RegistryVehicle
h.db.Get(&v, `SELECT id,unit_number,description,vehicle_type,active FROM resource_vehicles WHERE id=@p1`, id)
respond(w, http.StatusCreated, v)
}
func (h *RegistryHandler) UpdateVehicle(w http.ResponseWriter, r *http.Request) {
id, err := intParam(r, "id")
if err != nil {
respondError(w, http.StatusBadRequest, "invalid id")
return
}
var body model.RegistryVehicle
if err := decode(r, &body); err != nil || body.UnitNumber == "" {
respondError(w, http.StatusBadRequest, "unit_number required")
return
}
h.db.Exec(`UPDATE resource_vehicles SET unit_number=@p1,description=@p2,vehicle_type=@p3,active=@p4 WHERE id=@p5`,
body.UnitNumber, body.Description, body.VehicleType, body.Active, id)
var v model.RegistryVehicle
h.db.Get(&v, `SELECT id,unit_number,description,vehicle_type,active FROM resource_vehicles WHERE id=@p1`, id)
respond(w, http.StatusOK, v)
}
func (h *RegistryHandler) DeleteVehicle(w http.ResponseWriter, r *http.Request) {
id, err := intParam(r, "id")
if err != nil {
respondError(w, http.StatusBadRequest, "invalid id")
return
}
h.db.Exec(`UPDATE resource_vehicles SET active=0 WHERE id=@p1`, id)
respond(w, http.StatusOK, map[string]any{"deactivated": id})
}
// ── Equipment ─────────────────────────────────────────────────────────────────
func (h *RegistryHandler) ListEquipment(w http.ResponseWriter, r *http.Request) {
search := r.URL.Query().Get("search")
all := r.URL.Query().Get("all") == "1"
var rows []model.RegistryEquipment
h.db.Select(&rows, `SELECT id,name,asset_tag,category FROM resource_equipment WHERE active=1 ORDER BY name`)
var err error
pat := "%" + search + "%"
if all && search != "" {
err = h.db.Select(&rows, `SELECT id,name,asset_tag,category,active FROM resource_equipment WHERE (name LIKE @p1 OR asset_tag LIKE @p1 OR category LIKE @p1) ORDER BY name`, pat)
} else if all {
err = h.db.Select(&rows, `SELECT id,name,asset_tag,category,active FROM resource_equipment ORDER BY name`)
} else if search != "" {
err = h.db.Select(&rows, `SELECT id,name,asset_tag,category,active FROM resource_equipment WHERE active=1 AND (name LIKE @p1 OR asset_tag LIKE @p1) ORDER BY name`, pat)
} else {
err = h.db.Select(&rows, `SELECT id,name,asset_tag,category,active FROM resource_equipment WHERE active=1 ORDER BY name`)
}
if err != nil {
respondError(w, http.StatusInternalServerError, err.Error())
return
}
if rows == nil {
rows = []model.RegistryEquipment{}
}
respond(w, http.StatusOK, rows)
}
func (h *RegistryHandler) Materials(w http.ResponseWriter, r *http.Request) {
func (h *RegistryHandler) CreateEquipment(w http.ResponseWriter, r *http.Request) {
var body model.RegistryEquipment
if err := decode(r, &body); err != nil || body.Name == "" {
respondError(w, http.StatusBadRequest, "name required")
return
}
var id int
err := h.db.QueryRow(`INSERT INTO resource_equipment (name,asset_tag,category) OUTPUT INSERTED.id VALUES (@p1,@p2,@p3)`,
body.Name, body.AssetTag, body.Category).Scan(&id)
if err != nil {
respondError(w, http.StatusInternalServerError, err.Error())
return
}
var eq model.RegistryEquipment
h.db.Get(&eq, `SELECT id,name,asset_tag,category,active FROM resource_equipment WHERE id=@p1`, id)
respond(w, http.StatusCreated, eq)
}
func (h *RegistryHandler) UpdateEquipment(w http.ResponseWriter, r *http.Request) {
id, err := intParam(r, "id")
if err != nil {
respondError(w, http.StatusBadRequest, "invalid id")
return
}
var body model.RegistryEquipment
if err := decode(r, &body); err != nil || body.Name == "" {
respondError(w, http.StatusBadRequest, "name required")
return
}
h.db.Exec(`UPDATE resource_equipment SET name=@p1,asset_tag=@p2,category=@p3,active=@p4 WHERE id=@p5`,
body.Name, body.AssetTag, body.Category, body.Active, id)
var eq model.RegistryEquipment
h.db.Get(&eq, `SELECT id,name,asset_tag,category,active FROM resource_equipment WHERE id=@p1`, id)
respond(w, http.StatusOK, eq)
}
func (h *RegistryHandler) DeleteEquipment(w http.ResponseWriter, r *http.Request) {
id, err := intParam(r, "id")
if err != nil {
respondError(w, http.StatusBadRequest, "invalid id")
return
}
h.db.Exec(`UPDATE resource_equipment SET active=0 WHERE id=@p1`, id)
respond(w, http.StatusOK, map[string]any{"deactivated": id})
}
// ── Materials ─────────────────────────────────────────────────────────────────
func (h *RegistryHandler) ListMaterials(w http.ResponseWriter, r *http.Request) {
search := r.URL.Query().Get("search")
all := r.URL.Query().Get("all") == "1"
var rows []model.RegistryMaterial
h.db.Select(&rows, `SELECT id,name,unit,part_number FROM resource_materials WHERE active=1 ORDER BY name`)
var err error
pat := "%" + search + "%"
if all && search != "" {
err = h.db.Select(&rows, `SELECT id,name,unit,part_number,active FROM resource_materials WHERE (name LIKE @p1 OR part_number LIKE @p1) ORDER BY name`, pat)
} else if all {
err = h.db.Select(&rows, `SELECT id,name,unit,part_number,active FROM resource_materials ORDER BY name`)
} else if search != "" {
err = h.db.Select(&rows, `SELECT id,name,unit,part_number,active FROM resource_materials WHERE active=1 AND (name LIKE @p1 OR part_number LIKE @p1) ORDER BY name`, pat)
} else {
err = h.db.Select(&rows, `SELECT id,name,unit,part_number,active FROM resource_materials WHERE active=1 ORDER BY name`)
}
if err != nil {
respondError(w, http.StatusInternalServerError, err.Error())
return
}
if rows == nil {
rows = []model.RegistryMaterial{}
}
respond(w, http.StatusOK, rows)
}
func (h *RegistryHandler) CreateMaterial(w http.ResponseWriter, r *http.Request) {
var body model.RegistryMaterial
if err := decode(r, &body); err != nil || body.Name == "" {
respondError(w, http.StatusBadRequest, "name required")
return
}
var id int
err := h.db.QueryRow(`INSERT INTO resource_materials (name,unit,part_number) OUTPUT INSERTED.id VALUES (@p1,@p2,@p3)`,
body.Name, body.Unit, body.PartNumber).Scan(&id)
if err != nil {
respondError(w, http.StatusInternalServerError, err.Error())
return
}
var m model.RegistryMaterial
h.db.Get(&m, `SELECT id,name,unit,part_number,active FROM resource_materials WHERE id=@p1`, id)
respond(w, http.StatusCreated, m)
}
func (h *RegistryHandler) UpdateMaterial(w http.ResponseWriter, r *http.Request) {
id, err := intParam(r, "id")
if err != nil {
respondError(w, http.StatusBadRequest, "invalid id")
return
}
var body model.RegistryMaterial
if err := decode(r, &body); err != nil || body.Name == "" {
respondError(w, http.StatusBadRequest, "name required")
return
}
h.db.Exec(`UPDATE resource_materials SET name=@p1,unit=@p2,part_number=@p3,active=@p4 WHERE id=@p5`,
body.Name, body.Unit, body.PartNumber, body.Active, id)
var m model.RegistryMaterial
h.db.Get(&m, `SELECT id,name,unit,part_number,active FROM resource_materials WHERE id=@p1`, id)
respond(w, http.StatusOK, m)
}
func (h *RegistryHandler) DeleteMaterial(w http.ResponseWriter, r *http.Request) {
id, err := intParam(r, "id")
if err != nil {
respondError(w, http.StatusBadRequest, "invalid id")
return
}
h.db.Exec(`UPDATE resource_materials SET active=0 WHERE id=@p1`, id)
respond(w, http.StatusOK, map[string]any{"deactivated": id})
}
+12
View File
@@ -93,6 +93,18 @@ func (h *StepHandler) Complete(w http.ResponseWriter, r *http.Request) {
respond(w, http.StatusOK, step)
}
func (h *StepHandler) Uncomplete(w http.ResponseWriter, r *http.Request) {
sid, err := intParam(r, "sid")
if err != nil {
respondError(w, http.StatusBadRequest, "invalid sid")
return
}
h.db.Exec(`UPDATE wo_steps SET completed=0,completed_by=NULL,completed_at=NULL,notes='' WHERE id=@p1`, sid)
var step model.Step
h.db.Get(&step, `SELECT * FROM wo_steps WHERE id=@p1`, sid)
respond(w, http.StatusOK, step)
}
func (h *StepHandler) Delete(w http.ResponseWriter, r *http.Request) {
sid, err := intParam(r, "sid")
if err != nil {
+38 -5
View File
@@ -20,6 +20,9 @@ func NewRouter(cfg *config.Config, db *sqlx.DB) http.Handler {
// Serve frontend static files
r.Handle("/*", http.FileServer(http.Dir("./web")))
// Public routes — uploads served without auth (paths are UUIDs, not guessable)
r.Handle("/uploads/*", http.StripPrefix("/uploads/", http.FileServer(http.Dir(cfg.UploadPath))))
// Public auth routes
auth := handlers.NewAuthHandler(db, cfg)
r.Post("/api/auth/login", auth.Login)
@@ -44,6 +47,7 @@ func NewRouter(cfg *config.Config, db *sqlx.DB) http.Handler {
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.Post("/api/work-orders/{id}/steps/{sid}/uncomplete", step.Uncomplete)
r.Delete("/api/work-orders/{id}/steps/{sid}", step.Delete)
res := handlers.NewResourceHandler(db)
@@ -55,17 +59,46 @@ func NewRouter(cfg *config.Config, db *sqlx.DB) http.Handler {
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)
act := handlers.NewActivityHandler(db)
r.Get("/api/work-orders/{id}/activity", act.List)
prof := handlers.NewProfileHandler(db)
r.Get("/api/profiles", prof.List)
r.Post("/api/profiles", prof.Create)
r.Get("/api/profiles/{id}", prof.Get)
r.Put("/api/profiles/{id}", prof.Update)
r.Delete("/api/profiles/{id}", prof.Delete)
r.Get("/api/profiles/{id}/steps", prof.ListSteps)
r.Post("/api/profiles/{id}/steps", prof.CreateStep)
r.Put("/api/profiles/{id}/steps/{sid}", prof.UpdateStep)
r.Delete("/api/profiles/{id}/steps/{sid}", prof.DeleteStep)
r.Post("/api/work-orders/{id}/apply-profile/{profileId}", prof.Apply)
reg := handlers.NewRegistryHandler(db)
r.Get("/api/registry/people", reg.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/registry/people", reg.ListPeople)
r.Post("/api/registry/people", reg.CreatePerson)
r.Put("/api/registry/people/{id}", reg.UpdatePerson)
r.Delete("/api/registry/people/{id}", reg.DeletePerson)
r.Get("/api/registry/vehicles", reg.ListVehicles)
r.Post("/api/registry/vehicles", reg.CreateVehicle)
r.Put("/api/registry/vehicles/{id}", reg.UpdateVehicle)
r.Delete("/api/registry/vehicles/{id}", reg.DeleteVehicle)
r.Get("/api/registry/equipment", reg.ListEquipment)
r.Post("/api/registry/equipment", reg.CreateEquipment)
r.Put("/api/registry/equipment/{id}", reg.UpdateEquipment)
r.Delete("/api/registry/equipment/{id}", reg.DeleteEquipment)
r.Get("/api/registry/materials", reg.ListMaterials)
r.Post("/api/registry/materials", reg.CreateMaterial)
r.Put("/api/registry/materials/{id}", reg.UpdateMaterial)
r.Delete("/api/registry/materials/{id}", reg.DeleteMaterial)
})
return r
+44 -5
View File
@@ -101,11 +101,12 @@ type AccountingCode struct {
// ── Resource Registry ─────────────────────────────────────────────────────────
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"`
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"`
Active bool `db:"active" json:"active"`
}
type RegistryVehicle struct {
@@ -113,6 +114,7 @@ type RegistryVehicle struct {
UnitNumber string `db:"unit_number" json:"unit_number"`
Description string `db:"description" json:"description"`
VehicleType string `db:"vehicle_type" json:"vehicle_type"`
Active bool `db:"active" json:"active"`
}
type RegistryEquipment struct {
@@ -120,6 +122,7 @@ type RegistryEquipment struct {
Name string `db:"name" json:"name"`
AssetTag string `db:"asset_tag" json:"asset_tag"`
Category string `db:"category" json:"category"`
Active bool `db:"active" json:"active"`
}
type RegistryMaterial struct {
@@ -127,6 +130,42 @@ type RegistryMaterial struct {
Name string `db:"name" json:"name"`
Unit string `db:"unit" json:"unit"`
PartNumber string `db:"part_number" json:"part_number"`
Active bool `db:"active" json:"active"`
}
type AuditEntry struct {
ID int `db:"id" json:"id"`
Action string `db:"action" json:"action"`
OldValue string `db:"old_value" json:"old_value"`
NewValue string `db:"new_value" json:"new_value"`
PerformedBy string `db:"performed_by" json:"performed_by"`
PerformedAt time.Time `db:"performed_at" json:"performed_at"`
}
// ── Work Order Profiles ───────────────────────────────────────────────────────
type Profile struct {
ID int `db:"id" json:"id"`
Name string `db:"name" json:"name"`
Description string `db:"description" json:"description"`
Category string `db:"category" json:"category"`
DefaultPriority string `db:"default_priority" json:"default_priority"`
DefaultDurationHours *int `db:"default_duration_hours" json:"default_duration_hours"`
DefaultInstructions string `db:"default_instructions" json:"default_instructions"`
Active bool `db:"active" json:"active"`
StepCount int `db:"step_count" json:"step_count"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
Steps []ProfileStep `db:"-" json:"steps,omitempty"`
}
type ProfileStep struct {
ID int `db:"id" json:"id"`
ProfileID int `db:"profile_id" json:"profile_id"`
StepOrder int `db:"step_order" json:"step_order"`
Title string `db:"title" json:"title"`
Description string `db:"description" json:"description"`
Required bool `db:"required" json:"required"`
}
// ── Users & Auth ──────────────────────────────────────────────────────────────