diff --git a/internal/api/handlers/profile.go b/internal/api/handlers/profile.go index c2af61c..883ded1 100644 --- a/internal/api/handlers/profile.go +++ b/internal/api/handlers/profile.go @@ -178,14 +178,17 @@ func (h *ProfileHandler) CreateStep(w http.ResponseWriter, r *http.Request) { respondError(w, http.StatusBadRequest, "title required") return } + if body.StepType == "" { + body.StepType = "work_step" + } 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, + INSERT INTO wo_profile_steps (profile_id, step_order, title, description, required, step_type, type_config) + OUTPUT INSERTED.id VALUES (@p1, @p2, @p3, @p4, @p5, @p6, @p7)`, + profileID, maxOrder+1, body.Title, body.Description, body.Required, body.StepType, body.TypeConfig, ).Scan(&sid) if err != nil { respondError(w, http.StatusInternalServerError, err.Error()) @@ -207,8 +210,11 @@ func (h *ProfileHandler) UpdateStep(w http.ResponseWriter, r *http.Request) { 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) + if body.StepType == "" { + body.StepType = "work_step" + } + h.db.Exec(`UPDATE wo_profile_steps SET title=@p1, description=@p2, required=@p3, step_order=@p4, step_type=@p5, type_config=@p6 WHERE id=@p7`, + body.Title, body.Description, body.Required, body.StepOrder, body.StepType, body.TypeConfig, sid) var step model.ProfileStep h.db.Get(&step, `SELECT * FROM wo_profile_steps WHERE id=@p1`, sid) respond(w, http.StatusOK, step) @@ -262,10 +268,13 @@ func (h *ProfileHandler) Apply(w http.ResponseWriter, r *http.Request) { h.db.QueryRow(`SELECT ISNULL(MAX(step_order),0) FROM wo_steps WHERE wo_id=@p1`, woID).Scan(&maxOrder) for _, s := range steps { + if s.StepType == "" { + s.StepType = "work_step" + } 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) + INSERT INTO wo_steps (wo_id, step_order, title, description, required, step_type, type_config) + VALUES (@p1, @p2, @p3, @p4, @p5, @p6, @p7)`, + woID, maxOrder+s.StepOrder, s.Title, s.Description, s.Required, s.StepType, s.TypeConfig) } // Fill instructions if blank; update priority only on draft WOs diff --git a/internal/api/handlers/step.go b/internal/api/handlers/step.go index 8913813..0a09f21 100644 --- a/internal/api/handlers/step.go +++ b/internal/api/handlers/step.go @@ -41,11 +41,14 @@ func (h *StepHandler) Create(w http.ResponseWriter, r *http.Request) { var maxOrder int h.db.QueryRow(`SELECT ISNULL(MAX(step_order),0) FROM wo_steps WHERE wo_id=@p1`, woID).Scan(&maxOrder) + if body.StepType == "" { + body.StepType = "work_step" + } 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, + INSERT INTO wo_steps (wo_id,step_order,title,description,required,step_type,type_config) + OUTPUT INSERTED.id VALUES (@p1,@p2,@p3,@p4,@p5,@p6,@p7)`, + woID, maxOrder+1, body.Title, body.Description, body.Required, body.StepType, body.TypeConfig, ).Scan(&sid) if err != nil { respondError(w, http.StatusInternalServerError, err.Error()) diff --git a/internal/model/models.go b/internal/model/models.go index af6e68e..7839fee 100644 --- a/internal/model/models.go +++ b/internal/model/models.go @@ -56,6 +56,8 @@ type Step struct { Title string `db:"title" json:"title"` Description string `db:"description" json:"description"` Required bool `db:"required" json:"required"` + StepType string `db:"step_type" json:"step_type"` + TypeConfig string `db:"type_config" json:"type_config"` Completed bool `db:"completed" json:"completed"` CompletedBy *string `db:"completed_by" json:"completed_by"` CompletedAt *time.Time `db:"completed_at" json:"completed_at"` @@ -166,6 +168,8 @@ type ProfileStep struct { Title string `db:"title" json:"title"` Description string `db:"description" json:"description"` Required bool `db:"required" json:"required"` + StepType string `db:"step_type" json:"step_type"` + TypeConfig string `db:"type_config" json:"type_config"` } // ── Users & Auth ────────────────────────────────────────────────────────────── diff --git a/internal/repository/migrations/007_profiles.sql b/internal/repository/migrations/007_profiles.sql new file mode 100644 index 0000000..1d562f8 --- /dev/null +++ b/internal/repository/migrations/007_profiles.sql @@ -0,0 +1,31 @@ +-- Work Order Profiles + +IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = 'wo_profiles') +BEGIN + CREATE TABLE wo_profiles ( + id INT IDENTITY PRIMARY KEY, + name NVARCHAR(200) NOT NULL, + description NVARCHAR(MAX), + category NVARCHAR(100), + default_priority NVARCHAR(10) NOT NULL DEFAULT 'normal', + default_duration_hours INT, + default_instructions NVARCHAR(MAX), + active BIT NOT NULL DEFAULT 1, + created_at DATETIME2 NOT NULL DEFAULT GETUTCDATE(), + updated_at DATETIME2 NOT NULL DEFAULT GETUTCDATE() + ); +END + +IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = 'wo_profile_steps') +BEGIN + CREATE TABLE wo_profile_steps ( + id INT IDENTITY PRIMARY KEY, + profile_id INT NOT NULL REFERENCES wo_profiles(id) ON DELETE CASCADE, + step_order INT NOT NULL, + title NVARCHAR(200) NOT NULL, + description NVARCHAR(MAX), + required BIT NOT NULL DEFAULT 1 + ); + + CREATE INDEX ix_profile_steps ON wo_profile_steps (profile_id, step_order); +END diff --git a/internal/repository/migrations/008_step_types.sql b/internal/repository/migrations/008_step_types.sql new file mode 100644 index 0000000..fd946f3 --- /dev/null +++ b/internal/repository/migrations/008_step_types.sql @@ -0,0 +1,15 @@ +-- Add step_type and type_config to profile steps and WO steps + +IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID('wo_profile_steps') AND name = 'step_type') + ALTER TABLE wo_profile_steps ADD step_type NVARCHAR(30) NOT NULL DEFAULT 'work_step'; + -- 'work_step' | 'photo' | 'inspection' | 'note' + +IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID('wo_profile_steps') AND name = 'type_config') + ALTER TABLE wo_profile_steps ADD type_config NVARCHAR(MAX) NULL; + -- JSON; shape depends on step_type (see CLAUDE.md) + +IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID('wo_steps') AND name = 'step_type') + ALTER TABLE wo_steps ADD step_type NVARCHAR(30) NOT NULL DEFAULT 'work_step'; + +IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID('wo_steps') AND name = 'type_config') + ALTER TABLE wo_steps ADD type_config NVARCHAR(MAX) NULL; diff --git a/migrations/008_step_types.sql b/migrations/008_step_types.sql new file mode 100644 index 0000000..fd946f3 --- /dev/null +++ b/migrations/008_step_types.sql @@ -0,0 +1,15 @@ +-- Add step_type and type_config to profile steps and WO steps + +IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID('wo_profile_steps') AND name = 'step_type') + ALTER TABLE wo_profile_steps ADD step_type NVARCHAR(30) NOT NULL DEFAULT 'work_step'; + -- 'work_step' | 'photo' | 'inspection' | 'note' + +IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID('wo_profile_steps') AND name = 'type_config') + ALTER TABLE wo_profile_steps ADD type_config NVARCHAR(MAX) NULL; + -- JSON; shape depends on step_type (see CLAUDE.md) + +IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID('wo_steps') AND name = 'step_type') + ALTER TABLE wo_steps ADD step_type NVARCHAR(30) NOT NULL DEFAULT 'work_step'; + +IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID('wo_steps') AND name = 'type_config') + ALTER TABLE wo_steps ADD type_config NVARCHAR(MAX) NULL; diff --git a/web/components/registry/profile-form.mjs b/web/components/registry/profile-form.mjs index 2138a34..cf0210a 100644 --- a/web/components/registry/profile-form.mjs +++ b/web/components/registry/profile-form.mjs @@ -2,6 +2,13 @@ import { api } from '../../lib/api.mjs'; const PRIORITIES = ['low', 'normal', 'high', 'urgent']; +const STEP_TYPES = [ + { value: 'work_step', label: 'Work Step', icon: 'check-square', color: '#0A7EA4', hint: '' }, + { value: 'photo', label: 'Photo', icon: 'camera', color: '#8B5CF6', hint: 'Requires a photo to complete' }, + { value: 'inspection', label: 'Inspect', icon: 'clipboard-check', color: '#E07B39', hint: 'Pass / Fail / N/A' }, + { value: 'note', label: 'Note', icon: 'file-text', color: '#64748B', hint: 'Free-text entry' }, +]; + class ProfileForm extends HTMLElement { #onSave = null; @@ -19,7 +26,7 @@ class ProfileForm extends HTMLElement { d.innerHTML = ` ${allDone ? `
- All ${total} steps complete — ready for review! + All ${total} steps complete — ready for review!
` : ''}
@@ -133,43 +262,53 @@ class WoChecklist extends HTMLElement {
${total === 0 - ? '
No steps yet — add your first step below
' - : this.#steps.map(st => ` -
-
- -
-
-
- ${st.step_order}. - ${this.#esc(st.title)} -
- ${st.description ? `
${this.#esc(st.description)}
` : ''} - ${st.completed && st.completed_by ? ` -
- - ${this.#esc(st.completed_by)} - ${st.completed_at ? '· ' + new Date(st.completed_at).toLocaleString() : ''} -
` : ''} - ${st.notes ? `
"${this.#esc(st.notes)}"
` : ''} -
-
- ${st.completed - ? `` - : ``} -
-
`).join('')} + ? '
No steps yet — add your first step below
' + : this.#steps.map(st => { + const type = st.step_type || 'work_step'; + const cfg = this.#cfg(st); + // For note steps, the action takes flex:1 so body goes before it + const noteType = type === 'note' && !st.completed; + return ` +
+ ${noteType ? '' : this.#typeActionHTML(st)} +
+
+ ${st.step_order}. + ${this.#esc(st.title)} + ${this.#typeBadgeHTML(st)} +
+ ${st.description ? `
${this.#esc(st.description)}
` : ''} + ${type === 'inspection' && !st.completed && cfg.criteria ? `
${this.#esc(cfg.criteria)}
` : ''} + ${st.completed && st.completed_by ? ` +
+ + ${this.#esc(st.completed_by)} + ${st.completed_at ? '· ' + new Date(st.completed_at).toLocaleString() : ''} +
` : ''} + ${st.notes && type !== 'note' ? `
"${this.#esc(st.notes)}"
` : ''} +
+ ${noteType ? this.#typeActionHTML(st) : ''} +
+ ${st.completed + ? `` + : type === 'work_step' || !type + ? `` + : ``} +
+
`; + }).join('')}
- + @@ -178,6 +317,7 @@ class WoChecklist extends HTMLElement { if (window.lucide) lucide.createIcons({ root: s }); + // Work step checkboxes s.querySelectorAll('.step-check').forEach(cb => { cb.addEventListener('change', () => { if (cb.checked) this.#complete(+cb.dataset.id); @@ -185,12 +325,42 @@ class WoChecklist extends HTMLElement { }); }); + // Photo capture buttons + s.querySelectorAll('.photo-btn').forEach(btn => { + const stepId = +btn.dataset.photoId; + const fileInput = s.querySelector(`.photo-file-input[data-photo-id="${stepId}"]`); + btn.addEventListener('click', () => fileInput?.click()); + fileInput?.addEventListener('change', e => { + const file = e.target.files?.[0]; + if (!file) return; + const step = this.#steps.find(st => st.id === stepId); + if (step) this.#uploadPhoto(step, file); + }); + }); + + // Inspection buttons + s.querySelectorAll('.insp-btn').forEach(btn => { + btn.addEventListener('click', () => this.#complete(+btn.dataset.insp, btn.dataset.result)); + }); + + // Note save buttons + s.querySelectorAll('.note-save-btn').forEach(btn => { + const stepId = +btn.dataset.noteId; + btn.addEventListener('click', () => { + const textarea = s.querySelector(`.note-input[data-note-id="${stepId}"]`); + const text = textarea?.value.trim() || ''; + if (!text) { textarea?.focus(); return; } + this.#complete(stepId, text); + }); + }); + + // Delete / undo s.querySelectorAll('[data-del]').forEach(btn => btn.addEventListener('click', () => this.#deleteStep(+btn.dataset.del))); - s.querySelectorAll('[data-undo]').forEach(btn => btn.addEventListener('click', () => this.#uncomplete(+btn.dataset.undo))); + // Add step const titleInput = s.querySelector('#new-step-title'); const addBtn = s.querySelector('#add-step-btn'); addBtn.addEventListener('click', () => {