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

161 lines
6.6 KiB
JavaScript

import { api } from '../../lib/api.mjs';
const ACTION_ICONS = {
status_change: 'refresh-cw',
step_complete: 'check-square',
step_uncomplete: 'square',
resource_assigned: 'user-plus',
resource_removed: 'user-minus',
photo_upload: 'camera',
accounting_update: 'receipt',
created: 'plus-circle',
updated: 'edit-3',
};
const ACTION_LABELS = {
status_change: 'Status changed',
step_complete: 'Step completed',
step_uncomplete: 'Step reopened',
resource_assigned: 'Resource assigned',
resource_removed: 'Resource removed',
photo_upload: 'Photo uploaded',
accounting_update: 'Accounting updated',
created: 'Work order created',
updated: 'Work order updated',
};
class WoTimeline extends HTMLElement {
#woId = null;
#entries = [];
#loading = true;
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 { this.#entries = await api.get(`/work-orders/${this.#woId}/activity`) || []; }
catch { this.#entries = []; }
this.#loading = false;
this.#render();
}
#relativeTime(dateStr) {
const d = new Date(dateStr);
const diff = Date.now() - d.getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
if (days < 7) return `${days}d ago`;
return d.toLocaleDateString();
}
#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; }
.timeline { display: flex; flex-direction: column; gap: 0; }
.entry { display: flex; gap: 1rem; padding: .875rem 0; position: relative; }
.entry:not(:last-child)::before {
content: '';
position: absolute;
left: 19px;
top: 44px;
bottom: 0;
width: 2px;
background: var(--border);
}
.entry-icon { width: 38px; height: 38px; border-radius: 50%; background: var(--surface-2); border: 2px solid var(--border); display: flex; align-items: center; justify-content: center; flex-shrink: 0; color: var(--text-muted); z-index: 1; }
.entry-icon.status { background: #E0F2FE; border-color: var(--teal); color: var(--teal); }
.entry-icon.step { background: #DCFCE7; border-color: var(--success); color: var(--success); }
.entry-icon.photo { background: var(--surface-2); border-color: var(--text-muted); }
.entry-body { flex: 1; min-width: 0; padding-top: .4rem; }
.entry-action { font-size: .875rem; font-weight: 600; color: var(--text); }
.entry-detail { font-size: .813rem; color: var(--text-muted); margin-top: .2rem; line-height: 1.4; }
.entry-meta { display: flex; align-items: center; gap: .5rem; margin-top: .3rem; font-size: .75rem; color: var(--text-muted); }
.entry-user { font-weight: 600; color: var(--text); }
.status-change { display: inline-flex; align-items: center; gap: .4rem; font-size: .813rem; }
.status-arrow { color: var(--text-muted); }
.status-badge { display: inline-flex; align-items: center; gap: .3rem; padding: .15rem .5rem; border-radius: 999px; font-size: .7rem; font-weight: 700; text-transform: uppercase; background: var(--surface-2); }
.empty { text-align: center; padding: 3rem 1rem; color: var(--text-muted); }
.empty p { font-size: .875rem; margin-top: .5rem; }
.refresh-btn { display: inline-flex; align-items: center; gap: .35rem; margin-top: 1rem; background: none; border: 1px solid var(--border); color: var(--text-muted); border-radius: var(--radius); padding: .4rem .75rem; font-size: .813rem; cursor: pointer; }
.refresh-btn:hover { background: var(--surface-2); }
</style>
${this.#entries.length === 0 ? `
<div class="empty">
<i data-lucide="activity" style="width:32px;height:32px;opacity:.3;display:block;margin:0 auto"></i>
<p>No activity recorded yet</p>
</div>` : `
<div class="timeline">
${this.#entries.map(e => {
const icon = ACTION_ICONS[e.action] || 'info';
const label = ACTION_LABELS[e.action] || e.action.replace(/_/g,' ');
const cls = e.action.includes('status') ? 'status' : e.action.includes('step') ? 'step' : e.action.includes('photo') ? 'photo' : '';
return `
<div class="entry">
<div class="entry-icon ${cls}">
<i data-lucide="${icon}" style="width:16px;height:16px"></i>
</div>
<div class="entry-body">
<div class="entry-action">${this.#esc(label)}</div>
${this.#detailHTML(e)}
<div class="entry-meta">
<span class="entry-user">${this.#esc(e.performed_by)}</span>
<span>·</span>
<span title="${new Date(e.performed_at).toLocaleString()}">${this.#relativeTime(e.performed_at)}</span>
</div>
</div>
</div>`;
}).join('')}
</div>`}
<button class="refresh-btn" id="refresh-btn">
<i data-lucide="rotate-cw" style="width:13px;height:13px"></i> Refresh
</button>`;
if (window.lucide) lucide.createIcons({ nodes: [s] });
s.querySelector('#refresh-btn').addEventListener('click', () => this.#load());
}
#detailHTML(e) {
if (e.action === 'status_change' && e.old_value && e.new_value) {
return `<div class="entry-detail">
<span class="status-change">
<span class="status-badge">${this.#esc(e.old_value.replace('_',' '))}</span>
<span class="status-arrow">→</span>
<span class="status-badge" style="background:var(--surface-2)">${this.#esc(e.new_value.replace('_',' '))}</span>
</span>
</div>`;
}
if (e.new_value) {
return `<div class="entry-detail">${this.#esc(e.new_value)}</div>`;
}
return '';
}
#esc(s) { return (s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
}
customElements.define('wo-timeline', WoTimeline);