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
+175 -30
View File
@@ -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 = `
<style>
dialog { border:1px solid var(--border); border-radius:var(--radius-lg); padding:0; box-shadow:var(--shadow-lg); width:600px; max-width:96vw; max-height:90vh; background:var(--surface); color:var(--text); display:flex; flex-direction:column; }
dialog { border:1px solid var(--border); border-radius:var(--radius-lg); padding:0; box-shadow:var(--shadow-lg); width:640px; max-width:96vw; max-height:90vh; background:var(--surface); color:var(--text); display:flex; flex-direction:column; }
dialog::backdrop { background:rgba(0,0,0,.45); }
.dlg-header { display:flex; align-items:center; justify-content:space-between; padding:1.1rem 1.25rem; border-bottom:1px solid var(--border); flex-shrink:0; }
.dlg-title { font-size:1rem; font-weight:700; }
@@ -34,19 +41,41 @@ class ProfileForm extends HTMLElement {
.toggle-label { font-size:.875rem; font-weight:500; }
input[type=checkbox] { width:18px; height:18px; accent-color:var(--teal); cursor:pointer; }
.section-title { font-size:.75rem; font-weight:700; color:var(--text-muted); text-transform:uppercase; letter-spacing:.06em; padding-top:.25rem; border-top:1px solid var(--border); margin-top:.25rem; }
/* Step list */
.step-list { display:flex; flex-direction:column; gap:.4rem; }
.step-row { display:flex; align-items:center; gap:.5rem; background:var(--surface-2); border-radius:var(--radius-sm); padding:.4rem .6rem; }
.step-order { font-size:.75rem; font-weight:700; color:var(--text-muted); min-width:1.5rem; text-align:center; }
.step-title-input { flex:1; border:none; background:transparent; font-size:.875rem; color:var(--text); padding:.15rem .3rem; border-radius:4px; }
.step-row { display:flex; align-items:center; gap:.5rem; background:var(--surface-2); border-radius:var(--radius-sm); padding:.45rem .6rem; border-left:3px solid var(--border); }
.step-row[data-type="photo"] { border-left-color:#8B5CF6; }
.step-row[data-type="inspection"] { border-left-color:#E07B39; }
.step-row[data-type="note"] { border-left-color:#64748B; }
.step-row[data-type="work_step"] { border-left-color:var(--teal); }
.step-order { font-size:.75rem; font-weight:700; color:var(--text-muted); min-width:1.5rem; text-align:center; flex-shrink:0; }
.step-type-pill { font-size:.65rem; font-weight:700; text-transform:uppercase; letter-spacing:.04em; padding:.15rem .4rem; border-radius:99px; white-space:nowrap; flex-shrink:0; cursor:pointer; }
.step-title-input { flex:1; border:none; background:transparent; font-size:.875rem; color:var(--text); padding:.15rem .3rem; border-radius:4px; min-width:0; }
.step-title-input:focus { outline:1px solid var(--teal); background:var(--surface); }
.step-req { font-size:.7rem; color:var(--text-muted); white-space:nowrap; display:flex; align-items:center; gap:.25rem; }
.step-del { background:none; border:none; cursor:pointer; color:var(--text-muted); padding:.2rem; display:flex; border-radius:4px; }
.step-cfg-summary { font-size:.7rem; color:var(--text-muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:120px; }
.step-req { font-size:.7rem; color:var(--text-muted); white-space:nowrap; display:flex; align-items:center; gap:.25rem; flex-shrink:0; }
.step-del { background:none; border:none; cursor:pointer; color:var(--text-muted); padding:.2rem; display:flex; border-radius:4px; flex-shrink:0; }
.step-del:hover { color:var(--danger); background:#FEF2F2; }
/* Add step area */
.add-step-area { border:1px dashed var(--border); border-radius:var(--radius); padding:.875rem; display:flex; flex-direction:column; gap:.6rem; }
.add-step-row { display:flex; gap:.5rem; }
.add-step-input { flex:1; border:1px solid var(--border); border-radius:var(--radius-sm); padding:.45rem .75rem; font-size:.875rem; background:var(--surface); color:var(--text); }
.add-step-input:focus { outline:none; border-color:var(--teal); }
.btn-add-step { background:var(--teal); color:#fff; border:none; border-radius:var(--radius-sm); padding:.45rem .85rem; font-size:.813rem; font-weight:600; cursor:pointer; white-space:nowrap; }
/* Type picker */
.type-picker { display:flex; gap:.35rem; flex-wrap:wrap; }
.type-btn { display:flex; align-items:center; gap:.3rem; border:1px solid var(--border); border-radius:var(--radius-sm); padding:.3rem .65rem; font-size:.75rem; font-weight:600; cursor:pointer; background:var(--surface); color:var(--text-muted); transition:all .15s; }
.type-btn:hover { border-color:var(--teal); color:var(--teal); }
.type-btn.active { border-color:var(--selected-color, var(--teal)); background:color-mix(in srgb, var(--selected-color, var(--teal)) 10%, transparent); color:var(--selected-color, var(--teal)); }
.type-btn i { width:13px; height:13px; }
/* Config fields */
.cfg-fields { display:flex; flex-direction:column; gap:.45rem; padding:.5rem .6rem; background:var(--surface-2); border-radius:var(--radius-sm); }
.cfg-field-label { font-size:.75rem; font-weight:600; color:var(--text-muted); display:block; margin-bottom:.2rem; }
.cfg-input { width:100%; border:1px solid var(--border); border-radius:var(--radius-sm); padding:.4rem .6rem; font-size:.813rem; background:var(--surface); color:var(--text); box-sizing:border-box; }
.cfg-input:focus { outline:none; border-color:var(--teal); }
.cfg-select { width:100%; border:1px solid var(--border); border-radius:var(--radius-sm); padding:.4rem .6rem; font-size:.813rem; background:var(--surface); color:var(--text); }
.btn-add-step { background:var(--teal); color:#fff; border:none; border-radius:var(--radius-sm); padding:.45rem .85rem; font-size:.813rem; font-weight:600; cursor:pointer; white-space:nowrap; flex-shrink:0; }
.btn-add-step:hover { opacity:.88; }
/* Footer */
.dlg-footer { padding:.875rem 1.25rem; border-top:1px solid var(--border); display:flex; justify-content:flex-end; gap:.5rem; flex-shrink:0; }
.btn-ghost { background:transparent; border:1px solid var(--border); color:var(--text); border-radius:var(--radius); padding:.5rem 1rem; font-size:.875rem; font-weight:600; cursor:pointer; }
.btn-primary { background:var(--teal); color:#fff; border:none; border-radius:var(--radius); padding:.5rem 1rem; font-size:.875rem; font-weight:600; cursor:pointer; }
@@ -99,9 +128,21 @@ class ProfileForm extends HTMLElement {
? '<div class="empty-steps" id="empty-steps">No steps yet — add steps below</div>'
: (profile?.steps || []).map((s, i) => this.#stepRowHTML(s, i)).join('')}
</div>
<div class="add-step-row">
<input class="add-step-input" id="new-step-title" placeholder="Step title…" maxlength="200">
<button class="btn-add-step" id="add-step-btn">+ Add Step</button>
<div class="add-step-area">
<div class="add-step-row">
<input class="add-step-input" id="new-step-title" placeholder="Step title…" maxlength="200">
<button class="btn-add-step" id="add-step-btn">+ Add Step</button>
</div>
<div class="type-picker" id="type-picker">
${STEP_TYPES.map(t => `
<button class="type-btn${t.value === 'work_step' ? ' active' : ''}" data-type="${t.value}"
style="--selected-color:${t.color}" title="${t.hint}">
<i data-lucide="${t.icon}" style="width:13px;height:13px"></i>
${t.label}
</button>`).join('')}
</div>
<div id="cfg-fields" style="display:none"></div>
</div>
<div class="error-msg" id="form-error"></div>
</div>
@@ -112,19 +153,20 @@ class ProfileForm extends HTMLElement {
if (window.lucide) lucide.createIcons({ root: d });
// Local step state (not saved until the profile save)
const profileId = profile?.id || null;
let pendingSteps = (profile?.steps || []).map(s => ({ ...s }));
const profileId = profile?.id || null;
let pendingSteps = (profile?.steps || []).map(s => ({ ...s }));
let selectedType = 'work_step';
// ── Step list rendering ───────────────────────────────────────────────
const renderSteps = () => {
const list = d.querySelector('#step-list');
const empty = pendingSteps.length === 0;
list.innerHTML = empty
list.innerHTML = pendingSteps.length === 0
? '<div class="empty-steps">No steps yet — add steps below</div>'
: pendingSteps.map((s, i) => this.#stepRowHTML(s, i)).join('');
if (window.lucide) lucide.createIcons({ root: list });
list.querySelectorAll('.step-title-input').forEach((inp, i) => {
inp.addEventListener('input', () => { pendingSteps[i].title = inp.value; });
inp.addEventListener('input', () => { pendingSteps[i].title = inp.value; });
inp.addEventListener('change', () => { pendingSteps[i].title = inp.value; });
});
list.querySelectorAll('.step-req-check').forEach((cb, i) => {
@@ -136,29 +178,113 @@ class ProfileForm extends HTMLElement {
};
renderSteps();
// ── Type picker ───────────────────────────────────────────────────────
const cfgDiv = d.querySelector('#cfg-fields');
const renderCfgFields = (type) => {
if (type === 'work_step') {
cfgDiv.style.display = 'none';
cfgDiv.innerHTML = '';
return;
}
cfgDiv.style.display = 'block';
if (type === 'photo') {
cfgDiv.innerHTML = `
<div class="cfg-fields">
<div>
<label class="cfg-field-label">Phase</label>
<select class="cfg-select" id="cfg-phase">
<option value="before">Before</option>
<option value="during">During</option>
<option value="after">After</option>
</select>
</div>
<div>
<label class="cfg-field-label">Caption prompt (shown to field tech)</label>
<input class="cfg-input" id="cfg-caption" type="text" placeholder="e.g. Photo of existing equipment before work begins" maxlength="200">
</div>
</div>`;
} else if (type === 'inspection') {
cfgDiv.innerHTML = `
<div class="cfg-fields">
<div>
<label class="cfg-field-label">Criteria (what to inspect)</label>
<input class="cfg-input" id="cfg-criteria" type="text" placeholder="e.g. All cable slack coiled and secured per spec" maxlength="300">
</div>
</div>`;
} else if (type === 'note') {
cfgDiv.innerHTML = `
<div class="cfg-fields">
<div>
<label class="cfg-field-label">Prompt label</label>
<input class="cfg-input" id="cfg-prompt" type="text" placeholder="e.g. Record any site hazards or access issues" maxlength="200">
</div>
</div>`;
}
};
const getCfgJSON = (type) => {
if (type === 'photo') {
const phase = cfgDiv.querySelector('#cfg-phase')?.value || 'during';
const caption = cfgDiv.querySelector('#cfg-caption')?.value.trim() || '';
return JSON.stringify({ phase, caption_prompt: caption });
}
if (type === 'inspection') {
const criteria = cfgDiv.querySelector('#cfg-criteria')?.value.trim() || '';
return criteria ? JSON.stringify({ criteria }) : '';
}
if (type === 'note') {
const prompt = cfgDiv.querySelector('#cfg-prompt')?.value.trim() || '';
return prompt ? JSON.stringify({ prompt }) : '';
}
return '';
};
d.querySelectorAll('.type-btn').forEach(btn => {
btn.addEventListener('click', () => {
selectedType = btn.dataset.type;
d.querySelectorAll('.type-btn').forEach(b => b.classList.toggle('active', b === btn));
renderCfgFields(selectedType);
});
});
// ── Add step ──────────────────────────────────────────────────────────
d.querySelector('#add-step-btn').addEventListener('click', () => {
const inp = d.querySelector('#new-step-title');
const title = inp.value.trim();
if (!title) { inp.focus(); return; }
pendingSteps.push({ id: null, step_order: pendingSteps.length + 1, title, description: '', required: true });
pendingSteps.push({
id: null,
step_order: pendingSteps.length + 1,
title,
description: '',
required: true,
step_type: selectedType,
type_config: getCfgJSON(selectedType),
});
inp.value = '';
// Reset type picker to work_step after adding
selectedType = 'work_step';
d.querySelectorAll('.type-btn').forEach(b => b.classList.toggle('active', b.dataset.type === 'work_step'));
renderCfgFields('work_step');
renderSteps();
});
d.querySelector('#new-step-title').addEventListener('keydown', e => {
if (e.key === 'Enter') { e.preventDefault(); d.querySelector('#add-step-btn').click(); }
});
d.querySelector('#dlg-close').addEventListener('click', () => d.close());
// ── Dialog controls ───────────────────────────────────────────────────
d.querySelector('#dlg-close').addEventListener('click', () => d.close());
d.querySelector('#dlg-cancel').addEventListener('click', () => d.close());
d.querySelector('#dlg-save').addEventListener('click', async () => {
const name = d.querySelector('#p-name').value.trim();
const name = d.querySelector('#p-name').value.trim();
const errEl = d.querySelector('#form-error');
if (!name) { errEl.textContent = 'Name is required'; d.querySelector('#p-name').focus(); return; }
errEl.textContent = '';
const saveBtn = d.querySelector('#dlg-save');
saveBtn.disabled = true;
saveBtn.disabled = true;
saveBtn.textContent = 'Saving…';
const payload = {
@@ -179,20 +305,26 @@ class ProfileForm extends HTMLElement {
savedProfile = await api.post('/profiles', payload);
}
// Sync steps: delete removed, update existing, create new
const savedId = savedProfile.id;
const origIds = new Set((profile?.steps || []).map(s => s.id));
// Delete removed
// Delete removed steps
for (const orig of (profile?.steps || [])) {
if (!pendingSteps.find(s => s.id === orig.id)) {
await api.delete(`/profiles/${savedId}/steps/${orig.id}`);
}
}
// Update or create, re-assigning order
// Update existing / create new
for (let i = 0; i < pendingSteps.length; i++) {
const s = pendingSteps[i];
const stepPayload = { title: s.title, description: s.description || '', required: s.required !== false, step_order: i + 1 };
const stepPayload = {
title: s.title,
description: s.description || '',
required: s.required !== false,
step_order: i + 1,
step_type: s.step_type || 'work_step',
type_config: s.type_config || '',
};
if (s.id && origIds.has(s.id)) {
await api.put(`/profiles/${savedId}/steps/${s.id}`, stepPayload);
} else {
@@ -201,12 +333,14 @@ class ProfileForm extends HTMLElement {
}
d.close();
window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: isEdit ? 'Profile updated' : 'Profile created', type: 'success' } }));
window.dispatchEvent(new CustomEvent('wo:toast', {
detail: { message: isEdit ? 'Profile updated' : 'Profile created', type: 'success' }
}));
this.#onSave?.();
} catch (err) {
errEl.textContent = err.message;
saveBtn.disabled = false;
saveBtn.textContent = isEdit ? 'Save Changes' : 'Create Profile';
errEl.textContent = err.message;
saveBtn.disabled = false;
saveBtn.textContent = isEdit ? 'Save Changes' : 'Create Profile';
}
});
@@ -215,17 +349,28 @@ class ProfileForm extends HTMLElement {
}
#stepRowHTML(s, i) {
const type = s.step_type || 'work_step';
const tdef = { work_step: { color: '#0A7EA4', label: 'Step' }, photo: { color: '#8B5CF6', label: 'Photo' }, inspection: { color: '#E07B39', label: 'Inspect' }, note: { color: '#64748B', label: 'Note' } };
const td = tdef[type] || tdef.work_step;
const cfg = this.#parseCfg(s.type_config);
const cfgSummary = type === 'photo' ? (cfg.phase ? cfg.phase + (cfg.caption_prompt ? ' · ' + cfg.caption_prompt : '') : '')
: type === 'inspection' ? (cfg.criteria || '')
: type === 'note' ? (cfg.prompt || '')
: '';
return `
<div class="step-row">
<div class="step-row" data-type="${type}">
<span class="step-order">${i + 1}</span>
<span class="step-type-pill" style="background:color-mix(in srgb,${td.color} 15%,transparent);color:${td.color}">${td.label}</span>
<input class="step-title-input" value="${this.#esc(s.title)}" placeholder="Step title…">
${cfgSummary ? `<span class="step-cfg-summary" title="${this.#esc(cfgSummary)}">${this.#esc(cfgSummary)}</span>` : ''}
<label class="step-req">
<input type="checkbox" class="step-req-check" ${s.required !== false ? 'checked' : ''}> Required
<input type="checkbox" class="step-req-check" ${s.required !== false ? 'checked' : ''}> Req
</label>
<button class="step-del" title="Remove step"><i data-lucide="x" style="width:12px;height:12px"></i></button>
</div>`;
}
#parseCfg(str) { try { return JSON.parse(str || '{}'); } catch { return {}; } }
#esc(s) { return (s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); }
}