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

128 lines
6.3 KiB
JavaScript

import { api } from '../../lib/api.mjs';
const COLUMNS = [
{ key: 'draft', label: 'Draft', color: 'var(--status-draft)' },
{ key: 'assigned', label: 'Assigned', color: 'var(--status-assigned)' },
{ key: 'scheduled', label: 'Scheduled', color: 'var(--status-scheduled)' },
{ key: 'in_progress', label: 'In Progress', color: 'var(--status-in_progress)' },
{ key: 'pending_review', label: 'Pending Review', color: 'var(--status-pending_review)' },
{ key: 'closed', label: 'Closed', color: 'var(--status-closed)' },
];
const PRI_COLOR = { low:'var(--priority-low)', normal:'var(--priority-normal)', high:'var(--priority-high)', urgent:'var(--priority-urgent)' };
class WoKanban extends HTMLElement {
#data = [];
#dragging = null;
connectedCallback() {
this.attachShadow({ mode: 'open' });
this.#load();
}
async #load() {
try { this.#data = await api.get('/work-orders') ?? []; } catch { this.#data = []; }
this.#render();
}
#render() {
const s = this.shadowRoot;
const byStatus = Object.fromEntries(COLUMNS.map(c => [c.key, this.#data.filter(w => w.status === c.key)]));
s.innerHTML = `
<style>
:host { display: block; }
.board { display: grid; grid-template-columns: repeat(6, minmax(220px, 1fr)); gap: .75rem; overflow-x: auto; padding-bottom: .5rem; }
.col { background: var(--surface-2); border-radius: var(--radius); min-height: 400px; display: flex; flex-direction: column; }
.col-header { padding: .75rem 1rem; display: flex; align-items: center; gap: .5rem; border-bottom: 1px solid var(--border); }
.col-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.col-label { font-size: .813rem; font-weight: 700; color: var(--text); }
.col-count { margin-left: auto; font-size: .75rem; font-weight: 600; color: var(--text-muted); background: var(--surface); border: 1px solid var(--border); border-radius: 999px; padding: .1rem .45rem; }
.col-body { flex: 1; padding: .5rem; display: flex; flex-direction: column; gap: .5rem; overflow-y: auto; max-height: calc(100vh - 220px); }
.wo-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: .75rem; cursor: pointer; transition: box-shadow .15s, opacity .15s; }
.wo-card:hover { box-shadow: var(--shadow-md); }
.wo-card[draggable] { cursor: grab; }
.wo-card.dragging { opacity: .4; }
.drop-zone { border: 2px dashed var(--teal); border-radius: var(--radius-sm); min-height: 60px; background: rgba(10,126,164,.05); }
.wo-num { font-size: .7rem; font-family: monospace; color: var(--text-muted); margin-bottom: .2rem; }
.wo-title { font-size: .813rem; font-weight: 600; color: var(--text); line-height: 1.4; margin-bottom: .4rem; }
.wo-site { font-size: .75rem; color: var(--text-muted); margin-bottom: .4rem; }
.card-footer { display: flex; align-items: center; gap: .4rem; }
.pri-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
.pri-label { font-size: .7rem; color: var(--text-muted); flex: 1; }
.step-prog { font-size: .7rem; color: var(--text-muted); }
@media (max-width: 768px) { .board { grid-template-columns: repeat(6, 240px); } }
</style>
<div class="board">
${COLUMNS.map(col => `
<div class="col" data-status="${col.key}" id="col-${col.key}">
<div class="col-header">
<span class="col-dot" style="background:${col.color}"></span>
<span class="col-label">${col.label}</span>
<span class="col-count">${byStatus[col.key].length}</span>
</div>
<div class="col-body" data-status="${col.key}">
${byStatus[col.key].map(wo => `
<div class="wo-card" draggable="true" data-id="${wo.id}" data-status="${wo.status}">
<div class="wo-num">${wo.wo_number}</div>
<div class="wo-title">${this.#esc(wo.title)}</div>
${wo.site_name ? `<div class="wo-site">${this.#esc(wo.site_name)}</div>` : ''}
<div class="card-footer">
<span class="pri-dot" style="background:${PRI_COLOR[wo.priority]}"></span>
<span class="pri-label">${wo.priority}</span>
${wo.step_count ? `<span class="step-prog">${wo.steps_done}/${wo.step_count}</span>` : ''}
</div>
</div>`).join('')}
</div>
</div>`).join('')}
</div>`;
this.#bindDragDrop();
this.#bindClicks();
}
#bindClicks() {
this.shadowRoot.querySelectorAll('.wo-card').forEach(card => {
card.addEventListener('click', () =>
window.dispatchEvent(new CustomEvent('wo:navigate', { detail: { path: `/work-orders/${card.dataset.id}` } })));
});
}
#bindDragDrop() {
const s = this.shadowRoot;
s.querySelectorAll('.wo-card').forEach(card => {
card.addEventListener('dragstart', e => {
this.#dragging = card;
card.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
});
card.addEventListener('dragend', () => {
card.classList.remove('dragging');
this.#dragging = null;
});
});
s.querySelectorAll('.col-body').forEach(zone => {
zone.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; zone.classList.add('drop-zone'); });
zone.addEventListener('dragleave', () => zone.classList.remove('drop-zone'));
zone.addEventListener('drop', async e => {
e.preventDefault();
zone.classList.remove('drop-zone');
if (!this.#dragging) return;
const newStatus = zone.dataset.status;
const id = this.#dragging.dataset.id;
if (this.#dragging.dataset.status === newStatus) return;
try {
await api.put(`/work-orders/${id}/status`, { status: newStatus });
await this.#load();
} catch (err) {
window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: err.message, type: 'error' } }));
}
});
});
}
#esc(s) { return (s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
}
customElements.define('wo-kanban', WoKanban);