233 lines
13 KiB
JavaScript
233 lines
13 KiB
JavaScript
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({ root: 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
|
|
}
|
|
|
|
customElements.define('profile-form', ProfileForm);
|