Add migration scripts, activity handler, and registry components for equipment, materials, and people
This commit is contained in:
@@ -60,6 +60,22 @@ class WoForm extends HTMLElement {
|
||||
.btn:disabled { opacity:.5; cursor:not-allowed; }
|
||||
.err { color:var(--danger); font-size:.813rem; text-align:right; }
|
||||
@media (max-width:768px) { .layout { grid-template-columns:1fr; } .row2 { grid-template-columns:1fr; } }
|
||||
.lp-dialog { border:1px solid var(--border); border-radius:var(--radius-lg); padding:0; box-shadow:var(--shadow-lg); width:440px; max-width:96vw; background:var(--surface); color:var(--text); }
|
||||
.lp-dialog::backdrop { background:rgba(0,0,0,.45); }
|
||||
.lp-header { display:flex; align-items:center; justify-content:space-between; padding:1rem 1.25rem; border-bottom:1px solid var(--border); }
|
||||
.lp-title { font-size:.938rem; font-weight:700; }
|
||||
.lp-close { background:none; border:none; cursor:pointer; color:var(--text-muted); padding:.25rem; display:flex; }
|
||||
.lp-body { padding:1.1rem 1.25rem; display:flex; flex-direction:column; gap:.75rem; }
|
||||
.lp-search { width:100%; border:1px solid var(--border); border-radius:var(--radius-sm); padding:.5rem .75rem; font-size:.875rem; background:var(--surface); color:var(--text); box-sizing:border-box; }
|
||||
.lp-search:focus { outline:none; border-color:var(--teal); box-shadow:0 0 0 3px rgba(10,126,164,.12); }
|
||||
.lp-list { max-height:260px; overflow-y:auto; display:flex; flex-direction:column; gap:.3rem; border:1px solid var(--border); border-radius:var(--radius-sm); padding:.35rem; }
|
||||
.lp-item { padding:.55rem .75rem; border-radius:var(--radius-sm); cursor:pointer; display:flex; flex-direction:column; gap:.15rem; transition:background .12s; border:2px solid transparent; }
|
||||
.lp-item:hover { background:var(--surface-2); }
|
||||
.lp-item.selected { border-color:var(--teal); background:rgba(10,126,164,.06); }
|
||||
.lp-item-name { font-size:.875rem; font-weight:500; color:var(--text); }
|
||||
.lp-item-meta { font-size:.75rem; color:var(--text-muted); }
|
||||
.lp-footer { padding:.875rem 1.25rem; border-top:1px solid var(--border); display:flex; justify-content:flex-end; gap:.5rem; }
|
||||
.lp-empty { text-align:center; padding:1.5rem; color:var(--text-muted); font-size:.875rem; }
|
||||
`; }
|
||||
|
||||
#html(wo, isNew) {
|
||||
@@ -74,7 +90,12 @@ class WoForm extends HTMLElement {
|
||||
<!-- Left: main fields -->
|
||||
<div style="display:flex;flex-direction:column;gap:1rem;">
|
||||
<div class="card">
|
||||
<div class="card-title">Details</div>
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding-bottom:.75rem;border-bottom:1px solid var(--border);">
|
||||
<span class="card-title" style="border:none;padding:0;margin:0">Details</span>
|
||||
${(isNew || wo.status === 'draft') ? `<button type="button" class="btn btn-ghost" id="load-profile-btn" style="padding:.3rem .7rem;font-size:.75rem;">
|
||||
<i data-lucide="layout-template" style="width:13px;height:13px"></i> Load Profile
|
||||
</button>` : ''}
|
||||
</div>
|
||||
<label>Title *<input name="title" required value="${v('title')}" placeholder="Brief description of the work"></label>
|
||||
<label>Description<textarea name="description" rows="3" placeholder="What needs to be done…">${v('description')}</textarea></label>
|
||||
<label>Instructions<textarea name="instructions" rows="5" placeholder="Step-by-step instructions for the field crew…">${v('instructions')}</textarea></label>
|
||||
@@ -135,7 +156,22 @@ class WoForm extends HTMLElement {
|
||||
${isNew ? 'Create Work Order' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</form>`;
|
||||
</form>
|
||||
|
||||
<dialog class="lp-dialog" id="lp-dialog">
|
||||
<div class="lp-header">
|
||||
<span class="lp-title">Load Work Order Profile</span>
|
||||
<button class="lp-close" id="lp-close"><i data-lucide="x" style="width:16px;height:16px"></i></button>
|
||||
</div>
|
||||
<div class="lp-body">
|
||||
<input class="lp-search" id="lp-search" placeholder="Search profiles…" autocomplete="off">
|
||||
<div class="lp-list" id="lp-list"><div class="lp-empty">Loading…</div></div>
|
||||
</div>
|
||||
<div class="lp-footer">
|
||||
<button class="btn btn-ghost" id="lp-cancel">Cancel</button>
|
||||
<button class="btn btn-primary" id="lp-confirm" disabled>Load Profile</button>
|
||||
</div>
|
||||
</dialog>`;
|
||||
}
|
||||
|
||||
#bind() {
|
||||
@@ -144,6 +180,9 @@ class WoForm extends HTMLElement {
|
||||
s.querySelector('#back')?.addEventListener('click', () => this.#goBack());
|
||||
s.querySelector('#cancel-btn')?.addEventListener('click', () => this.#goBack());
|
||||
|
||||
// Load Profile dialog
|
||||
this.#bindLoadProfile(s);
|
||||
|
||||
// Update priority dot colour on change
|
||||
s.querySelector('[name="priority"]')?.addEventListener('change', e => {
|
||||
const dot = s.querySelector('#pri-dot');
|
||||
@@ -207,6 +246,86 @@ class WoForm extends HTMLElement {
|
||||
});
|
||||
}
|
||||
|
||||
#bindLoadProfile(s) {
|
||||
const lpBtn = s.querySelector('#load-profile-btn');
|
||||
const dialog = s.querySelector('#lp-dialog');
|
||||
if (!lpBtn || !dialog) return;
|
||||
|
||||
const listEl = s.querySelector('#lp-list');
|
||||
const searchEl = s.querySelector('#lp-search');
|
||||
const confirmBtn = s.querySelector('#lp-confirm');
|
||||
let allProfiles = [];
|
||||
let selectedProfile = null;
|
||||
|
||||
const renderList = (items) => {
|
||||
if (items.length === 0) { listEl.innerHTML = '<div class="lp-empty">No profiles found</div>'; return; }
|
||||
listEl.innerHTML = items.map(p => `
|
||||
<div class="lp-item${selectedProfile?.id === p.id ? ' selected' : ''}" data-id="${p.id}">
|
||||
<div class="lp-item-name">${this.#esc(p.name)}</div>
|
||||
<div class="lp-item-meta">${p.category ? `${p.category} · ` : ''}${p.step_count} step${p.step_count !== 1 ? 's' : ''} · ${p.default_priority} priority${p.default_duration_hours ? ` · ${p.default_duration_hours}h` : ''}</div>
|
||||
</div>`).join('');
|
||||
listEl.querySelectorAll('.lp-item').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
selectedProfile = allProfiles.find(p => p.id === +item.dataset.id);
|
||||
listEl.querySelectorAll('.lp-item').forEach(i => i.classList.toggle('selected', +i.dataset.id === selectedProfile?.id));
|
||||
confirmBtn.disabled = !selectedProfile;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
lpBtn.addEventListener('click', async () => {
|
||||
selectedProfile = null;
|
||||
confirmBtn.disabled = true;
|
||||
searchEl.value = '';
|
||||
listEl.innerHTML = '<div class="lp-empty">Loading…</div>';
|
||||
dialog.showModal();
|
||||
try {
|
||||
allProfiles = await api.get('/profiles') || [];
|
||||
renderList(allProfiles);
|
||||
} catch (err) {
|
||||
listEl.innerHTML = `<div class="lp-empty">${err.message}</div>`;
|
||||
}
|
||||
});
|
||||
|
||||
searchEl.addEventListener('input', () => {
|
||||
const q = searchEl.value.toLowerCase();
|
||||
renderList(q ? allProfiles.filter(p => p.name.toLowerCase().includes(q) || (p.category || '').toLowerCase().includes(q)) : allProfiles);
|
||||
});
|
||||
|
||||
s.querySelector('#lp-close').addEventListener('click', () => dialog.close());
|
||||
s.querySelector('#lp-cancel').addEventListener('click', () => dialog.close());
|
||||
|
||||
confirmBtn.addEventListener('click', async () => {
|
||||
if (!selectedProfile) return;
|
||||
// Fetch full profile to get instructions
|
||||
let full = selectedProfile;
|
||||
try { full = await api.get(`/profiles/${selectedProfile.id}`); } catch { /* use list data */ }
|
||||
|
||||
dialog.close();
|
||||
|
||||
// Pre-fill form fields (only if currently empty)
|
||||
const priEl = s.querySelector('[name="priority"]');
|
||||
const instrEl = s.querySelector('[name="instructions"]');
|
||||
|
||||
if (priEl) { priEl.value = full.default_priority || 'normal'; priEl.dispatchEvent(new Event('change')); }
|
||||
if (instrEl && !instrEl.value.trim() && full.default_instructions) {
|
||||
instrEl.value = full.default_instructions;
|
||||
}
|
||||
// Compute scheduled_end from duration if start is filled and end is empty
|
||||
const startEl = s.querySelector('[name="scheduled_start"]');
|
||||
const endEl = s.querySelector('[name="scheduled_end"]');
|
||||
if (full.default_duration_hours && startEl?.value && !endEl?.value) {
|
||||
const startMs = new Date(startEl.value).getTime();
|
||||
const endDate = new Date(startMs + full.default_duration_hours * 3600000);
|
||||
// Format back to datetime-local value (YYYY-MM-DDTHH:MM)
|
||||
endEl.value = endDate.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
this.#dirty = true;
|
||||
window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: `Profile "${full.name}" loaded`, type: 'success' } }));
|
||||
});
|
||||
}
|
||||
|
||||
#goBack() {
|
||||
if (this.#dirty && !confirm('You have unsaved changes. Leave anyway?')) return;
|
||||
if (this.#woId) {
|
||||
|
||||
Reference in New Issue
Block a user