import { api } from '../../lib/api.mjs'; import { formatDateTime, formatDate } from '../../lib/format.mjs'; import './wo-checklist.mjs'; import './wo-resource-panel.mjs'; import './wo-photo-panel.mjs'; import './wo-map.mjs'; import './wo-accounting.mjs'; import './wo-timeline.mjs'; const TABS = ['Overview', 'Checklist', 'Resources', 'Photos', 'Accounting', 'Activity']; const STATUS_TRANSITIONS = { draft: ['assigned','scheduled'], assigned: ['scheduled','in_progress'], scheduled: ['in_progress'], in_progress: ['pending_review','closed'], pending_review: ['in_progress','closed'], closed: [], }; class WoDetail extends HTMLElement { #woId = null; #wo = null; #tab = 'Overview'; #loading = true; static get observedAttributes() { return ['wo-id']; } attributeChangedCallback(_, __, val) { this.#woId = val ? +val : null; if (this.shadowRoot) this.#load(); } connectedCallback() { this.attachShadow({ mode: 'open' }); if (this.#woId) this.#load(); } async #load() { this.#loading = true; this.#render(); try { this.#wo = await api.get(`/work-orders/${this.#woId}`); } catch { this.#wo = null; } this.#loading = false; this.#render(); } #render() { const s = this.shadowRoot; s.innerHTML = `${this.#html()}`; this.#bind(); if (window.lucide) lucide.createIcons({ root: s }); } #css() { return ` :host { display: block; } .page-header { display:flex; align-items:center; gap:1rem; margin-bottom:1.25rem; flex-wrap:wrap; } .back-btn { background:none; border:1px solid var(--border); color:var(--text); border-radius:var(--radius); padding:.4rem .75rem; font-size:.813rem; font-weight:500; cursor:pointer; display:flex; align-items:center; gap:.3rem; transition:background .15s; flex-shrink:0; } .back-btn:hover { background:var(--surface-2); } .header-info { flex:1; min-width:0; } .wo-number { font-family:monospace; font-size:.813rem; color:var(--text-muted); margin-bottom:.15rem; } h1 { font-size:1.2rem; font-weight:700; color:var(--text); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .header-badges { display:flex; align-items:center; gap:.5rem; margin-top:.35rem; flex-wrap:wrap; } .header-actions { display:flex; gap:.5rem; flex-shrink:0; } .btn { display:inline-flex; align-items:center; gap:.35rem; padding:.45rem .9rem; border:none; border-radius:var(--radius); font-size:.813rem; font-weight:600; cursor:pointer; transition:opacity .15s,background .15s; white-space:nowrap; } .btn-primary { background:var(--teal); color:#fff; } .btn-primary:hover { opacity:.88; } .btn-ghost { background:transparent; border:1px solid var(--border); color:var(--text); } .btn-ghost:hover { background:var(--surface-2); } .meta-strip { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); padding:.9rem 1.25rem; margin-bottom:1rem; display:flex; gap:2rem; flex-wrap:wrap; box-shadow:var(--shadow-sm); } .meta-item { display:flex; flex-direction:column; gap:.15rem; } .meta-label { font-size:.7rem; font-weight:700; color:var(--text-muted); text-transform:uppercase; letter-spacing:.06em; } .meta-value { font-size:.875rem; color:var(--text); font-weight:500; } .tab-bar { display:flex; gap:0; border-bottom:2px solid var(--border); margin-bottom:1rem; overflow-x:auto; } .tab { padding:.65rem 1rem; font-size:.875rem; font-weight:500; color:var(--text-muted); border:none; background:none; cursor:pointer; border-bottom:2px solid transparent; margin-bottom:-2px; white-space:nowrap; transition:color .15s,border-color .15s; } .tab.active { color:var(--teal); border-bottom-color:var(--teal); font-weight:600; } .tab:hover:not(.active) { color:var(--text); } .tab-content { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); padding:1.25rem; box-shadow:var(--shadow-sm); } .detail-grid { display:grid; grid-template-columns:1fr 1fr; gap:1.25rem; } .detail-field { display:flex; flex-direction:column; gap:.25rem; } .field-label { font-size:.7rem; font-weight:700; color:var(--text-muted); text-transform:uppercase; letter-spacing:.06em; } .field-value { font-size:.875rem; color:var(--text); line-height:1.5; } .field-value.empty { color:var(--text-muted); font-style:italic; } .text-block { background:var(--surface-2); border-radius:var(--radius-sm); padding:.75rem 1rem; font-size:.875rem; line-height:1.6; color:var(--text); white-space:pre-wrap; } .status-menu { position:relative; } .status-dropdown { position:absolute; top:calc(100% + .25rem); right:0; background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); box-shadow:var(--shadow-md); z-index:50; min-width:160px; overflow:hidden; } .status-opt { display:flex; align-items:center; gap:.5rem; padding:.6rem 1rem; font-size:.875rem; cursor:pointer; border:none; background:none; width:100%; text-align:left; transition:background .15s; color:var(--text); } .status-opt:hover { background:var(--surface-2); } .status-dot { width:8px; height:8px; border-radius:50%; flex-shrink:0; } @media (max-width:768px) { .detail-grid { grid-template-columns:1fr; } .meta-strip { gap:1rem; } } .ap-dialog { border:1px solid var(--border); border-radius:var(--radius-lg); padding:0; box-shadow:var(--shadow-lg); width:460px; max-width:96vw; background:var(--surface); color:var(--text); } .ap-dialog::backdrop { background:rgba(0,0,0,.45); } .ap-header { display:flex; align-items:center; justify-content:space-between; padding:1rem 1.25rem; border-bottom:1px solid var(--border); } .ap-title { font-size:.938rem; font-weight:700; } .ap-close { background:none; border:none; cursor:pointer; color:var(--text-muted); padding:.25rem; display:flex; } .ap-body { padding:1.1rem 1.25rem; display:flex; flex-direction:column; gap:.75rem; } .ap-search { width:100%; border:1px solid var(--border); border-radius:var(--radius-sm); padding:.5rem .75rem; font-size:.875rem; background:var(--surface); color:var(--text); box-sizing:border-box; } .ap-search:focus { outline:none; border-color:var(--teal); box-shadow:0 0 0 3px rgba(10,126,164,.12); } .ap-list { max-height:240px; overflow-y:auto; display:flex; flex-direction:column; gap:.3rem; border:1px solid var(--border); border-radius:var(--radius-sm); padding:.35rem; background:var(--surface); } .ap-item { padding:.55rem .75rem; border-radius:var(--radius-sm); cursor:pointer; display:flex; align-items:center; gap:.6rem; transition:background .12s; border:2px solid transparent; } .ap-item:hover { background:var(--surface-2); } .ap-item.selected { border-color:var(--teal); background:rgba(10,126,164,.06); } .ap-item-name { font-size:.875rem; font-weight:500; color:var(--text); } .ap-item-meta { font-size:.75rem; color:var(--text-muted); } .ap-mode { display:flex; flex-direction:column; gap:.4rem; } .ap-mode-label { font-size:.813rem; font-weight:600; color:var(--text); } .ap-mode-opts { display:flex; gap:.5rem; } .ap-mode-opt { flex:1; border:2px solid var(--border); border-radius:var(--radius-sm); padding:.6rem .75rem; cursor:pointer; background:var(--surface); transition:border-color .12s,background .12s; } .ap-mode-opt.selected { border-color:var(--teal); background:rgba(10,126,164,.06); } .ap-mode-opt-title { font-size:.813rem; font-weight:600; color:var(--text); } .ap-mode-opt-desc { font-size:.75rem; color:var(--text-muted); margin-top:.15rem; } .ap-footer { padding:.875rem 1.25rem; border-top:1px solid var(--border); display:flex; justify-content:flex-end; gap:.5rem; align-items:center; } .ap-err { font-size:.813rem; color:var(--danger); flex:1; } .ap-empty { text-align:center; padding:1.5rem; color:var(--text-muted); font-size:.875rem; } `; } #html() { if (this.#loading) return `
`; if (!this.#wo) return ` `; const wo = this.#wo; const transitions = STATUS_TRANSITIONS[wo.status] || []; const STATUS_COLORS = { draft:'var(--status-draft)', assigned:'var(--status-assigned)', scheduled:'var(--status-scheduled)', in_progress:'var(--status-in_progress)', pending_review:'var(--status-pending_review)', closed:'var(--status-closed)' }; return `
Site ${this.#esc(wo.site_name) || '—'}
Scheduled Start ${formatDateTime(wo.scheduled_start)}
Scheduled End ${formatDateTime(wo.scheduled_end)}
Created By ${this.#esc(wo.created_by) || '—'}
Created ${formatDate(wo.created_at)}
${wo.parent_type ? `
Parent ${wo.parent_type} #${wo.parent_id}
` : ''}
${TABS.map(t => ``).join('')}
${this.#tabContent(wo)}
Apply Work Order Profile
Loading profiles…
`; } #tabContent(wo) { switch (this.#tab) { case 'Overview': return `
Description
${wo.description ? `
${this.#esc(wo.description)}
` : '
No description provided
'}
Instructions
${wo.instructions ? `
${this.#esc(wo.instructions)}
` : '
No instructions provided
'}
Address
${this.#esc(wo.address) || 'Not set'}
Access Notes
${wo.access_notes ? `
${this.#esc(wo.access_notes)}
` : '
No access notes
'}
${wo.lat && wo.lng ? `
Location
` : ''}
`; case 'Checklist': return ``; case 'Resources': return ``; case 'Photos': return ``; case 'Accounting': return ``; case 'Activity': return ``; default: return ''; } } #bind() { const s = this.shadowRoot; s.querySelector('#back')?.addEventListener('click', () => window.dispatchEvent(new CustomEvent('wo:navigate', { detail: { path: '/work-orders' } }))); s.querySelector('#edit-btn')?.addEventListener('click', () => window.dispatchEvent(new CustomEvent('wo:navigate', { detail: { path: `/work-orders/${this.#woId}/edit` } }))); // Tab switching s.querySelectorAll('.tab').forEach(tab => tab.addEventListener('click', () => { this.#tab = tab.dataset.tab; s.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab === this.#tab)); s.querySelector('#tab-content').innerHTML = this.#tabContent(this.#wo); if (window.lucide) lucide.createIcons({ root: s.querySelector('#tab-content') }); })); // Status dropdown const statusBtn = s.querySelector('#status-btn'); const dropdown = s.querySelector('#status-dropdown'); statusBtn?.addEventListener('click', e => { e.stopPropagation(); dropdown.style.display = dropdown.style.display === 'none' ? 'block' : 'none'; }); document.addEventListener('click', () => { if (dropdown) dropdown.style.display = 'none'; }, { once: false }); s.querySelectorAll('.status-opt').forEach(opt => opt.addEventListener('click', async () => { dropdown.style.display = 'none'; try { await api.put(`/work-orders/${this.#woId}/status`, { status: opt.dataset.status }); window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: `Status changed to ${opt.dataset.status.replace('_',' ')}`, type: 'success' } })); this.#load(); } catch (err) { window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: err.message, type: 'error' } })); } })); // Apply Profile dialog this.#bindApplyProfile(s); } #bindApplyProfile(s) { const apBtn = s.querySelector('#apply-profile-btn'); const dialog = s.querySelector('#ap-dialog'); const closeBtn = s.querySelector('#ap-close'); const cancelBtn = s.querySelector('#ap-cancel'); const confirmBtn = s.querySelector('#ap-confirm'); const searchEl = s.querySelector('#ap-search'); const listEl = s.querySelector('#ap-profile-list'); const modeEl = s.querySelector('#ap-mode'); const errEl = s.querySelector('#ap-err'); if (!apBtn) return; let allProfiles = []; let selectedId = null; let selectedMode = 'append'; const renderList = (items) => { if (items.length === 0) { listEl.innerHTML = '
No profiles found
'; return; } listEl.innerHTML = items.map(p => `
${this.#esc(p.name)}
${p.category ? `${p.category} · ` : ''}${p.step_count} step${p.step_count !== 1 ? 's' : ''} · ${p.default_priority} priority
`).join(''); listEl.querySelectorAll('.ap-item').forEach(item => { item.addEventListener('click', () => { selectedId = +item.dataset.id; listEl.querySelectorAll('.ap-item').forEach(i => i.classList.toggle('selected', +i.dataset.id === selectedId)); confirmBtn.disabled = false; }); }); }; apBtn.addEventListener('click', async () => { selectedId = null; selectedMode = 'append'; confirmBtn.disabled = true; errEl.textContent = ''; searchEl.value = ''; // Show/hide mode selector based on whether WO already has steps try { const steps = await api.get(`/work-orders/${this.#woId}/steps`) || []; modeEl.style.display = steps.length > 0 ? '' : 'none'; } catch { modeEl.style.display = 'none'; } // Select append by default modeEl.querySelectorAll('.ap-mode-opt').forEach(o => o.classList.toggle('selected', o.dataset.mode === 'append')); listEl.innerHTML = '
Loading…
'; dialog.showModal(); try { allProfiles = await api.get('/profiles') || []; renderList(allProfiles); } catch (err) { listEl.innerHTML = `
${err.message}
`; } }); searchEl.addEventListener('input', () => { const q = searchEl.value.toLowerCase(); renderList(q ? allProfiles.filter(p => p.name.toLowerCase().includes(q) || (p.category || '').toLowerCase().includes(q)) : allProfiles); }); modeEl.querySelectorAll('.ap-mode-opt').forEach(opt => { opt.addEventListener('click', () => { selectedMode = opt.dataset.mode; modeEl.querySelectorAll('.ap-mode-opt').forEach(o => o.classList.toggle('selected', o.dataset.mode === selectedMode)); }); }); closeBtn.addEventListener('click', () => dialog.close()); cancelBtn.addEventListener('click', () => dialog.close()); confirmBtn.addEventListener('click', async () => { if (!selectedId) return; confirmBtn.disabled = true; confirmBtn.textContent = 'Applying…'; errEl.textContent = ''; try { const result = await api.post(`/work-orders/${this.#woId}/apply-profile/${selectedId}`, { mode: selectedMode }); dialog.close(); window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: `Profile applied — ${result.steps_added} step${result.steps_added !== 1 ? 's' : ''} ${selectedMode === 'replace' ? 'loaded' : 'added'}`, type: 'success' }})); // Reload WO and switch to Checklist tab this.#tab = 'Checklist'; this.#load(); } catch (err) { errEl.textContent = err.message; confirmBtn.disabled = false; confirmBtn.textContent = 'Apply Profile'; } }); } #esc(s) { return (s || '').replace(/&/g,'&').replace(//g,'>'); } } customElements.define('wo-detail', WoDetail);