/* Loadpath — shared UI atoms. Exported to window for cross-file use. */ (function () { const { Icon } = window; const h = React.createElement; const { useState, useEffect, useRef } = React; function Btn({ kind = 'secondary', size, icon, iconRight, children, block, ...rest }) { const cls = ['btn', 'btn-' + kind, size ? 'btn-' + size : '', block ? 'btn-block' : ''] .filter(Boolean).join(' '); return h('button', { className: cls, ...rest }, icon && h(Icon, { name: icon, size: size === 'sm' ? 14 : 16 }), children, iconRight && h(Icon, { name: iconRight, size: size === 'sm' ? 14 : 16 })); } const STATUS = { PASS: { cls: 'badge-pass', icon: 'check', label: 'PASS' }, FAIL: { cls: 'badge-fail', icon: 'x', label: 'FAIL' }, EXEMPT: { cls: 'badge-exempt', icon: 'info', label: 'EXEMPT' }, WARN: { cls: 'badge-warn', icon: 'alert-triangle', label: 'FLAG' }, }; function Status({ s, label }) { const m = STATUS[s] || STATUS.PASS; return h('span', { className: 'badge badge-mono ' + m.cls }, h(Icon, { name: m.icon, size: 12 }), label || m.label); } const PROJ_STATUS = { delivered: { cls: 'badge-pass', label: 'Delivered', icon: 'check' }, in_progress: { cls: 'badge-accent', label: 'In progress', icon: 'clock' }, sistering: { cls: 'badge-warn', label: 'Sistering', icon: 'wrench' }, rejected: { cls: 'badge-fail', label: 'Rejected', icon: 'x' }, missing_info: { cls: 'badge-neutral', label: 'Needs info', icon: 'alert-triangle' }, draft: { cls: 'badge-neutral', label: 'Draft', icon: 'clock' }, }; function ProjStatus({ s }) { const m = PROJ_STATUS[s] || PROJ_STATUS.draft; return h('span', { className: 'badge ' + m.cls }, h(Icon, { name: m.icon, size: 12 }), m.label); } function Badge({ kind = 'neutral', icon, mono, children }) { return h('span', { className: 'badge badge-' + kind + (mono ? ' badge-mono' : '') }, icon && h(Icon, { name: icon, size: 12 }), children); } function ConfDot({ level }) { const cls = level === 'high' ? 'conf-high' : level === 'med' ? 'conf-med' : 'conf-low'; const label = level === 'high' ? 'High confidence' : level === 'med' ? 'Medium — verify' : 'Low — flagged'; return h('span', { className: 'conf-dot ' + cls, title: label }); } function SrcTag({ sheet }) { return h('span', { className: 'srctag' }, sheet); } // DCR bar with the 1.00 gate marker function DCRBar({ dcr, animate = true }) { const [w, setW] = React.useState(animate ? 0 : Math.min(dcr, 1.25)); React.useEffect(() => { if (!animate) return; const t = setTimeout(() => setW(Math.min(dcr, 1.25)), 60); return () => clearTimeout(t); }, [dcr, animate]); const over = dcr > 1.0; const warnish = dcr > 0.85 && dcr <= 1.0; const fillCls = over ? 'over' : warnish ? 'warnish' : 'ok'; // bar scaled so 1.0 sits at 80% of track (gate marker), allowing overflow display const pct = (w / 1.25) * 100; return h('div', { className: 'dcr-cell' }, h('div', { className: 'dcr-bar' }, h('div', { className: 'dcr-fill ' + fillCls, style: { width: pct + '%' } }), h('div', { className: 'gate', style: { left: '80%' } })), h('span', { className: 'dcr-num', style: { color: over ? 'var(--signal-danger)' : 'var(--fg)' } }, dcr.toFixed(3))); } function Callout({ kind = 'info', icon, title, children }) { const defIcon = { info: 'info', warn: 'alert-triangle', danger: 'alert-octagon', success: 'check-circle', accent: 'sparkles' }[kind]; return h('div', { className: 'callout callout-' + kind }, h(Icon, { name: icon || defIcon, size: 18 }), h('div', null, title && h('div', { className: 'ct-title' }, title), h('p', null, children))); } function KV({ k, children }) { return h('div', { className: 'kv' }, h('span', { className: 'k' }, k), h('span', { className: 'v' }, children)); } function Card({ title, eyebrow, right, tight, className, children }) { return h('div', { className: 'card' + (tight ? ' card-tight' : '') + (className ? ' ' + className : '') }, (title || eyebrow || right) && h('div', { className: 'card-head' }, h('div', null, eyebrow && h('div', { className: 'eyebrow', style: { marginBottom: title ? 4 : 0 } }, eyebrow), title && h('h3', null, title)), right), children); } function Money({ amount, size }) { if (amount == null) return h('span', { className: 'num' }, '—'); const [d, c] = amount.toFixed(2).split('.'); return h('span', { className: 'quote-amount' + (size ? ' ' + size : '') }, '$', d, h('span', { className: 'quote-cents' }, '.' + c)); } // ─── Address autocomplete (server-side proxy, no Maps JS needed) ───────────── function AddressInput({ value, onChange, onFill, placeholder, className, required, style }) { const [suggestions, setSuggestions] = useState([]); const [open, setOpen] = useState(false); const [busy, setBusy] = useState(false); const [active, setActive] = useState(-1); const timer = useRef(null); const wrap = useRef(null); useEffect(() => { function outside(e) { if (wrap.current && !wrap.current.contains(e.target)) setOpen(false); } document.addEventListener('mousedown', outside); return () => document.removeEventListener('mousedown', outside); }, []); function search(q) { clearTimeout(timer.current); if (!q || q.length < 2) { setSuggestions([]); setOpen(false); return; } setBusy(true); timer.current = setTimeout(async () => { try { const r = await fetch('/api/v1/places/autocomplete?q=' + encodeURIComponent(q)); const d = await r.json(); const list = (d.data && d.data.suggestions) || d.suggestions || []; setSuggestions(list); setOpen(list.length > 0); } catch (_) {} setBusy(false); }, 320); } async function pick(s) { setOpen(false); setSuggestions([]); onChange && onChange({ target: { value: s.description } }); try { const r = await fetch('/api/v1/places/details?place_id=' + encodeURIComponent(s.place_id)); const d = await r.json(); onFill && onFill(d.data || d); } catch (_) {} } function onKey(e) { if (!open) return; if (e.key === 'ArrowDown') { e.preventDefault(); setActive(i => Math.min(i + 1, suggestions.length - 1)); } else if (e.key === 'ArrowUp') { e.preventDefault(); setActive(i => Math.max(i - 1, 0)); } else if (e.key === 'Enter' && active >= 0) { e.preventDefault(); pick(suggestions[active]); } else if (e.key === 'Escape') { setOpen(false); } } return h('div', { ref: wrap, style: { position: 'relative', flex: 1 } }, h('input', { className: className || 'input', style, value: value || '', onChange: e => { onChange && onChange(e); search(e.target.value); }, onKeyDown: onKey, placeholder, required, autoComplete: 'off', spellCheck: false, }), busy && h('span', { style: { position: 'absolute', right: 10, top: '50%', transform: 'translateY(-50%)', pointerEvents: 'none' } }, h('span', { className: 'spin', style: { width: 14, height: 14 } })), open && suggestions.length > 0 && h('ul', { style: { position: 'absolute', top: 'calc(100% + 3px)', left: 0, right: 0, zIndex: 999, background: 'var(--surface, #fff)', border: '1px solid var(--border, #e4e7ee)', borderRadius: 8, boxShadow: '0 8px 28px rgba(0,0,0,.13)', padding: '4px 0', margin: 0, listStyle: 'none', } }, suggestions.map((s, i) => h('li', { key: s.place_id, onMouseDown: e => { e.preventDefault(); pick(s); }, onMouseEnter: () => setActive(i), style: { padding: '9px 14px', cursor: 'pointer', fontSize: 14, display: 'flex', gap: 8, alignItems: 'center', background: i === active ? 'var(--surface-2, #f5f7fa)' : 'transparent', color: 'var(--fg, #13171c)', }, }, h(Icon, { name: 'map-pin', size: 13, style: { color: 'var(--accent, #c77d1e)', flexShrink: 0, marginTop: 1 } }), h('span', null, h('span', { style: { fontWeight: 500 } }, s.main_text || s.description), s.secondary_text && h('span', { style: { color: 'var(--fg-subtle, #9ba3b2)', marginLeft: 5, fontSize: 12.5 } }, s.secondary_text)))))); } // ─── ProjectNameEditor ─────────────────────────────────────────────────────── // Inline edit: click pencil → name becomes an input field + ✓/✗ buttons. // No popup/portal needed — works inside any overflow container. function ProjectNameEditor({ projectId, name, onSaved, style, textStyle }) { const [editing, setEditing] = useState(false); const [value, setValue] = useState(name || ''); const [saving, setSaving] = useState(false); const inputRef = useRef(null); useEffect(() => { setValue(name || ''); }, [name]); useEffect(() => { if (editing && inputRef.current) { inputRef.current.focus(); inputRef.current.select(); } }, [editing]); const open = (e) => { e.stopPropagation(); setValue(name || ''); setEditing(true); }; const cancel = (e) => { if (e) e.stopPropagation(); setEditing(false); }; const save = async (e) => { if (e) e.stopPropagation(); const v = value.trim(); if (!v) return; setSaving(true); try { const updated = await window.API.projects.rename(projectId, v); const newName = (updated && (updated.name || (updated.project && updated.project.name))) || v; setEditing(false); if (onSaved) onSaved(newName); } catch (_) {} setSaving(false); }; const onKey = (e) => { e.stopPropagation(); if (e.key === 'Enter') save(e); if (e.key === 'Escape') cancel(e); }; if (editing) { return h('span', { style: Object.assign({ display: 'inline-flex', alignItems: 'center', gap: 4 }, style), onClick: (e) => e.stopPropagation() }, h('input', { ref: inputRef, value, onChange: (e) => setValue(e.target.value), onKeyDown: onKey, style: { padding: '2px 6px', fontSize: 'inherit', fontWeight: 'inherit', borderRadius: 4, border: '1px solid var(--accent, #c77d1e)', background: 'var(--surface, #fff)', color: 'var(--fg)', outline: 'none', minWidth: 120, maxWidth: 240, }, }), h('button', { onClick: save, disabled: saving, title: 'Save', style: { background: 'none', border: 'none', cursor: 'pointer', padding: '2px 3px', lineHeight: 0, color: 'var(--accent)' }, }, saving ? h('span', { className: 'spin', style: { width: 12, height: 12, borderWidth: 1.5 } }) : h(Icon, { name: 'check', size: 14 })), h('button', { onClick: cancel, title: 'Cancel', style: { background: 'none', border: 'none', cursor: 'pointer', padding: '2px 3px', lineHeight: 0, color: 'var(--fg-muted)' }, }, h(Icon, { name: 'x', size: 14 }))); } return h('span', { style: Object.assign({ display: 'inline-flex', alignItems: 'center', gap: 5 }, style) }, h('span', { style: textStyle }, name || 'New Project'), h('button', { onClick: open, title: 'Rename project', style: { background: 'none', border: 'none', padding: '2px 3px', cursor: 'pointer', lineHeight: 0, opacity: 0.45, borderRadius: 4, display: 'inline-flex', alignItems: 'center' }, }, h(Icon, { name: 'pencil', size: 12 }))); } // engineerTypeLabel maps the internal engineer_type letter (A/B/C — still // the matching key used everywhere in project data) to the human-readable // system name shown in the UI. Purely cosmetic. function engineerTypeLabel(t) { switch (String(t || '').trim().toUpperCase()) { case 'A': return 'Roof Mounted'; case 'B': return 'Ground Mount'; case 'C': return 'Battery'; default: return 'Engineer ' + t; } } Object.assign(window, { Btn, Status, ProjStatus, Badge, ConfDot, SrcTag, DCRBar, Callout, KV, Card, Money, AddressInput, ProjectNameEditor, engineerTypeLabel }); })();