278 lines
14 KiB
JavaScript
278 lines
14 KiB
JavaScript
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({ root: 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,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
|
}
|
|
|
|
customElements.define('wo-resource-panel', WoResourcePanel);
|