Files
workorders/web/components/work-orders/wo-resource-panel.mjs
T

278 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { api } from '../../lib/api.mjs';
const SECTIONS = [
{ type: 'person', label: 'People', icon: 'users', endpoint: 'people' },
{ type: 'vehicle', label: 'Vehicles', icon: 'truck', endpoint: 'vehicles' },
{ type: 'equipment', label: 'Equipment', icon: 'wrench', endpoint: 'equipment' },
{ type: 'material', label: 'Materials', icon: 'package', endpoint: 'materials' },
];
class WoResourcePanel extends HTMLElement {
#woId = null;
#resources = [];
#registry = {};
#loading = true;
#pickerType = null;
static get observedAttributes() { return ['wo-id']; }
connectedCallback() {
if (!this.shadowRoot) this.attachShadow({ mode: 'open' });
if (this.#woId) this.#load();
}
attributeChangedCallback(_, __, val) {
this.#woId = val ? +val : null;
if (this.shadowRoot && this.#woId) this.#load();
}
async #load() {
this.#loading = true;
this.#render();
try {
const [resources, people, vehicles, equipment, materials] = await Promise.all([
api.get(`/work-orders/${this.#woId}/resources`),
api.get('/registry/people'),
api.get('/registry/vehicles'),
api.get('/registry/equipment'),
api.get('/registry/materials'),
]);
this.#resources = resources || [];
this.#registry = { person: people || [], vehicle: vehicles || [], equipment: equipment || [], material: materials || [] };
} catch { this.#resources = []; this.#registry = {}; }
this.#loading = false;
this.#render();
}
async #assign(type, resourceId, quantity) {
try {
await api.post(`/work-orders/${this.#woId}/resources`, {
resource_type: type,
resource_id: +resourceId,
quantity: quantity ? +quantity : null,
});
await this.#load();
} catch (err) {
window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: err.message, type: 'error' } }));
}
}
async #remove(assignId) {
try {
await api.delete(`/work-orders/${this.#woId}/resources/${assignId}`);
await this.#load();
} catch (err) {
window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: err.message, type: 'error' } }));
}
}
#resourceName(item, type) {
if (type === 'vehicle') return `${item.unit_number}${item.description ? ' ' + item.description : ''}`;
return item.name || item.unit_number || '—';
}
#sectionHTML(section) {
const assigned = this.#resources.filter(r => r.resource_type === section.type);
const count = assigned.length;
return `
<div class="section" data-type="${section.type}">
<div class="section-header">
<span class="section-icon"><i data-lucide="${section.icon}" style="width:15px;height:15px"></i></span>
<span class="section-label">${section.label}</span>
${count ? `<span class="section-count">${count}</span>` : ''}
<button class="add-resource-btn" data-type="${section.type}" title="Add ${section.label}">
<i data-lucide="plus" style="width:14px;height:14px"></i>
</button>
</div>
<div class="assigned-list">
${count === 0 ? `<div class="empty-section">No ${section.label.toLowerCase()} assigned</div>` :
assigned.map(r => `
<div class="assigned-item">
<span class="item-name">${this.#esc(r.name)}</span>
${r.resource_type === 'material' && r.quantity != null
? `<span class="item-qty">${r.quantity} ${this.#unitFor(r.resource_id)}</span>`
: ''}
<button class="remove-btn" data-rid="${r.id}" title="Remove">
<i data-lucide="x" style="width:13px;height:13px"></i>
</button>
</div>`).join('')}
</div>
</div>`;
}
#unitFor(resourceId) {
const item = (this.#registry['material'] || []).find(m => m.id === resourceId);
return item ? item.unit : '';
}
#render() {
const s = this.shadowRoot;
if (this.#loading) {
s.innerHTML = `<style>:host{display:block;padding:2rem;text-align:center}</style><ui-spinner></ui-spinner>`;
return;
}
s.innerHTML = `
<style>
:host { display: block; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
.section { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 1rem; }
.section-header { display: flex; align-items: center; gap: .5rem; margin-bottom: .75rem; }
.section-icon { color: var(--teal); display: flex; }
.section-label { font-weight: 700; font-size: .813rem; text-transform: uppercase; letter-spacing: .06em; color: var(--text-muted); flex: 1; }
.section-count { background: var(--teal); color: #fff; border-radius: 999px; font-size: .7rem; font-weight: 700; padding: .1rem .45rem; }
.add-resource-btn { background: none; border: 1px solid var(--border); color: var(--teal); border-radius: var(--radius-sm); cursor: pointer; padding: .25rem; display: flex; align-items: center; transition: background .15s; }
.add-resource-btn:hover { background: var(--surface-2); }
.assigned-list { display: flex; flex-direction: column; gap: .35rem; }
.assigned-item { display: flex; align-items: center; gap: .5rem; padding: .4rem .6rem; background: var(--surface-2); border-radius: var(--radius-sm); font-size: .875rem; }
.item-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.item-qty { font-size: .75rem; color: var(--text-muted); white-space: nowrap; }
.remove-btn { background: none; border: none; cursor: pointer; color: var(--text-muted); padding: .15rem; border-radius: var(--radius-sm); display: flex; flex-shrink: 0; transition: color .15s; }
.remove-btn:hover { color: var(--danger); }
.empty-section { font-size: .813rem; color: var(--text-muted); font-style: italic; padding: .25rem 0; }
/* Picker dialog */
dialog { border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 0; box-shadow: var(--shadow-lg); width: 400px; max-width: 95vw; background: var(--surface); color: var(--text); }
dialog::backdrop { background: rgba(0,0,0,.45); }
.dialog-header { display: flex; align-items: center; justify-content: space-between; padding: 1rem 1.25rem; border-bottom: 1px solid var(--border); }
.dialog-title { font-size: 1rem; font-weight: 700; }
.dialog-close { background: none; border: none; cursor: pointer; color: var(--text-muted); padding: .25rem; display: flex; }
.dialog-body { padding: 1rem 1.25rem; }
.picker-search { width: 100%; border: 1px solid var(--border); border-radius: var(--radius-sm); padding: .5rem .75rem; font-size: .875rem; margin-bottom: .75rem; background: var(--surface); color: var(--text); box-sizing: border-box; }
.picker-search:focus { outline: none; border-color: var(--teal); box-shadow: 0 0 0 3px rgba(10,126,164,.12); }
.picker-list { max-height: 240px; overflow-y: auto; display: flex; flex-direction: column; gap: .3rem; }
.picker-item { display: flex; align-items: center; gap: .75rem; padding: .6rem .75rem; border: 1px solid transparent; border-radius: var(--radius-sm); cursor: pointer; transition: background .15s; }
.picker-item:hover { background: var(--surface-2); border-color: var(--border); }
.picker-item.selected { background: #E0F2FE; border-color: var(--teal); }
.picker-name { font-size: .875rem; font-weight: 500; flex: 1; }
.picker-sub { font-size: .75rem; color: var(--text-muted); }
.picker-empty { text-align: center; padding: 1.5rem; color: var(--text-muted); font-size: .875rem; }
.qty-row { display: flex; align-items: center; gap: .5rem; margin-top: .75rem; padding-top: .75rem; border-top: 1px solid var(--border); }
.qty-label { font-size: .875rem; font-weight: 500; }
.qty-input { width: 80px; border: 1px solid var(--border); border-radius: var(--radius-sm); padding: .4rem .5rem; font-size: .875rem; background: var(--surface); color: var(--text); }
.dialog-footer { padding: .75rem 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: .45rem .9rem; font-size: .813rem; font-weight: 600; cursor: pointer; }
.btn-primary { background: var(--teal); color: #fff; border: none; border-radius: var(--radius); padding: .45rem .9rem; font-size: .813rem; font-weight: 600; cursor: pointer; }
.btn-primary:disabled { opacity: .5; cursor: not-allowed; }
@media (max-width: 768px) { .grid { grid-template-columns: 1fr; } }
</style>
<div class="grid">
${SECTIONS.map(sec => this.#sectionHTML(sec)).join('')}
</div>
<dialog id="picker-dialog">
<div class="dialog-header">
<span class="dialog-title" id="picker-title">Add Resource</span>
<button class="dialog-close" id="picker-close"><i data-lucide="x" style="width:16px;height:16px"></i></button>
</div>
<div class="dialog-body">
<input class="picker-search" id="picker-search" placeholder="Search…" autocomplete="off">
<div class="picker-list" id="picker-list"></div>
<div class="qty-row" id="qty-row" style="display:none">
<label class="qty-label" for="qty-input">Quantity:</label>
<input class="qty-input" id="qty-input" type="number" min="0" step="0.01" value="1">
</div>
</div>
<div class="dialog-footer">
<button class="btn-ghost" id="picker-cancel">Cancel</button>
<button class="btn-primary" id="picker-add" disabled>Add Selected</button>
</div>
</dialog>`;
if (window.lucide) lucide.createIcons({ nodes: [s] });
this.#bindEvents();
}
#bindEvents() {
const s = this.shadowRoot;
s.querySelectorAll('[data-rid]').forEach(btn =>
btn.addEventListener('click', () => this.#remove(+btn.dataset.rid)));
s.querySelectorAll('.add-resource-btn').forEach(btn =>
btn.addEventListener('click', () => this.#openPicker(btn.dataset.type)));
const dialog = s.querySelector('#picker-dialog');
const closeBtn = s.querySelector('#picker-close');
const cancelBtn = s.querySelector('#picker-cancel');
const addBtn = s.querySelector('#picker-add');
const search = s.querySelector('#picker-search');
closeBtn.addEventListener('click', () => dialog.close());
cancelBtn.addEventListener('click', () => dialog.close());
dialog.addEventListener('click', e => { if (e.target === dialog) dialog.close(); });
search.addEventListener('input', () => this.#filterPicker(search.value));
addBtn.addEventListener('click', () => {
const selected = s.querySelector('.picker-item.selected');
if (!selected) return;
const qty = s.querySelector('#qty-input')?.value || null;
this.#assign(this.#pickerType, selected.dataset.id, qty);
dialog.close();
});
}
#openPicker(type) {
const s = this.shadowRoot;
const section = SECTIONS.find(sec => sec.type === type);
this.#pickerType = type;
s.querySelector('#picker-title').textContent = `Add ${section.label}`;
s.querySelector('#picker-search').value = '';
s.querySelector('#qty-row').style.display = type === 'material' ? 'flex' : 'none';
s.querySelector('#picker-add').disabled = true;
this.#filterPicker('');
s.querySelector('#picker-dialog').showModal();
s.querySelector('#picker-search').focus();
}
#filterPicker(query) {
const s = this.shadowRoot;
const type = this.#pickerType;
const list = this.#registry[type] || [];
const assigned = this.#resources.filter(r => r.resource_type === type).map(r => r.resource_id);
const q = query.toLowerCase();
const available = list.filter(item => {
if (assigned.includes(item.id)) return false;
const name = (item.name || item.unit_number || '').toLowerCase();
const sub = (item.role || item.vehicle_type || item.category || item.part_number || '').toLowerCase();
return !q || name.includes(q) || sub.includes(q);
});
const container = s.querySelector('#picker-list');
if (available.length === 0) {
container.innerHTML = '<div class="picker-empty">No items available</div>';
return;
}
container.innerHTML = available.map(item => {
const name = type === 'vehicle'
? `${item.unit_number}${item.description ? ' ' + item.description : ''}`
: item.name;
const sub = item.role || item.vehicle_type || item.category || (item.part_number ? `#${item.part_number}` : '') || '';
return `
<div class="picker-item" data-id="${item.id}">
<div>
<div class="picker-name">${this.#esc(name)}</div>
${sub ? `<div class="picker-sub">${this.#esc(sub)}</div>` : ''}
</div>
</div>`;
}).join('');
container.querySelectorAll('.picker-item').forEach(item => {
item.addEventListener('click', () => {
container.querySelectorAll('.picker-item').forEach(i => i.classList.remove('selected'));
item.classList.add('selected');
this.shadowRoot.querySelector('#picker-add').disabled = false;
});
});
}
#esc(s) { return (s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
}
customElements.define('wo-resource-panel', WoResourcePanel);