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
+115
View File
@@ -0,0 +1,115 @@
import { api } from '../../lib/api.mjs';
const CATEGORIES = ['Fusion Splicer', 'OTDR', 'Blower', 'Reel', 'Generator', 'Power Meter', 'Cable Locator', 'Other'];
class EquipmentForm extends HTMLElement {
#onSave = null;
connectedCallback() {
if (!this.shadowRoot) {
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `<style>:host{display:block}</style><dialog></dialog>`;
}
}
open(equipment, onSave) {
this.#onSave = onSave;
const isEdit = !!equipment;
const d = this.shadowRoot.querySelector('dialog');
d.innerHTML = `
<style>
dialog { border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 0; box-shadow: var(--shadow-lg); width: 440px; max-width: 95vw; background: var(--surface); color: var(--text); }
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); }
.dlg-title { font-size: 1rem; font-weight: 700; }
.dlg-close { background: none; border: none; cursor: pointer; color: var(--text-muted); padding: .25rem; display: flex; }
form { padding: 1.25rem; display: flex; flex-direction: column; gap: .875rem; }
.field-label { font-size: .813rem; font-weight: 600; color: var(--text); display: block; margin-bottom: .3rem; }
.field-input, .field-select { width: 100%; border: 1px solid var(--border); border-radius: var(--radius-sm); padding: .55rem .75rem; font-size: .875rem; background: var(--surface); color: var(--text); box-sizing: border-box; transition: border-color .15s; }
.field-input:focus, .field-select:focus { outline: none; border-color: var(--teal); box-shadow: 0 0 0 3px rgba(10,126,164,.12); }
.row-2 { display: grid; grid-template-columns: 1fr 1fr; gap: .75rem; }
.toggle-row { display: flex; align-items: center; justify-content: space-between; padding: .5rem 0; }
.toggle-label { font-size: .875rem; font-weight: 500; }
input[type=checkbox] { width: 18px; height: 18px; accent-color: var(--teal); cursor: pointer; }
.dlg-footer { padding: .875rem 1.25rem; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; gap: .5rem; }
.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; }
.btn-primary:disabled { opacity: .5; cursor: not-allowed; }
.error-msg { color: var(--danger); font-size: .813rem; min-height: 1.2em; }
</style>
<div class="dlg-header">
<span class="dlg-title">${isEdit ? 'Edit Equipment' : 'Add Equipment'}</span>
<button class="dlg-close" id="dlg-close"><i data-lucide="x" style="width:16px;height:16px"></i></button>
</div>
<form id="eq-form" novalidate>
<div>
<label class="field-label" for="eq-name">Name *</label>
<input class="field-input" id="eq-name" type="text" value="${this.#esc(equipment?.name || '')}" placeholder="e.g. Fujikura CT-50 Splicer" required maxlength="200">
</div>
<div class="row-2">
<div>
<label class="field-label" for="eq-tag">Asset Tag</label>
<input class="field-input" id="eq-tag" type="text" value="${this.#esc(equipment?.asset_tag || '')}" placeholder="e.g. EQ-1042" maxlength="100">
</div>
<div>
<label class="field-label" for="eq-cat">Category</label>
<select class="field-select" id="eq-cat">
<option value="">— Select —</option>
${CATEGORIES.map(c => `<option value="${c}" ${equipment?.category === c ? 'selected' : ''}>${c}</option>`).join('')}
</select>
</div>
</div>
${isEdit ? `
<div class="toggle-row">
<span class="toggle-label">Active</span>
<input type="checkbox" id="eq-active" ${equipment.active ? 'checked' : ''}>
</div>` : ''}
<div class="error-msg" id="form-error"></div>
</form>
<div class="dlg-footer">
<button class="btn-ghost" id="dlg-cancel">Cancel</button>
<button class="btn-primary" id="dlg-save">${isEdit ? 'Save Changes' : 'Add Equipment'}</button>
</div>`;
if (window.lucide) lucide.createIcons({ nodes: [d] });
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('#eq-name').value.trim();
const errEl = d.querySelector('#form-error');
if (!name) { errEl.textContent = 'Name is required'; d.querySelector('#eq-name').focus(); return; }
errEl.textContent = '';
const saveBtn = d.querySelector('#dlg-save');
saveBtn.disabled = true;
saveBtn.textContent = 'Saving…';
const payload = {
name,
asset_tag: d.querySelector('#eq-tag').value.trim(),
category: d.querySelector('#eq-cat').value,
active: isEdit ? d.querySelector('#eq-active').checked : true,
};
try {
if (isEdit) await api.put(`/registry/equipment/${equipment.id}`, payload);
else await api.post('/registry/equipment', payload);
d.close();
window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: isEdit ? 'Equipment updated' : 'Equipment added', type: 'success' } }));
this.#onSave?.();
} catch (err) {
errEl.textContent = err.message;
saveBtn.disabled = false;
saveBtn.textContent = isEdit ? 'Save Changes' : 'Add Equipment';
}
});
d.showModal();
d.querySelector('#eq-name').focus();
}
#esc(s) { return (s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
}
customElements.define('equipment-form', EquipmentForm);
+129
View File
@@ -0,0 +1,129 @@
import { api } from '../../lib/api.mjs';
import './equipment-form.mjs';
class EquipmentList extends HTMLElement {
#items = [];
#loading = true;
#search = '';
connectedCallback() {
if (!this.shadowRoot) this.attachShadow({ mode: 'open' });
this.#load();
}
async #load() {
this.#loading = true;
this.#render();
try {
this.#items = await api.get(`/registry/equipment?all=1${this.#search ? '&search=' + encodeURIComponent(this.#search) : ''}`) || [];
} catch { this.#items = []; }
this.#loading = false;
this.#render();
}
async #deactivate(id) {
try {
await api.delete(`/registry/equipment/${id}`);
window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: 'Equipment deactivated', type: 'success' } }));
await this.#load();
} catch (err) {
window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: err.message, type: 'error' } }));
}
}
#render() {
const s = this.shadowRoot;
s.innerHTML = `
<style>
:host { display: block; }
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.25rem; flex-wrap: wrap; gap: .75rem; }
h1 { font-size: 1.2rem; font-weight: 700; color: var(--text); }
.toolbar { display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; }
.search-input { border: 1px solid var(--border); border-radius: var(--radius-sm); padding: .45rem .75rem; font-size: .875rem; background: var(--surface); color: var(--text); width: 200px; }
.search-input:focus { outline: none; border-color: var(--teal); box-shadow: 0 0 0 3px rgba(10,126,164,.12); }
.btn-primary { display: inline-flex; align-items: center; gap: .35rem; background: var(--teal); color: #fff; border: none; border-radius: var(--radius); padding: .5rem 1rem; font-size: .875rem; font-weight: 600; cursor: pointer; }
.btn-primary:hover { opacity: .88; }
.list { display: flex; flex-direction: column; gap: .5rem; }
.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: .875rem 1rem; display: flex; align-items: center; gap: 1rem; box-shadow: var(--shadow-sm); }
.card:hover { box-shadow: var(--shadow-md); }
.eq-icon { width: 40px; height: 40px; border-radius: var(--radius); background: var(--surface-2); display: flex; align-items: center; justify-content: center; color: var(--warning); flex-shrink: 0; }
.info { flex: 1; min-width: 0; }
.eq-name { font-weight: 600; color: var(--text); font-size: .938rem; }
.meta { font-size: .813rem; color: var(--text-muted); margin-top: .15rem; display: flex; gap: .75rem; flex-wrap: wrap; }
.asset-tag { font-family: monospace; font-size: .813rem; background: var(--surface-2); padding: .1rem .4rem; border-radius: var(--radius-sm); }
.status-badge { display: inline-flex; padding: .15rem .55rem; border-radius: 999px; font-size: .7rem; font-weight: 700; text-transform: uppercase; }
.status-active { background: #DCFCE7; color: #15803D; }
.status-inactive { background: var(--surface-2); color: var(--text-muted); }
.actions { display: flex; gap: .35rem; flex-shrink: 0; }
.icon-btn { background: none; border: 1px solid var(--border); border-radius: var(--radius-sm); padding: .35rem .5rem; cursor: pointer; color: var(--text-muted); display: flex; align-items: center; transition: background .15s; }
.icon-btn:hover { background: var(--surface-2); color: var(--text); }
.icon-btn.danger:hover { background: #FEF2F2; color: var(--danger); border-color: var(--danger); }
.empty { text-align: center; padding: 3rem; color: var(--text-muted); }
</style>
<div class="page-header">
<h1>Equipment</h1>
<div class="toolbar">
<input class="search-input" id="search" placeholder="Search equipment…" value="${this.#esc(this.#search)}">
<button class="btn-primary" id="new-btn">
<i data-lucide="plus" style="width:14px;height:14px"></i> Add Equipment
</button>
</div>
</div>
${this.#loading ? '<ui-spinner></ui-spinner>' :
this.#items.length === 0
? `<div class="empty">
<i data-lucide="wrench" style="width:32px;height:32px;opacity:.3;display:block;margin:0 auto .5rem"></i>
No equipment found
</div>`
: `<div class="list">
${this.#items.map(eq => `
<div class="card">
<div class="eq-icon"><i data-lucide="wrench" style="width:18px;height:18px"></i></div>
<div class="info">
<div class="eq-name">${this.#esc(eq.name)}</div>
<div class="meta">
${eq.asset_tag ? `<span class="asset-tag">${this.#esc(eq.asset_tag)}</span>` : ''}
${eq.category ? `<span>${this.#esc(eq.category)}</span>` : ''}
</div>
</div>
<span class="status-badge ${eq.active ? 'status-active' : 'status-inactive'}">${eq.active ? 'Active' : 'Inactive'}</span>
<div class="actions">
<button class="icon-btn" data-edit="${eq.id}" title="Edit">
<i data-lucide="pencil" style="width:14px;height:14px"></i>
</button>
${eq.active ? `<button class="icon-btn danger" data-deactivate="${eq.id}" title="Deactivate">
<i data-lucide="ban" style="width:14px;height:14px"></i>
</button>` : ''}
</div>
</div>`).join('')}
</div>`}
<equipment-form id="equipment-form"></equipment-form>`;
if (window.lucide) lucide.createIcons({ nodes: [s] });
const searchEl = s.querySelector('#search');
let debounce;
searchEl.addEventListener('input', () => {
clearTimeout(debounce);
debounce = setTimeout(() => { this.#search = searchEl.value; this.#load(); }, 350);
});
s.querySelector('#new-btn').addEventListener('click', () =>
s.querySelector('#equipment-form').open(null, () => this.#load()));
s.querySelectorAll('[data-edit]').forEach(btn => {
const eq = this.#items.find(x => x.id === +btn.dataset.edit);
btn.addEventListener('click', () => s.querySelector('#equipment-form').open(eq, () => this.#load()));
});
s.querySelectorAll('[data-deactivate]').forEach(btn =>
btn.addEventListener('click', () => this.#deactivate(+btn.dataset.deactivate)));
}
#esc(s) { return (s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
}
customElements.define('equipment-list', EquipmentList);
+115
View File
@@ -0,0 +1,115 @@
import { api } from '../../lib/api.mjs';
const UNITS = ['ft', 'ea', 'box', 'roll', 'pair', 'bag', 'lb', 'gal', 'pkg'];
class MaterialForm extends HTMLElement {
#onSave = null;
connectedCallback() {
if (!this.shadowRoot) {
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `<style>:host{display:block}</style><dialog></dialog>`;
}
}
open(material, onSave) {
this.#onSave = onSave;
const isEdit = !!material;
const d = this.shadowRoot.querySelector('dialog');
d.innerHTML = `
<style>
dialog { border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 0; box-shadow: var(--shadow-lg); width: 440px; max-width: 95vw; background: var(--surface); color: var(--text); }
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); }
.dlg-title { font-size: 1rem; font-weight: 700; }
.dlg-close { background: none; border: none; cursor: pointer; color: var(--text-muted); padding: .25rem; display: flex; }
form { padding: 1.25rem; display: flex; flex-direction: column; gap: .875rem; }
.field-label { font-size: .813rem; font-weight: 600; color: var(--text); display: block; margin-bottom: .3rem; }
.field-input, .field-select { width: 100%; border: 1px solid var(--border); border-radius: var(--radius-sm); padding: .55rem .75rem; font-size: .875rem; background: var(--surface); color: var(--text); box-sizing: border-box; transition: border-color .15s; }
.field-input:focus, .field-select:focus { outline: none; border-color: var(--teal); box-shadow: 0 0 0 3px rgba(10,126,164,.12); }
.row-2 { display: grid; grid-template-columns: 1fr 1fr; gap: .75rem; }
.toggle-row { display: flex; align-items: center; justify-content: space-between; padding: .5rem 0; }
.toggle-label { font-size: .875rem; font-weight: 500; }
input[type=checkbox] { width: 18px; height: 18px; accent-color: var(--teal); cursor: pointer; }
.dlg-footer { padding: .875rem 1.25rem; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; gap: .5rem; }
.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; }
.btn-primary:disabled { opacity: .5; cursor: not-allowed; }
.error-msg { color: var(--danger); font-size: .813rem; min-height: 1.2em; }
</style>
<div class="dlg-header">
<span class="dlg-title">${isEdit ? 'Edit Material' : 'Add Material'}</span>
<button class="dlg-close" id="dlg-close"><i data-lucide="x" style="width:16px;height:16px"></i></button>
</div>
<form id="mat-form" novalidate>
<div>
<label class="field-label" for="m-name">Name *</label>
<input class="field-input" id="m-name" type="text" value="${this.#esc(material?.name || '')}" placeholder="e.g. Single Mode Fiber Cable" required maxlength="200">
</div>
<div class="row-2">
<div>
<label class="field-label" for="m-unit">Unit of Measure</label>
<select class="field-select" id="m-unit">
<option value="">— Select —</option>
${UNITS.map(u => `<option value="${u}" ${material?.unit === u ? 'selected' : ''}>${u}</option>`).join('')}
</select>
</div>
<div>
<label class="field-label" for="m-part">Part Number</label>
<input class="field-input" id="m-part" type="text" value="${this.#esc(material?.part_number || '')}" placeholder="e.g. SMF-09-500" maxlength="100">
</div>
</div>
${isEdit ? `
<div class="toggle-row">
<span class="toggle-label">Active</span>
<input type="checkbox" id="m-active" ${material.active ? 'checked' : ''}>
</div>` : ''}
<div class="error-msg" id="form-error"></div>
</form>
<div class="dlg-footer">
<button class="btn-ghost" id="dlg-cancel">Cancel</button>
<button class="btn-primary" id="dlg-save">${isEdit ? 'Save Changes' : 'Add Material'}</button>
</div>`;
if (window.lucide) lucide.createIcons({ nodes: [d] });
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('#m-name').value.trim();
const errEl = d.querySelector('#form-error');
if (!name) { errEl.textContent = 'Name is required'; d.querySelector('#m-name').focus(); return; }
errEl.textContent = '';
const saveBtn = d.querySelector('#dlg-save');
saveBtn.disabled = true;
saveBtn.textContent = 'Saving…';
const payload = {
name,
unit: d.querySelector('#m-unit').value,
part_number: d.querySelector('#m-part').value.trim(),
active: isEdit ? d.querySelector('#m-active').checked : true,
};
try {
if (isEdit) await api.put(`/registry/materials/${material.id}`, payload);
else await api.post('/registry/materials', payload);
d.close();
window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: isEdit ? 'Material updated' : 'Material added', type: 'success' } }));
this.#onSave?.();
} catch (err) {
errEl.textContent = err.message;
saveBtn.disabled = false;
saveBtn.textContent = isEdit ? 'Save Changes' : 'Add Material';
}
});
d.showModal();
d.querySelector('#m-name').focus();
}
#esc(s) { return (s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
}
customElements.define('material-form', MaterialForm);
+130
View File
@@ -0,0 +1,130 @@
import { api } from '../../lib/api.mjs';
import './material-form.mjs';
class MaterialList extends HTMLElement {
#items = [];
#loading = true;
#search = '';
connectedCallback() {
if (!this.shadowRoot) this.attachShadow({ mode: 'open' });
this.#load();
}
async #load() {
this.#loading = true;
this.#render();
try {
this.#items = await api.get(`/registry/materials?all=1${this.#search ? '&search=' + encodeURIComponent(this.#search) : ''}`) || [];
} catch { this.#items = []; }
this.#loading = false;
this.#render();
}
async #deactivate(id) {
try {
await api.delete(`/registry/materials/${id}`);
window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: 'Material deactivated', type: 'success' } }));
await this.#load();
} catch (err) {
window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: err.message, type: 'error' } }));
}
}
#render() {
const s = this.shadowRoot;
s.innerHTML = `
<style>
:host { display: block; }
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.25rem; flex-wrap: wrap; gap: .75rem; }
h1 { font-size: 1.2rem; font-weight: 700; color: var(--text); }
.toolbar { display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; }
.search-input { border: 1px solid var(--border); border-radius: var(--radius-sm); padding: .45rem .75rem; font-size: .875rem; background: var(--surface); color: var(--text); width: 200px; }
.search-input:focus { outline: none; border-color: var(--teal); box-shadow: 0 0 0 3px rgba(10,126,164,.12); }
.btn-primary { display: inline-flex; align-items: center; gap: .35rem; background: var(--teal); color: #fff; border: none; border-radius: var(--radius); padding: .5rem 1rem; font-size: .875rem; font-weight: 600; cursor: pointer; }
.btn-primary:hover { opacity: .88; }
.list { display: flex; flex-direction: column; gap: .5rem; }
.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: .875rem 1rem; display: flex; align-items: center; gap: 1rem; box-shadow: var(--shadow-sm); }
.card:hover { box-shadow: var(--shadow-md); }
.mat-icon { width: 40px; height: 40px; border-radius: var(--radius); background: var(--surface-2); display: flex; align-items: center; justify-content: center; color: var(--text-muted); flex-shrink: 0; }
.info { flex: 1; min-width: 0; }
.mat-name { font-weight: 600; color: var(--text); font-size: .938rem; }
.meta { font-size: .813rem; color: var(--text-muted); margin-top: .15rem; display: flex; gap: .75rem; align-items: center; }
.unit-pill { background: var(--surface-2); border-radius: var(--radius-sm); padding: .1rem .5rem; font-size: .75rem; font-weight: 600; color: var(--text-muted); }
.part-num { font-family: monospace; font-size: .813rem; }
.status-badge { display: inline-flex; padding: .15rem .55rem; border-radius: 999px; font-size: .7rem; font-weight: 700; text-transform: uppercase; }
.status-active { background: #DCFCE7; color: #15803D; }
.status-inactive { background: var(--surface-2); color: var(--text-muted); }
.actions { display: flex; gap: .35rem; flex-shrink: 0; }
.icon-btn { background: none; border: 1px solid var(--border); border-radius: var(--radius-sm); padding: .35rem .5rem; cursor: pointer; color: var(--text-muted); display: flex; align-items: center; transition: background .15s; }
.icon-btn:hover { background: var(--surface-2); color: var(--text); }
.icon-btn.danger:hover { background: #FEF2F2; color: var(--danger); border-color: var(--danger); }
.empty { text-align: center; padding: 3rem; color: var(--text-muted); }
</style>
<div class="page-header">
<h1>Materials</h1>
<div class="toolbar">
<input class="search-input" id="search" placeholder="Search materials…" value="${this.#esc(this.#search)}">
<button class="btn-primary" id="new-btn">
<i data-lucide="plus" style="width:14px;height:14px"></i> Add Material
</button>
</div>
</div>
${this.#loading ? '<ui-spinner></ui-spinner>' :
this.#items.length === 0
? `<div class="empty">
<i data-lucide="package" style="width:32px;height:32px;opacity:.3;display:block;margin:0 auto .5rem"></i>
No materials found
</div>`
: `<div class="list">
${this.#items.map(m => `
<div class="card">
<div class="mat-icon"><i data-lucide="package" style="width:18px;height:18px"></i></div>
<div class="info">
<div class="mat-name">${this.#esc(m.name)}</div>
<div class="meta">
${m.unit ? `<span class="unit-pill">${this.#esc(m.unit)}</span>` : ''}
${m.part_number ? `<span class="part-num">#${this.#esc(m.part_number)}</span>` : ''}
</div>
</div>
<span class="status-badge ${m.active ? 'status-active' : 'status-inactive'}">${m.active ? 'Active' : 'Inactive'}</span>
<div class="actions">
<button class="icon-btn" data-edit="${m.id}" title="Edit">
<i data-lucide="pencil" style="width:14px;height:14px"></i>
</button>
${m.active ? `<button class="icon-btn danger" data-deactivate="${m.id}" title="Deactivate">
<i data-lucide="ban" style="width:14px;height:14px"></i>
</button>` : ''}
</div>
</div>`).join('')}
</div>`}
<material-form id="material-form"></material-form>`;
if (window.lucide) lucide.createIcons({ nodes: [s] });
const searchEl = s.querySelector('#search');
let debounce;
searchEl.addEventListener('input', () => {
clearTimeout(debounce);
debounce = setTimeout(() => { this.#search = searchEl.value; this.#load(); }, 350);
});
s.querySelector('#new-btn').addEventListener('click', () =>
s.querySelector('#material-form').open(null, () => this.#load()));
s.querySelectorAll('[data-edit]').forEach(btn => {
const m = this.#items.find(x => x.id === +btn.dataset.edit);
btn.addEventListener('click', () => s.querySelector('#material-form').open(m, () => this.#load()));
});
s.querySelectorAll('[data-deactivate]').forEach(btn =>
btn.addEventListener('click', () => this.#deactivate(+btn.dataset.deactivate)));
}
#esc(s) { return (s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
}
customElements.define('material-list', MaterialList);
+116
View File
@@ -0,0 +1,116 @@
import { api } from '../../lib/api.mjs';
class PeopleForm extends HTMLElement {
#onSave = null;
connectedCallback() {
if (!this.shadowRoot) {
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `<style>:host{display:block}</style><dialog></dialog>`;
}
}
open(person, onSave) {
this.#onSave = onSave;
const isEdit = !!person;
const d = this.shadowRoot.querySelector('dialog');
d.innerHTML = `
<style>
dialog { border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 0; box-shadow: var(--shadow-lg); width: 440px; max-width: 95vw; background: var(--surface); color: var(--text); }
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); }
.dlg-title { font-size: 1rem; font-weight: 700; }
.dlg-close { background: none; border: none; cursor: pointer; color: var(--text-muted); padding: .25rem; display: flex; }
form { padding: 1.25rem; display: flex; flex-direction: column; gap: .875rem; }
.field-label { font-size: .813rem; font-weight: 600; color: var(--text); display: block; margin-bottom: .3rem; }
.field-input, .field-select { width: 100%; border: 1px solid var(--border); border-radius: var(--radius-sm); padding: .55rem .75rem; font-size: .875rem; background: var(--surface); color: var(--text); box-sizing: border-box; transition: border-color .15s; }
.field-input:focus, .field-select:focus { outline: none; border-color: var(--teal); box-shadow: 0 0 0 3px rgba(10,126,164,.12); }
.row-2 { display: grid; grid-template-columns: 1fr 1fr; gap: .75rem; }
.toggle-row { display: flex; align-items: center; justify-content: space-between; padding: .5rem 0; }
.toggle-label { font-size: .875rem; font-weight: 500; }
input[type=checkbox] { width: 18px; height: 18px; accent-color: var(--teal); cursor: pointer; }
.dlg-footer { padding: .875rem 1.25rem; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; gap: .5rem; }
.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; }
.btn-primary:disabled { opacity: .5; cursor: not-allowed; }
.error-msg { color: var(--danger); font-size: .813rem; min-height: 1.2em; }
</style>
<div class="dlg-header">
<span class="dlg-title">${isEdit ? 'Edit Person' : 'Add Person'}</span>
<button class="dlg-close" id="dlg-close"><i data-lucide="x" style="width:16px;height:16px"></i></button>
</div>
<form id="person-form" novalidate>
<div>
<label class="field-label" for="p-name">Name *</label>
<input class="field-input" id="p-name" type="text" value="${this.#esc(person?.name || '')}" placeholder="Full name" required maxlength="100">
</div>
<div>
<label class="field-label" for="p-role">Role / Title</label>
<input class="field-input" id="p-role" type="text" value="${this.#esc(person?.role || '')}" placeholder="e.g. Technician, Foreman" maxlength="100">
</div>
<div class="row-2">
<div>
<label class="field-label" for="p-email">Email</label>
<input class="field-input" id="p-email" type="email" value="${this.#esc(person?.email || '')}" placeholder="email@example.com" maxlength="200">
</div>
<div>
<label class="field-label" for="p-phone">Phone</label>
<input class="field-input" id="p-phone" type="tel" value="${this.#esc(person?.phone || '')}" placeholder="(555) 000-0000" maxlength="30">
</div>
</div>
${isEdit ? `
<div class="toggle-row">
<span class="toggle-label">Active</span>
<input type="checkbox" id="p-active" ${person.active ? 'checked' : ''}>
</div>` : ''}
<div class="error-msg" id="form-error"></div>
</form>
<div class="dlg-footer">
<button class="btn-ghost" id="dlg-cancel">Cancel</button>
<button class="btn-primary" id="dlg-save">${isEdit ? 'Save Changes' : 'Add Person'}</button>
</div>`;
if (window.lucide) lucide.createIcons({ nodes: [d] });
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 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.textContent = 'Saving…';
const payload = {
name,
role: d.querySelector('#p-role').value.trim(),
email: d.querySelector('#p-email').value.trim(),
phone: d.querySelector('#p-phone').value.trim(),
active: isEdit ? d.querySelector('#p-active').checked : true,
};
try {
if (isEdit) await api.put(`/registry/people/${person.id}`, payload);
else await api.post('/registry/people', payload);
d.close();
window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: isEdit ? 'Person updated' : 'Person added', type: 'success' } }));
this.#onSave?.();
} catch (err) {
errEl.textContent = err.message;
saveBtn.disabled = false;
saveBtn.textContent = isEdit ? 'Save Changes' : 'Add Person';
}
});
d.showModal();
d.querySelector('#p-name').focus();
}
#esc(s) { return (s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
}
customElements.define('people-form', PeopleForm);
+143
View File
@@ -0,0 +1,143 @@
import { api } from '../../lib/api.mjs';
import './people-form.mjs';
class PeopleList extends HTMLElement {
#people = [];
#loading = true;
#search = '';
connectedCallback() {
if (!this.shadowRoot) this.attachShadow({ mode: 'open' });
this.#load();
}
async #load() {
this.#loading = true;
this.#render();
try {
this.#people = await api.get(`/registry/people?all=1${this.#search ? '&search=' + encodeURIComponent(this.#search) : ''}`) || [];
} catch { this.#people = []; }
this.#loading = false;
this.#render();
}
async #deactivate(id) {
try {
await api.delete(`/registry/people/${id}`);
window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: 'Person deactivated', type: 'success' } }));
await this.#load();
} catch (err) {
window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: err.message, type: 'error' } }));
}
}
#initials(name) {
return (name || '?').split(' ').slice(0,2).map(w => w[0]).join('').toUpperCase();
}
#avatarColor(name) {
const colors = ['#0A7EA4','#8B5CF6','#E07B39','#1D9D6C','#D97706','#C0392B','#64748B'];
let h = 0;
for (const c of name || '') h = (h * 31 + c.charCodeAt(0)) & 0xffffffff;
return colors[Math.abs(h) % colors.length];
}
#render() {
const s = this.shadowRoot;
s.innerHTML = `
<style>
:host { display: block; }
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.25rem; flex-wrap: wrap; gap: .75rem; }
h1 { font-size: 1.2rem; font-weight: 700; color: var(--text); }
.toolbar { display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; }
.search-input { border: 1px solid var(--border); border-radius: var(--radius-sm); padding: .45rem .75rem; font-size: .875rem; background: var(--surface); color: var(--text); width: 200px; }
.search-input:focus { outline: none; border-color: var(--teal); box-shadow: 0 0 0 3px rgba(10,126,164,.12); }
.btn-primary { display: inline-flex; align-items: center; gap: .35rem; background: var(--teal); color: #fff; border: none; border-radius: var(--radius); padding: .5rem 1rem; font-size: .875rem; font-weight: 600; cursor: pointer; }
.btn-primary:hover { opacity: .88; }
.people-grid { display: flex; flex-direction: column; gap: .5rem; }
.person-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: .875rem 1rem; display: flex; align-items: center; gap: 1rem; box-shadow: var(--shadow-sm); transition: box-shadow .15s; }
.person-card:hover { box-shadow: var(--shadow-md); }
.avatar { width: 40px; height: 40px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: .875rem; font-weight: 700; color: #fff; flex-shrink: 0; }
.person-info { flex: 1; min-width: 0; }
.person-name { font-weight: 600; color: var(--text); font-size: .938rem; }
.person-meta { font-size: .813rem; color: var(--text-muted); margin-top: .15rem; display: flex; gap: .75rem; flex-wrap: wrap; }
.person-meta span { display: flex; align-items: center; gap: .25rem; }
.status-badge { display: inline-flex; align-items: center; gap: .3rem; padding: .15rem .55rem; border-radius: 999px; font-size: .7rem; font-weight: 700; text-transform: uppercase; }
.status-active { background: #DCFCE7; color: #15803D; }
.status-inactive { background: var(--surface-2); color: var(--text-muted); }
.actions { display: flex; gap: .35rem; flex-shrink: 0; }
.icon-btn { background: none; border: 1px solid var(--border); border-radius: var(--radius-sm); padding: .35rem .5rem; cursor: pointer; color: var(--text-muted); display: flex; align-items: center; transition: background .15s; }
.icon-btn:hover { background: var(--surface-2); color: var(--text); }
.icon-btn.danger:hover { background: #FEF2F2; color: var(--danger); border-color: var(--danger); }
.empty { text-align: center; padding: 3rem; color: var(--text-muted); }
@media (max-width: 768px) { .person-meta { display: none; } }
</style>
<div class="page-header">
<h1>People</h1>
<div class="toolbar">
<input class="search-input" id="search" placeholder="Search people…" value="${this.#esc(this.#search)}">
<button class="btn-primary" id="new-btn">
<i data-lucide="user-plus" style="width:14px;height:14px"></i> Add Person
</button>
</div>
</div>
${this.#loading ? '<ui-spinner></ui-spinner>' :
this.#people.length === 0
? `<div class="empty">
<i data-lucide="users" style="width:32px;height:32px;opacity:.3;display:block;margin:0 auto .5rem"></i>
No people found
</div>`
: `<div class="people-grid">
${this.#people.map(p => `
<div class="person-card">
<div class="avatar" style="background:${this.#avatarColor(p.name)}">${this.#initials(p.name)}</div>
<div class="person-info">
<div class="person-name">${this.#esc(p.name)}</div>
<div class="person-meta">
${p.role ? `<span><i data-lucide="briefcase" style="width:12px;height:12px"></i>${this.#esc(p.role)}</span>` : ''}
${p.phone ? `<span><i data-lucide="phone" style="width:12px;height:12px"></i>${this.#esc(p.phone)}</span>` : ''}
${p.email ? `<span><i data-lucide="mail" style="width:12px;height:12px"></i>${this.#esc(p.email)}</span>` : ''}
</div>
</div>
<span class="status-badge ${p.active ? 'status-active' : 'status-inactive'}">${p.active ? 'Active' : 'Inactive'}</span>
<div class="actions">
<button class="icon-btn" data-edit="${p.id}" title="Edit">
<i data-lucide="pencil" style="width:14px;height:14px"></i>
</button>
${p.active ? `<button class="icon-btn danger" data-deactivate="${p.id}" title="Deactivate">
<i data-lucide="user-x" style="width:14px;height:14px"></i>
</button>` : ''}
</div>
</div>`).join('')}
</div>`}
<people-form id="people-form"></people-form>`;
if (window.lucide) lucide.createIcons({ nodes: [s] });
const searchEl = s.querySelector('#search');
let debounce;
searchEl.addEventListener('input', () => {
clearTimeout(debounce);
debounce = setTimeout(() => { this.#search = searchEl.value; this.#load(); }, 350);
});
s.querySelector('#new-btn').addEventListener('click', () => {
s.querySelector('#people-form').open(null, () => this.#load());
});
s.querySelectorAll('[data-edit]').forEach(btn => {
const p = this.#people.find(x => x.id === +btn.dataset.edit);
btn.addEventListener('click', () => s.querySelector('#people-form').open(p, () => this.#load()));
});
s.querySelectorAll('[data-deactivate]').forEach(btn =>
btn.addEventListener('click', () => this.#deactivate(+btn.dataset.deactivate)));
}
#esc(s) { return (s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
}
customElements.define('people-list', PeopleList);
+232
View File
@@ -0,0 +1,232 @@
import { api } from '../../lib/api.mjs';
const PRIORITIES = ['low', 'normal', 'high', 'urgent'];
class ProfileForm extends HTMLElement {
#onSave = null;
connectedCallback() {
if (!this.shadowRoot) {
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `<style>:host{display:block}</style><dialog></dialog>`;
}
}
open(profile, onSave) {
this.#onSave = onSave;
const isEdit = !!profile;
const d = this.shadowRoot.querySelector('dialog');
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::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; }
.dlg-close { background:none; border:none; cursor:pointer; color:var(--text-muted); padding:.25rem; display:flex; }
.dlg-body { padding:1.25rem; display:flex; flex-direction:column; gap:.875rem; overflow-y:auto; flex:1; }
.field-label { font-size:.813rem; font-weight:600; color:var(--text); display:block; margin-bottom:.3rem; }
.field-input,.field-select,.field-textarea { width:100%; border:1px solid var(--border); border-radius:var(--radius-sm); padding:.55rem .75rem; font-size:.875rem; background:var(--surface); color:var(--text); box-sizing:border-box; transition:border-color .15s; font-family:inherit; }
.field-input:focus,.field-select:focus,.field-textarea:focus { outline:none; border-color:var(--teal); box-shadow:0 0 0 3px rgba(10,126,164,.12); }
.field-textarea { resize:vertical; min-height:80px; line-height:1.5; }
.row-2 { display:grid; grid-template-columns:1fr 1fr; gap:.75rem; }
.toggle-row { display:flex; align-items:center; justify-content:space-between; padding:.5rem 0; }
.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 { 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-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-del:hover { color:var(--danger); background:#FEF2F2; }
.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; }
.btn-add-step:hover { opacity:.88; }
.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; }
.btn-primary:disabled { opacity:.5; cursor:not-allowed; }
.error-msg { color:var(--danger); font-size:.813rem; min-height:1.2em; }
.empty-steps { font-size:.813rem; color:var(--text-muted); font-style:italic; text-align:center; padding:.5rem 0; }
</style>
<div class="dlg-header">
<span class="dlg-title">${isEdit ? 'Edit Profile' : 'New Profile'}</span>
<button class="dlg-close" id="dlg-close"><i data-lucide="x" style="width:16px;height:16px"></i></button>
</div>
<div class="dlg-body">
<div>
<label class="field-label" for="p-name">Name *</label>
<input class="field-input" id="p-name" type="text" value="${this.#esc(profile?.name || '')}" placeholder="e.g. Preventive Maintenance" required maxlength="200">
</div>
<div>
<label class="field-label" for="p-desc">Description</label>
<textarea class="field-textarea" id="p-desc" rows="2" placeholder="What type of work does this profile cover?">${this.#esc(profile?.description || '')}</textarea>
</div>
<div class="row-2">
<div>
<label class="field-label" for="p-cat">Category</label>
<input class="field-input" id="p-cat" type="text" value="${this.#esc(profile?.category || '')}" placeholder="e.g. Preventive, Emergency" maxlength="100">
</div>
<div>
<label class="field-label" for="p-pri">Default Priority</label>
<select class="field-select" id="p-pri">
${PRIORITIES.map(p => `<option value="${p}" ${(profile?.default_priority || 'normal') === p ? 'selected' : ''}>${p.charAt(0).toUpperCase() + p.slice(1)}</option>`).join('')}
</select>
</div>
</div>
<div>
<label class="field-label" for="p-dur">Default Duration (hours)</label>
<input class="field-input" id="p-dur" type="number" min="0" step="0.5" value="${profile?.default_duration_hours ?? ''}" placeholder="e.g. 4">
</div>
<div>
<label class="field-label" for="p-instr">Default Instructions</label>
<textarea class="field-textarea" id="p-instr" rows="4" placeholder="Instructions that will be copied to each work order using this profile.">${this.#esc(profile?.default_instructions || '')}</textarea>
</div>
${isEdit ? `
<div class="toggle-row">
<span class="toggle-label">Active</span>
<input type="checkbox" id="p-active" ${profile.active ? 'checked' : ''}>
</div>` : ''}
<div class="section-title">Default Steps</div>
<div class="step-list" id="step-list">
${(profile?.steps || []).length === 0
? '<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>
<div class="error-msg" id="form-error"></div>
</div>
<div class="dlg-footer">
<button class="btn-ghost" id="dlg-cancel">Cancel</button>
<button class="btn-primary" id="dlg-save">${isEdit ? 'Save Changes' : 'Create Profile'}</button>
</div>`;
if (window.lucide) lucide.createIcons({ nodes: [d] });
// Local step state (not saved until the profile save)
const profileId = profile?.id || null;
let pendingSteps = (profile?.steps || []).map(s => ({ ...s }));
const renderSteps = () => {
const list = d.querySelector('#step-list');
const empty = pendingSteps.length === 0;
list.innerHTML = empty
? '<div class="empty-steps">No steps yet — add steps below</div>'
: pendingSteps.map((s, i) => this.#stepRowHTML(s, i)).join('');
list.querySelectorAll('.step-title-input').forEach((inp, i) => {
inp.addEventListener('input', () => { pendingSteps[i].title = inp.value; });
inp.addEventListener('change', () => { pendingSteps[i].title = inp.value; });
});
list.querySelectorAll('.step-req-check').forEach((cb, i) => {
cb.addEventListener('change', () => { pendingSteps[i].required = cb.checked; });
});
list.querySelectorAll('.step-del').forEach((btn, i) => {
btn.addEventListener('click', () => { pendingSteps.splice(i, 1); renderSteps(); });
});
};
renderSteps();
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 });
inp.value = '';
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());
d.querySelector('#dlg-cancel').addEventListener('click', () => d.close());
d.querySelector('#dlg-save').addEventListener('click', async () => {
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.textContent = 'Saving…';
const payload = {
name,
description: d.querySelector('#p-desc').value.trim(),
category: d.querySelector('#p-cat').value.trim(),
default_priority: d.querySelector('#p-pri').value,
default_duration_hours: d.querySelector('#p-dur').value ? +d.querySelector('#p-dur').value : null,
default_instructions: d.querySelector('#p-instr').value.trim(),
active: isEdit ? d.querySelector('#p-active').checked : true,
};
try {
let savedProfile;
if (isEdit) {
savedProfile = await api.put(`/profiles/${profile.id}`, payload);
} else {
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
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
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 };
if (s.id && origIds.has(s.id)) {
await api.put(`/profiles/${savedId}/steps/${s.id}`, stepPayload);
} else {
await api.post(`/profiles/${savedId}/steps`, stepPayload);
}
}
d.close();
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';
}
});
d.showModal();
d.querySelector('#p-name').focus();
}
#stepRowHTML(s, i) {
return `
<div class="step-row">
<span class="step-order">${i + 1}</span>
<input class="step-title-input" value="${this.#esc(s.title)}" placeholder="Step title…">
<label class="step-req">
<input type="checkbox" class="step-req-check" ${s.required !== false ? 'checked' : ''}> Required
</label>
<button class="step-del" title="Remove step"><i data-lucide="x" style="width:12px;height:12px"></i></button>
</div>`;
}
#esc(s) { return (s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); }
}
customElements.define('profile-form', ProfileForm);
+143
View File
@@ -0,0 +1,143 @@
import { api } from '../../lib/api.mjs';
import './profile-form.mjs';
const PRIORITY_COLORS = { low: '#64748B', normal: '#0A7EA4', high: '#E07B39', urgent: '#C0392B' };
class ProfileList extends HTMLElement {
#items = [];
#loading = true;
#search = '';
connectedCallback() {
if (!this.shadowRoot) this.attachShadow({ mode: 'open' });
this.#load();
}
async #load() {
this.#loading = true;
this.#render();
try {
this.#items = await api.get(`/profiles?all=1${this.#search ? '&search=' + encodeURIComponent(this.#search) : ''}`) || [];
} catch { this.#items = []; }
this.#loading = false;
this.#render();
}
async #deactivate(id) {
try {
await api.delete(`/profiles/${id}`);
window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: 'Profile deactivated', type: 'success' } }));
await this.#load();
} catch (err) {
window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: err.message, type: 'error' } }));
}
}
#render() {
const s = this.shadowRoot;
s.innerHTML = `
<style>
:host { display: block; }
.page-header { display:flex; align-items:center; justify-content:space-between; margin-bottom:1.25rem; flex-wrap:wrap; gap:.75rem; }
h1 { font-size:1.2rem; font-weight:700; color:var(--text); }
.toolbar { display:flex; gap:.5rem; align-items:center; flex-wrap:wrap; }
.search-input { border:1px solid var(--border); border-radius:var(--radius-sm); padding:.45rem .75rem; font-size:.875rem; background:var(--surface); color:var(--text); width:220px; }
.search-input:focus { outline:none; border-color:var(--teal); box-shadow:0 0 0 3px rgba(10,126,164,.12); }
.btn-primary { display:inline-flex; align-items:center; gap:.35rem; background:var(--teal); color:#fff; border:none; border-radius:var(--radius); padding:.5rem 1rem; font-size:.875rem; font-weight:600; cursor:pointer; }
.btn-primary:hover { opacity:.88; }
.list { display:flex; flex-direction:column; gap:.5rem; }
.card { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); padding:.875rem 1rem; display:flex; align-items:center; gap:1rem; box-shadow:var(--shadow-sm); transition:box-shadow .15s; }
.card:hover { box-shadow:var(--shadow-md); }
.profile-icon { width:40px; height:40px; border-radius:var(--radius); background:var(--surface-2); display:flex; align-items:center; justify-content:center; color:var(--teal); flex-shrink:0; }
.info { flex:1; min-width:0; }
.profile-name { font-weight:600; color:var(--text); font-size:.938rem; }
.meta { font-size:.813rem; color:var(--text-muted); margin-top:.15rem; display:flex; gap:.75rem; align-items:center; flex-wrap:wrap; }
.cat-pill { background:var(--surface-2); border-radius:var(--radius-sm); padding:.1rem .5rem; font-size:.75rem; font-weight:600; color:var(--text-muted); }
.step-count { font-size:.75rem; color:var(--text-muted); }
.pri-dot { width:8px; height:8px; border-radius:50%; display:inline-block; flex-shrink:0; }
.status-badge { display:inline-flex; padding:.15rem .55rem; border-radius:999px; font-size:.7rem; font-weight:700; text-transform:uppercase; }
.status-active { background:#DCFCE7; color:#15803D; }
.status-inactive { background:var(--surface-2); color:var(--text-muted); }
.actions { display:flex; gap:.35rem; flex-shrink:0; }
.icon-btn { background:none; border:1px solid var(--border); border-radius:var(--radius-sm); padding:.35rem .5rem; cursor:pointer; color:var(--text-muted); display:flex; align-items:center; transition:background .15s; }
.icon-btn:hover { background:var(--surface-2); color:var(--text); }
.icon-btn.danger:hover { background:#FEF2F2; color:var(--danger); border-color:var(--danger); }
.empty { text-align:center; padding:3rem; color:var(--text-muted); }
</style>
<div class="page-header">
<h1>Work Order Profiles</h1>
<div class="toolbar">
<input class="search-input" id="search" placeholder="Search profiles…" value="${this.#esc(this.#search)}">
<button class="btn-primary" id="new-btn">
<i data-lucide="plus" style="width:14px;height:14px"></i> New Profile
</button>
</div>
</div>
${this.#loading ? '<ui-spinner></ui-spinner>' :
this.#items.length === 0
? `<div class="empty">
<i data-lucide="layout-template" style="width:32px;height:32px;opacity:.3;display:block;margin:0 auto .5rem"></i>
No profiles found
</div>`
: `<div class="list">
${this.#items.map(p => `
<div class="card">
<div class="profile-icon"><i data-lucide="layout-template" style="width:18px;height:18px"></i></div>
<div class="info">
<div class="profile-name">${this.#esc(p.name)}</div>
<div class="meta">
${p.category ? `<span class="cat-pill">${this.#esc(p.category)}</span>` : ''}
<span class="pri-dot" style="background:${PRIORITY_COLORS[p.default_priority] || PRIORITY_COLORS.normal}"></span>
<span>${p.default_priority}</span>
<span class="step-count"><i data-lucide="list-checks" style="width:11px;height:11px;vertical-align:middle"></i> ${p.step_count} step${p.step_count !== 1 ? 's' : ''}</span>
${p.default_duration_hours ? `<span>⏱ ${p.default_duration_hours}h</span>` : ''}
</div>
</div>
<span class="status-badge ${p.active ? 'status-active' : 'status-inactive'}">${p.active ? 'Active' : 'Inactive'}</span>
<div class="actions">
<button class="icon-btn" data-edit="${p.id}" title="Edit">
<i data-lucide="pencil" style="width:14px;height:14px"></i>
</button>
${p.active ? `<button class="icon-btn danger" data-deactivate="${p.id}" title="Deactivate">
<i data-lucide="ban" style="width:14px;height:14px"></i>
</button>` : ''}
</div>
</div>`).join('')}
</div>`}
<profile-form id="profile-form"></profile-form>`;
if (window.lucide) lucide.createIcons({ nodes: [s] });
const searchEl = s.querySelector('#search');
let debounce;
searchEl.addEventListener('input', () => {
clearTimeout(debounce);
debounce = setTimeout(() => { this.#search = searchEl.value; this.#load(); }, 350);
});
s.querySelector('#new-btn').addEventListener('click', () =>
s.querySelector('#profile-form').open(null, () => this.#load()));
s.querySelectorAll('[data-edit]').forEach(btn => {
btn.addEventListener('click', async () => {
const id = +btn.dataset.edit;
try {
const full = await api.get(`/profiles/${id}`);
s.querySelector('#profile-form').open(full, () => this.#load());
} catch (err) {
window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: err.message, type: 'error' } }));
}
});
});
s.querySelectorAll('[data-deactivate]').forEach(btn =>
btn.addEventListener('click', () => this.#deactivate(+btn.dataset.deactivate)));
}
#esc(s) { return (s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
}
customElements.define('profile-list', ProfileList);
+112
View File
@@ -0,0 +1,112 @@
import { api } from '../../lib/api.mjs';
const VEHICLE_TYPES = ['Bucket Truck', 'Van', 'Pickup Truck', 'Trailer', 'Digger Derrick', 'Other'];
class VehicleForm extends HTMLElement {
#onSave = null;
connectedCallback() {
if (!this.shadowRoot) {
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `<style>:host{display:block}</style><dialog></dialog>`;
}
}
open(vehicle, onSave) {
this.#onSave = onSave;
const isEdit = !!vehicle;
const d = this.shadowRoot.querySelector('dialog');
d.innerHTML = `
<style>
dialog { border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 0; box-shadow: var(--shadow-lg); width: 440px; max-width: 95vw; background: var(--surface); color: var(--text); }
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); }
.dlg-title { font-size: 1rem; font-weight: 700; }
.dlg-close { background: none; border: none; cursor: pointer; color: var(--text-muted); padding: .25rem; display: flex; }
form { padding: 1.25rem; display: flex; flex-direction: column; gap: .875rem; }
.field-label { font-size: .813rem; font-weight: 600; color: var(--text); display: block; margin-bottom: .3rem; }
.field-input, .field-select { width: 100%; border: 1px solid var(--border); border-radius: var(--radius-sm); padding: .55rem .75rem; font-size: .875rem; background: var(--surface); color: var(--text); box-sizing: border-box; transition: border-color .15s; }
.field-input:focus, .field-select:focus { outline: none; border-color: var(--teal); box-shadow: 0 0 0 3px rgba(10,126,164,.12); }
.toggle-row { display: flex; align-items: center; justify-content: space-between; padding: .5rem 0; }
.toggle-label { font-size: .875rem; font-weight: 500; }
input[type=checkbox] { width: 18px; height: 18px; accent-color: var(--teal); cursor: pointer; }
.dlg-footer { padding: .875rem 1.25rem; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; gap: .5rem; }
.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; }
.btn-primary:disabled { opacity: .5; cursor: not-allowed; }
.error-msg { color: var(--danger); font-size: .813rem; min-height: 1.2em; }
</style>
<div class="dlg-header">
<span class="dlg-title">${isEdit ? 'Edit Vehicle' : 'Add Vehicle'}</span>
<button class="dlg-close" id="dlg-close"><i data-lucide="x" style="width:16px;height:16px"></i></button>
</div>
<form id="vehicle-form" novalidate>
<div>
<label class="field-label" for="v-unit">Unit Number *</label>
<input class="field-input" id="v-unit" type="text" value="${this.#esc(vehicle?.unit_number || '')}" placeholder="e.g. VEH-001" required maxlength="50">
</div>
<div>
<label class="field-label" for="v-desc">Description</label>
<input class="field-input" id="v-desc" type="text" value="${this.#esc(vehicle?.description || '')}" placeholder="e.g. 2022 Ford F-350" maxlength="200">
</div>
<div>
<label class="field-label" for="v-type">Vehicle Type</label>
<select class="field-select" id="v-type">
<option value="">— Select type —</option>
${VEHICLE_TYPES.map(t => `<option value="${t}" ${vehicle?.vehicle_type === t ? 'selected' : ''}>${t}</option>`).join('')}
</select>
</div>
${isEdit ? `
<div class="toggle-row">
<span class="toggle-label">Active</span>
<input type="checkbox" id="v-active" ${vehicle.active ? 'checked' : ''}>
</div>` : ''}
<div class="error-msg" id="form-error"></div>
</form>
<div class="dlg-footer">
<button class="btn-ghost" id="dlg-cancel">Cancel</button>
<button class="btn-primary" id="dlg-save">${isEdit ? 'Save Changes' : 'Add Vehicle'}</button>
</div>`;
if (window.lucide) lucide.createIcons({ nodes: [d] });
d.querySelector('#dlg-close').addEventListener('click', () => d.close());
d.querySelector('#dlg-cancel').addEventListener('click', () => d.close());
d.querySelector('#dlg-save').addEventListener('click', async () => {
const unit = d.querySelector('#v-unit').value.trim();
const errEl = d.querySelector('#form-error');
if (!unit) { errEl.textContent = 'Unit number is required'; d.querySelector('#v-unit').focus(); return; }
errEl.textContent = '';
const saveBtn = d.querySelector('#dlg-save');
saveBtn.disabled = true;
saveBtn.textContent = 'Saving…';
const payload = {
unit_number: unit,
description: d.querySelector('#v-desc').value.trim(),
vehicle_type: d.querySelector('#v-type').value,
active: isEdit ? d.querySelector('#v-active').checked : true,
};
try {
if (isEdit) await api.put(`/registry/vehicles/${vehicle.id}`, payload);
else await api.post('/registry/vehicles', payload);
d.close();
window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: isEdit ? 'Vehicle updated' : 'Vehicle added', type: 'success' } }));
this.#onSave?.();
} catch (err) {
errEl.textContent = err.message;
saveBtn.disabled = false;
saveBtn.textContent = isEdit ? 'Save Changes' : 'Add Vehicle';
}
});
d.showModal();
d.querySelector('#v-unit').focus();
}
#esc(s) { return (s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
}
customElements.define('vehicle-form', VehicleForm);
+128
View File
@@ -0,0 +1,128 @@
import { api } from '../../lib/api.mjs';
import './vehicle-form.mjs';
class VehicleList extends HTMLElement {
#vehicles = [];
#loading = true;
#search = '';
connectedCallback() {
if (!this.shadowRoot) this.attachShadow({ mode: 'open' });
this.#load();
}
async #load() {
this.#loading = true;
this.#render();
try {
this.#vehicles = await api.get(`/registry/vehicles?all=1${this.#search ? '&search=' + encodeURIComponent(this.#search) : ''}`) || [];
} catch { this.#vehicles = []; }
this.#loading = false;
this.#render();
}
async #deactivate(id) {
try {
await api.delete(`/registry/vehicles/${id}`);
window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: 'Vehicle deactivated', type: 'success' } }));
await this.#load();
} catch (err) {
window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: err.message, type: 'error' } }));
}
}
#render() {
const s = this.shadowRoot;
s.innerHTML = `
<style>
:host { display: block; }
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.25rem; flex-wrap: wrap; gap: .75rem; }
h1 { font-size: 1.2rem; font-weight: 700; color: var(--text); }
.toolbar { display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; }
.search-input { border: 1px solid var(--border); border-radius: var(--radius-sm); padding: .45rem .75rem; font-size: .875rem; background: var(--surface); color: var(--text); width: 200px; }
.search-input:focus { outline: none; border-color: var(--teal); box-shadow: 0 0 0 3px rgba(10,126,164,.12); }
.btn-primary { display: inline-flex; align-items: center; gap: .35rem; background: var(--teal); color: #fff; border: none; border-radius: var(--radius); padding: .5rem 1rem; font-size: .875rem; font-weight: 600; cursor: pointer; }
.btn-primary:hover { opacity: .88; }
.list { display: flex; flex-direction: column; gap: .5rem; }
.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: .875rem 1rem; display: flex; align-items: center; gap: 1rem; box-shadow: var(--shadow-sm); }
.card:hover { box-shadow: var(--shadow-md); }
.vehicle-icon { width: 40px; height: 40px; border-radius: var(--radius); background: var(--surface-2); display: flex; align-items: center; justify-content: center; color: var(--teal); flex-shrink: 0; }
.info { flex: 1; min-width: 0; }
.unit-num { font-weight: 700; color: var(--text); font-family: monospace; font-size: .938rem; }
.meta { font-size: .813rem; color: var(--text-muted); margin-top: .15rem; display: flex; gap: .75rem; flex-wrap: wrap; }
.status-badge { display: inline-flex; align-items: center; gap: .3rem; padding: .15rem .55rem; border-radius: 999px; font-size: .7rem; font-weight: 700; text-transform: uppercase; }
.status-active { background: #DCFCE7; color: #15803D; }
.status-inactive { background: var(--surface-2); color: var(--text-muted); }
.actions { display: flex; gap: .35rem; flex-shrink: 0; }
.icon-btn { background: none; border: 1px solid var(--border); border-radius: var(--radius-sm); padding: .35rem .5rem; cursor: pointer; color: var(--text-muted); display: flex; align-items: center; transition: background .15s; }
.icon-btn:hover { background: var(--surface-2); color: var(--text); }
.icon-btn.danger:hover { background: #FEF2F2; color: var(--danger); border-color: var(--danger); }
.empty { text-align: center; padding: 3rem; color: var(--text-muted); }
</style>
<div class="page-header">
<h1>Vehicles</h1>
<div class="toolbar">
<input class="search-input" id="search" placeholder="Search vehicles…" value="${this.#esc(this.#search)}">
<button class="btn-primary" id="new-btn">
<i data-lucide="plus" style="width:14px;height:14px"></i> Add Vehicle
</button>
</div>
</div>
${this.#loading ? '<ui-spinner></ui-spinner>' :
this.#vehicles.length === 0
? `<div class="empty">
<i data-lucide="truck" style="width:32px;height:32px;opacity:.3;display:block;margin:0 auto .5rem"></i>
No vehicles found
</div>`
: `<div class="list">
${this.#vehicles.map(v => `
<div class="card">
<div class="vehicle-icon"><i data-lucide="truck" style="width:18px;height:18px"></i></div>
<div class="info">
<div class="unit-num">${this.#esc(v.unit_number)}</div>
<div class="meta">
${v.description ? `<span>${this.#esc(v.description)}</span>` : ''}
${v.vehicle_type ? `<span>${this.#esc(v.vehicle_type)}</span>` : ''}
</div>
</div>
<span class="status-badge ${v.active ? 'status-active' : 'status-inactive'}">${v.active ? 'Active' : 'Inactive'}</span>
<div class="actions">
<button class="icon-btn" data-edit="${v.id}" title="Edit">
<i data-lucide="pencil" style="width:14px;height:14px"></i>
</button>
${v.active ? `<button class="icon-btn danger" data-deactivate="${v.id}" title="Deactivate">
<i data-lucide="ban" style="width:14px;height:14px"></i>
</button>` : ''}
</div>
</div>`).join('')}
</div>`}
<vehicle-form id="vehicle-form"></vehicle-form>`;
if (window.lucide) lucide.createIcons({ nodes: [s] });
const searchEl = s.querySelector('#search');
let debounce;
searchEl.addEventListener('input', () => {
clearTimeout(debounce);
debounce = setTimeout(() => { this.#search = searchEl.value; this.#load(); }, 350);
});
s.querySelector('#new-btn').addEventListener('click', () =>
s.querySelector('#vehicle-form').open(null, () => this.#load()));
s.querySelectorAll('[data-edit]').forEach(btn => {
const v = this.#vehicles.find(x => x.id === +btn.dataset.edit);
btn.addEventListener('click', () => s.querySelector('#vehicle-form').open(v, () => this.#load()));
});
s.querySelectorAll('[data-deactivate]').forEach(btn =>
btn.addEventListener('click', () => this.#deactivate(+btn.dataset.deactivate)));
}
#esc(s) { return (s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
}
customElements.define('vehicle-list', VehicleList);