Add step type support for work order profiles and steps, update database schema, APIs, and UI components to handle configurable step types.

This commit is contained in:
2026-05-17 19:21:11 -04:00
parent 309f19520b
commit 6307babbfa
8 changed files with 481 additions and 89 deletions
+218 -48
View File
@@ -1,4 +1,12 @@
import { api } from '../../lib/api.mjs';
import { api } from '../../lib/api.mjs';
// Step type constants
const STEP_TYPES = {
work_step: { label: 'Step', icon: 'check-square', color: 'var(--teal)' },
photo: { label: 'Photo', icon: 'camera', color: '#8B5CF6' },
inspection: { label: 'Inspect', icon: 'clipboard-check', color: '#E07B39' },
note: { label: 'Note', icon: 'file-text', color: '#64748B' },
};
class WoChecklist extends HTMLElement {
#woId = null;
@@ -26,9 +34,9 @@ class WoChecklist extends HTMLElement {
this.#render();
}
async #complete(stepId) {
async #complete(stepId, notes = '') {
try {
await api.post(`/work-orders/${this.#woId}/steps/${stepId}/complete`, {});
await api.post(`/work-orders/${this.#woId}/steps/${stepId}/complete`, { notes });
await this.#load();
this.dispatchEvent(new CustomEvent('steps:updated', { bubbles: true, composed: true }));
} catch (err) {
@@ -48,7 +56,7 @@ class WoChecklist extends HTMLElement {
async #addStep(title) {
try {
await api.post(`/work-orders/${this.#woId}/steps`, { title, description: '', required: true });
await api.post(`/work-orders/${this.#woId}/steps`, { title, description: '', required: true, step_type: 'work_step' });
await this.#load();
} catch (err) {
window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: err.message, type: 'error' } }));
@@ -64,6 +72,95 @@ class WoChecklist extends HTMLElement {
}
}
async #uploadPhoto(step, file) {
const cfg = this.#cfg(step);
const form = new FormData();
form.append('file', file);
form.append('phase', cfg.phase || 'during');
form.append('caption', cfg.caption_prompt || step.title);
form.append('step_id', String(step.id));
try {
await api.upload(`/work-orders/${this.#woId}/attachments`, form);
await this.#complete(step.id, 'Photo captured');
} catch (err) {
window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: err.message, type: 'error' } }));
}
}
#cfg(step) {
try { return JSON.parse(step.type_config || '{}'); } catch { return {}; }
}
// ── Per-type action HTML ───────────────────────────────────────────────────
#workStepHTML(st) {
return `
<div class="step-check-wrap">
<input type="checkbox" class="step-check" data-id="${st.id}" ${st.completed ? 'checked' : ''}>
</div>`;
}
#photoHTML(st) {
const cfg = this.#cfg(st);
const prompt = cfg.caption_prompt || 'Capture photo';
const phase = cfg.phase ? `<span class="phase-badge phase-${cfg.phase}">${cfg.phase}</span>` : '';
if (st.completed) {
return `<div class="type-icon photo-done"><i data-lucide="image" style="width:18px;height:18px"></i></div>`;
}
return `
<div class="photo-action">
<button class="photo-btn" data-photo-id="${st.id}" title="${this.#esc(prompt)}">
<i data-lucide="camera" style="width:16px;height:16px"></i>
</button>
${phase}
<input type="file" accept="image/*" capture="environment" class="photo-file-input" data-photo-id="${st.id}" style="display:none">
</div>`;
}
#inspectionHTML(st) {
if (st.completed) {
const result = st.notes || '';
const cls = result === 'PASS' ? 'insp-pass' : result === 'FAIL' ? 'insp-fail' : 'insp-na';
return `<div class="insp-result ${cls}">${result || 'Done'}</div>`;
}
return `
<div class="insp-btns">
<button class="insp-btn pass" data-insp="${st.id}" data-result="PASS">Pass</button>
<button class="insp-btn fail" data-insp="${st.id}" data-result="FAIL">Fail</button>
<button class="insp-btn na" data-insp="${st.id}" data-result="N/A">N/A</button>
</div>`;
}
#noteHTML(st) {
const cfg = this.#cfg(st);
const prompt = cfg.prompt || 'Add note…';
if (st.completed) {
return `<div class="type-icon note-done"><i data-lucide="check" style="width:16px;height:16px"></i></div>`;
}
return `
<div class="note-action">
<textarea class="note-input" data-note-id="${st.id}" placeholder="${this.#esc(prompt)}" rows="2">${this.#esc(st.notes || '')}</textarea>
<button class="note-save-btn" data-note-id="${st.id}">Save</button>
</div>`;
}
#typeActionHTML(st) {
switch (st.step_type || 'work_step') {
case 'photo': return this.#photoHTML(st);
case 'inspection': return this.#inspectionHTML(st);
case 'note': return this.#noteHTML(st);
default: return this.#workStepHTML(st);
}
}
#typeBadgeHTML(st) {
const t = STEP_TYPES[st.step_type] || STEP_TYPES.work_step;
if (st.step_type === 'work_step' || !st.step_type) return '';
return `<span class="type-badge" style="--badge-color:${t.color}">${t.label}</span>`;
}
// ── Render ─────────────────────────────────────────────────────────────────
#render() {
const s = this.shadowRoot;
if (this.#loading) {
@@ -89,21 +186,55 @@ class WoChecklist extends HTMLElement {
.step-row:hover { background: var(--surface-2); }
.step-row.done { background: var(--surface-2); border-color: var(--border-lt); }
.step-row.done .step-title { text-decoration: line-through; color: var(--text-muted); }
.step-check-wrap { display: flex; align-items: center; justify-content: center; min-width: 24px; height: 24px; margin-top: .1rem; }
.step-row.type-photo { border-left: 3px solid #8B5CF6; }
.step-row.type-inspection { border-left: 3px solid #E07B39; }
.step-row.type-note { border-left: 3px solid #64748B; }
/* Work step checkbox */
.step-check-wrap { display: flex; align-items: center; justify-content: center; min-width: 24px; height: 24px; margin-top: .1rem; flex-shrink: 0; }
.step-check { width: 20px; height: 20px; accent-color: var(--teal); cursor: pointer; }
.step-check:disabled { cursor: not-allowed; opacity: .6; }
/* Photo step */
.photo-action { display: flex; align-items: center; gap: .4rem; flex-shrink: 0; }
.photo-btn { background: #8B5CF6; color: #fff; border: none; border-radius: var(--radius-sm); padding: .35rem .6rem; cursor: pointer; display: flex; align-items: center; gap: .3rem; font-size: .75rem; font-weight: 600; transition: opacity .15s; }
.photo-btn:hover { opacity: .85; }
.photo-done { color: #8B5CF6; display: flex; align-items: center; min-width: 24px; flex-shrink: 0; }
.phase-badge { font-size: .65rem; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; padding: .15rem .4rem; border-radius: 99px; flex-shrink: 0; }
.phase-before { background: #FEF3C7; color: #92400E; }
.phase-during { background: #DBEAFE; color: #1E40AF; }
.phase-after { background: #DCFCE7; color: #15803D; }
/* Inspection step */
.insp-btns { display: flex; gap: .3rem; flex-shrink: 0; }
.insp-btn { border: none; border-radius: var(--radius-sm); padding: .3rem .65rem; font-size: .75rem; font-weight: 700; cursor: pointer; transition: opacity .15s; }
.insp-btn:hover { opacity: .8; }
.insp-btn.pass { background: #DCFCE7; color: #15803D; }
.insp-btn.fail { background: #FEE2E2; color: #991B1B; }
.insp-btn.na { background: var(--surface-2); color: var(--text-muted); }
.insp-result { font-size: .75rem; font-weight: 700; padding: .3rem .65rem; border-radius: var(--radius-sm); flex-shrink: 0; }
.insp-pass { background: #DCFCE7; color: #15803D; }
.insp-fail { background: #FEE2E2; color: #991B1B; }
.insp-na { background: var(--surface-2); color: var(--text-muted); }
/* Note step */
.note-action { display: flex; flex-direction: column; gap: .35rem; flex: 1; min-width: 0; }
.note-input { border: 1px solid var(--border); border-radius: var(--radius-sm); padding: .45rem .6rem; font-size: .813rem; background: var(--surface); color: var(--text); resize: vertical; font-family: inherit; width: 100%; box-sizing: border-box; }
.note-input:focus { outline: none; border-color: var(--teal); }
.note-save-btn { align-self: flex-end; background: var(--teal); color: #fff; border: none; border-radius: var(--radius-sm); padding: .3rem .85rem; font-size: .813rem; font-weight: 600; cursor: pointer; transition: opacity .15s; }
.note-save-btn:hover { opacity: .88; }
.note-done { color: var(--teal); display: flex; align-items: center; min-width: 24px; flex-shrink: 0; }
/* Step body */
.step-body { flex: 1; min-width: 0; }
.step-header { display: flex; align-items: baseline; gap: .4rem; }
.step-num { font-size: .75rem; color: var(--text-muted); font-weight: 700; min-width: 1.5rem; }
.step-header { display: flex; align-items: baseline; gap: .4rem; flex-wrap: wrap; }
.step-num { font-size: .75rem; color: var(--text-muted); font-weight: 700; min-width: 1.5rem; }
.step-title { font-weight: 500; font-size: .875rem; color: var(--text); line-height: 1.4; }
.step-desc { font-size: .813rem; color: var(--text-muted); margin-top: .2rem; line-height: 1.5; }
.step-meta { font-size: .75rem; color: var(--text-muted); margin-top: .3rem; display: flex; align-items: center; gap: .3rem; }
.step-note { font-style: italic; margin-top: .2rem; font-size: .75rem; color: var(--text-muted); }
.step-desc { font-size: .813rem; color: var(--text-muted); margin-top: .2rem; line-height: 1.5; }
.step-meta { font-size: .75rem; color: var(--text-muted); margin-top: .3rem; display: flex; align-items: center; gap: .3rem; }
.step-note { font-style: italic; margin-top: .2rem; font-size: .75rem; color: var(--text-muted); }
.type-badge { font-size: .65rem; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; padding: .15rem .4rem; border-radius: 99px; background: color-mix(in srgb, var(--badge-color) 15%, transparent); color: var(--badge-color); flex-shrink: 0; }
/* Step actions */
.step-actions { display: flex; gap: .25rem; align-items: center; flex-shrink: 0; }
.icon-btn { background: none; border: none; cursor: pointer; color: var(--text-muted); padding: .3rem; border-radius: var(--radius-sm); display: flex; align-items: center; transition: color .15s, background .15s; opacity: 0; }
.step-row:hover .icon-btn { opacity: 1; }
.icon-btn:hover { color: var(--danger); background: #FEF2F2; }
.icon-btn.undo-btn:hover { color: var(--teal); background: var(--surface-2); }
/* Add section */
.empty-steps { text-align: center; padding: 2rem; color: var(--text-muted); font-size: .875rem; }
.add-section { border-top: 1px solid var(--border); padding-top: 1.25rem; }
.add-section-label { font-size: .75rem; font-weight: 700; color: var(--text-muted); text-transform: uppercase; letter-spacing: .06em; margin-bottom: .75rem; }
@@ -112,15 +243,13 @@ class WoChecklist extends HTMLElement {
.add-input:focus { outline: none; border-color: var(--teal); box-shadow: 0 0 0 3px rgba(10,126,164,.12); }
.add-btn { background: var(--teal); color: #fff; border: none; border-radius: var(--radius); padding: .55rem 1.1rem; font-weight: 600; font-size: .875rem; cursor: pointer; white-space: nowrap; transition: opacity .15s; }
.add-btn:hover { opacity: .88; }
@media (max-width: 768px) {
.icon-btn { opacity: 1; }
}
@media (max-width: 768px) { .icon-btn { opacity: 1; } }
</style>
${allDone ? `
<div class="all-done">
<i data-lucide="check-circle-2" style="width:18px;height:18px;flex-shrink:0"></i>
All ${total} steps complete — ready for review!
All ${total} steps complete ready for review!
</div>` : ''}
<div class="progress-wrap">
@@ -133,43 +262,53 @@ class WoChecklist extends HTMLElement {
<div class="steps-list">
${total === 0
? '<div class="empty-steps">No steps yet — add your first step below</div>'
: this.#steps.map(st => `
<div class="step-row${st.completed ? ' done' : ''}">
<div class="step-check-wrap">
<input type="checkbox" class="step-check" data-id="${st.id}"
${st.completed ? 'checked' : ''}>
</div>
<div class="step-body">
<div class="step-header">
<span class="step-num">${st.step_order}.</span>
<span class="step-title">${this.#esc(st.title)}</span>
</div>
${st.description ? `<div class="step-desc">${this.#esc(st.description)}</div>` : ''}
${st.completed && st.completed_by ? `
<div class="step-meta">
<i data-lucide="user-check" style="width:12px;height:12px"></i>
${this.#esc(st.completed_by)}
${st.completed_at ? '· ' + new Date(st.completed_at).toLocaleString() : ''}
</div>` : ''}
${st.notes ? `<div class="step-note">"${this.#esc(st.notes)}"</div>` : ''}
</div>
<div class="step-actions">
${st.completed
? `<button class="icon-btn undo-btn" data-undo="${st.id}" title="Mark incomplete">
<i data-lucide="rotate-ccw" style="width:14px;height:14px"></i>
</button>`
: `<button class="icon-btn" data-del="${st.id}" title="Remove step">
<i data-lucide="trash-2" style="width:14px;height:14px"></i>
</button>`}
</div>
</div>`).join('')}
? '<div class="empty-steps">No steps yet add your first step below</div>'
: 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 `
<div class="step-row${st.completed ? ' done' : ''} type-${type}">
${noteType ? '' : this.#typeActionHTML(st)}
<div class="step-body" style="${noteType ? 'flex:0 0 auto;max-width:45%' : ''}">
<div class="step-header">
<span class="step-num">${st.step_order}.</span>
<span class="step-title">${this.#esc(st.title)}</span>
${this.#typeBadgeHTML(st)}
</div>
${st.description ? `<div class="step-desc">${this.#esc(st.description)}</div>` : ''}
${type === 'inspection' && !st.completed && cfg.criteria ? `<div class="step-desc">${this.#esc(cfg.criteria)}</div>` : ''}
${st.completed && st.completed_by ? `
<div class="step-meta">
<i data-lucide="user-check" style="width:12px;height:12px"></i>
${this.#esc(st.completed_by)}
${st.completed_at ? '· ' + new Date(st.completed_at).toLocaleString() : ''}
</div>` : ''}
${st.notes && type !== 'note' ? `<div class="step-note">"${this.#esc(st.notes)}"</div>` : ''}
</div>
${noteType ? this.#typeActionHTML(st) : ''}
<div class="step-actions">
${st.completed
? `<button class="icon-btn undo-btn" data-undo="${st.id}" title="Mark incomplete">
<i data-lucide="rotate-ccw" style="width:14px;height:14px"></i>
</button>`
: type === 'work_step' || !type
? `<button class="icon-btn" data-del="${st.id}" title="Remove step">
<i data-lucide="trash-2" style="width:14px;height:14px"></i>
</button>`
: `<button class="icon-btn" data-del="${st.id}" title="Remove step">
<i data-lucide="trash-2" style="width:14px;height:14px"></i>
</button>`}
</div>
</div>`;
}).join('')}
</div>
<div class="add-section">
<div class="add-section-label">Add Step</div>
<div class="add-row">
<input class="add-input" id="new-step-title" placeholder="Step title…" maxlength="200">
<input class="add-input" id="new-step-title" placeholder="Step title" maxlength="200">
<button class="add-btn" id="add-step-btn">
<i data-lucide="plus" style="width:14px;height:14px;vertical-align:middle"></i> Add
</button>
@@ -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', () => {