HVAC Suite Rev 1HVAC engineering suite
engine Toolbox
`); w.document.close(); } function reportDOCX(rep, hi) { const x = s => esc(s == null ? '-' : s); const p = (t, b, sz) => `${b ? '' : ''}${sz ? `` : ''}${x(t)}`; const tbl = (cols, rows) => `${['top', 'left', 'bottom', 'right', 'insideH', 'insideV'].map(b => ``).join('')}` + [cols].concat(rows).map((r, i) => `${r.map(v => `${i ? '' : ''}${x(v)}`).join('')}`).join('') + ''; const imgs = []; const pic = (uri, rid, cx, cy, n) => { imgs.push([rid, 'media/' + rid + '.png', b64bytes(uri)]); return ``; }; const ownDim = () => { const m2 = /^data:image\/png;base64,/.test(hi.owner_logo || '') ? b64bytes(hi.owner_logo) : null; if (!m2 || m2.length < 24) return null; const w = (m2[16] << 24 | m2[17] << 16 | m2[18] << 8 | m2[19]) >>> 0, hh = (m2[20] << 24 | m2[21] << 16 | m2[22] << 8 | m2[23]) >>> 0; return w && hh ? [w, hh] : null; }; const od = ownDim(); const brand = od ? '' + pic(hi.owner_logo, 'rIdOwner', Math.round(Math.min(2400000, 640000 * od[0] / od[1])), Math.round(Math.min(2400000, 640000 * od[0] / od[1]) * od[1] / od[0]), 1) + '' + p(hi.company, true, 24) + p([hi.owner_address, hi.owner_email, hi.owner_phone].filter(Boolean).join(' · ')) : '' + pic(SA.brand_mark_png, 'rIdMark', 540000, 540000, 1) + ' ' + pic(SA.brand_logo_png, 'rIdWord', 2050000, 540000, 2) + ''; const img = hi.logo && hi.logo.startsWith('data:image/png') ? '' + pic(hi.logo, 'rIdLogo', 1600000, 800000, 3) + '' : ''; let body = brand + p(D.brand.product, true, 28) + img + p(rep.title, true, 32) + tbl(['Field', 'Value'], hdrRows(hi)); if (rep.inputs && rep.inputs.length) body += p('Inputs', true, 26) + tbl(['Input', 'Value', 'Unit'], rep.inputs); if (rep.summary && rep.summary.length) body += p('Results', true, 26) + tbl(['Result', 'Value', 'Unit'], rep.summary); (rep.tables || []).forEach(t => { body += p(t.title, true, 24) + tbl(t.columns, t.rows); }); (rep.warnings || []).forEach(w2 => { body += p('Warning: ' + w2); }); body += p(D.brand.export_line + ' · ' + hi.revision + ' · ' + hi.date); body += p(D.brand.legal); const doc = `${body}`; const files = [['[Content_Types].xml', ``], ['_rels/.rels', ``], ['word/_rels/document.xml.rels', `${imgs.map(i => ``).join('')}`], ['word/document.xml', doc]]; imgs.forEach(i => files.push(['word/' + i[1], i[2]])); return zip(files); } function reportXLSX(rep, hi) { const rows = [[D.brand.product + ' - ' + rep.title]].concat(hdrRows(hi), [[]]); if (rep.inputs && rep.inputs.length) rows.push(['Inputs'], ['Input', 'Value', 'Unit'], ...rep.inputs, []); if (rep.summary && rep.summary.length) rows.push(['Results'], ['Result', 'Value', 'Unit'], ...rep.summary, []); (rep.tables || []).forEach(t => rows.push([t.title], t.columns, ...t.rows, [])); (rep.warnings || []).forEach(w2 => rows.push(['Warning', w2])); rows.push([], [D.brand.export_line], [D.brand.legal]); const col = n => { let s = ''; n++; while (n) { const m = (n - 1) % 26; s = String.fromCharCode(65 + m) + s; n = (n - m - 1) / 26; } return s; }; const cell = (v, r, c) => { const ref = col(c) + (r + 1); if (typeof v === 'number' && isFinite(v)) return `${v}`; if (v == null || v === '') return ''; return `${esc(typeof v === 'boolean' ? (v ? 'TRUE' : 'FALSE') : v)}`; }; const sheet = `${rows.map((r, i) => `${r.map((v, j) => cell(v, i, j)).join('')}`).join('')}`; return zip([['[Content_Types].xml', ``], ['_rels/.rels', ``], ['xl/workbook.xml', ``], ['xl/_rels/workbook.xml.rels', ``], ['xl/worksheets/sheet1.xml', sheet]]); } function reportCSV(rep, hi) { const q = v => '"' + String(v == null ? '' : v).replace(/"/g, '""') + '"'; const rows = hdrRows(hi).concat([[]], (rep.inputs || []).map(r => ['Input'].concat(r)), (rep.summary || []).map(r => ['Result'].concat(r))); (rep.tables || []).forEach(t => rows.push([], [t.title], t.columns, ...t.rows)); return new Blob(['\ufeff' + rows.map(r => r.map(q).join(',')).join('\r\n')], { type: 'text/csv' }); } const fileStem = (rep, rev) => [(Ctx.get('project') || {}).number || 'FV', rep.id || 'report', rev.replace(/\s+/g, '')].join('_').replace(/[^A-Za-z0-9_.-]+/g, '-'); function exportDialog(rep) { const ov = h('div', { class: 'sa-modal' }); const close = () => ov.remove(); const miss = Ctx.missing(); const revSel = h('select', {}, REVS[S.rev_scheme].map(r => h('option', { value: r, selected: r === nextRev(rep.id) }, r))); const go = fmtx => { const rev = revSel.value, hi = headerInfo(rev), stem = fileStem(rep, rev); if (fmtx === 'pdf') reportPDF(rep, hi); else if (fmtx === 'docx') download(reportDOCX(rep, hi), stem + '.docx'); else if (fmtx === 'xlsx') download(reportXLSX(rep, hi), stem + '.xlsx'); else download(reportCSV(rep, hi), stem + '.csv'); S.revisions.unshift({ key: ctxKey(rep.id), module: rep.id, title: rep.title, rev, format: fmtx.toUpperCase(), project: hi.project, drawing: hi.drawing, engineer: hi.engineer, date: hi.date }); saAudit(`exported ${rep.title} as ${fmtx.toUpperCase()} (${rev})`); status.textContent = '✓ ' + fmtx.toUpperCase() + ' - ' + rev + ' - ' + stem; }; const status = h('p', { class: 'muted' }); ov.append(h('div', { class: 'sa-dlg card' }, h('h2', {}, 'Export · ' + rep.title), miss.length ? h('div', { class: 'flag warning' }, 'Project register incomplete: ' + miss.join(', ') + ' (export still allowed for super admin).') : h('div', { class: 'flag info' }, '✓ ' + Ctx.label(Ctx.get('project')) + ' · ' + Ctx.label(Ctx.get('drawing'))), h('p', {}, 'Revision ', revSel, ' ', h('span', { class: 'note' }, 'current: ' + (curRev(rep.id) || 'none'))), h('p', {}, h('button', { class: 'primary', onclick: () => go('pdf') }, 'PDF (print)'), ' ', h('button', { onclick: () => go('docx') }, 'Word .docx'), ' ', h('button', { onclick: () => go('xlsx') }, 'Excel .xlsx'), ' ', h('button', { onclick: () => go('csv') }, 'CSV')), window.FVOwner && !FVOwner.isSet() ? h('div', { class: 'flag warning' }, 'No owner profile yet - reports show the software brand instead of your company. ', h('a', { href: '#owner', onclick: close }, 'Set up the owner profile')) : null, h('p', { class: 'note' }, 'Every export carries your company (owner profile: logo, address, e-mail), the client logo, project, sub-project, area, plantroom, drawing no., engineer, date and revision. Built in the browser, offline.'), status, h('p', {}, h('button', { onclick: close }, 'Close')))); ov.addEventListener('click', e => { if (e.target === ov) close(); }); document.body.append(ov); } const objTables = (o, title) => { const t = []; const walk = (v, name) => { if (Array.isArray(v) && v.length && v.every(x => x && typeof x === 'object' && !Array.isArray(x))) { const cols = Object.keys(v[0]).filter(k => v.some(x => x[k] == null || typeof x[k] !== 'object')).slice(0, 12); t.push({ title: name, columns: cols, rows: v.map(x => cols.map(c => typeof x[c] === 'number' ? Math.round(x[c] * 1000) / 1000 : x[c])) }); } else if (v && typeof v === 'object' && !Array.isArray(v)) { const sc = Object.entries(v).filter(([, x]) => x == null || typeof x !== 'object'); if (sc.length) t.push({ title: name, columns: ['Item', 'Value'], rows: sc.map(([k, x]) => [k.replace(/_/g, ' '), typeof x === 'number' ? Math.round(x * 1000) / 1000 : x]) }); Object.entries(v).filter(([, x]) => x && typeof x === 'object').slice(0, 8).forEach(([k, x]) => walk(x, name + ' - ' + k.replace(/_/g, ' '))); } }; walk(o, title); return t.slice(0, 20); }; const PAGE_KEYS = { dashboard: ['summary', 'rooms'], site: ['site'], rooms: ['rooms'], loads: ['rooms', 'systems'], systems: ['systems'], ducts: ['duct_networks'], pipes: ['pipe_networks'], plant: ['plant'], energy: ['energy'], labour: ['labour'], estimate: ['estimate'], psychro: ['systems'] }; function exportFrom(kind, id, title) { if (kind === 'module') { const L = (FVD.ui.last || {}); if (!L.r || L.id !== id) { alert(T('btn.calculate') + ' first.'); return; } const inp = (FVD.ui.inputs || {})[id] || {}; const spec = ((FVD.cache.modules || {}).modules || []).find(x => x.id === id) || { fields: [] }; exportDialog({ id, title, summary: L.r.summary || [], tables: L.r.tables || [], warnings: L.r.warnings || [], inputs: spec.fields.filter(f => f.type !== 'table').map(f => [f.label, inp[f.key] == null ? '-' : (typeof inp[f.key] === 'object' ? JSON.stringify(inp[f.key]) : String(inp[f.key])), f.unit || '']) }); return; } const R = FVD.results || {}; const s = R.summary || {}; const tables = [].concat(...(PAGE_KEYS[id] || ['summary']).map(k => objTables(R[k], k.replace(/_/g, ' ')))); exportDialog({ id, title, summary: Object.entries(s).filter(([, v]) => typeof v === 'number').map(([k, v]) => [k.replace(/_/g, ' '), Math.round(v * 100) / 100, '']), tables, warnings: (R.flags || []).map(f => f.message).slice(0, 20) }); } window.FV_SA = { exportFrom, exportDialog, reportDOCX, reportXLSX, reportCSV, headerInfo, S }; /* ---------- admin console ---------- */ P.admin = m => { m.append(h('h1', {}, '🛡️ Super admin console'), h('p', { class: 'sub' }, 'Owner build for Cristian · all tiers unlocked · offline file (no server sign-in)'), h('p', { class: 'note' }, 'Settings are stored in this browser only. This offline file is not password-protected; the password-protected Super Admin runs on the deployed server.')); const counts = l => mods.filter(x => x.tier <= l).length, pgs = l => D.pages.filter(p => p.tier <= l).length; m.append(h('div', { class: 'kpis' }, [['Modules in nav', SA.all_names.length], ['Toolbox tools', mods.length], ['Live tools', mods.length - SA.coming.length], ['Coming next release', SA.coming.length], ['Plans unlocked', 'Essentials + Complete'], ['PlanScanFlow', S.released ? 'Released' : 'Beta (OFF)']] .map(([l, v]) => h('div', { class: 'kpi' }, h('div', { class: 'l' }, l), h('div', { class: 'v' }, String(v)))))); m.append(card('Tier / plan overview', h('div', { class: 'tbl-wrap' }, h('table', {}, h('tr', {}, ['Level', 'Plan', 'USD / month', 'ZAR / month (indicative)', 'USD / year', 'Seats', 'Tools', 'Pages', 'Price', 'Super admin'].map(c => h('th', {}, c))), D.tiers.map(t => h('tr', {}, h('td', {}, 'L' + t.level), h('td', {}, tierName(t.level)), h('td', { class: 'num' }, '$' + t.monthly_usd.toFixed(2)), h('td', { class: 'num' }, zar(t.monthly_usd)), h('td', { class: 'num' }, '$' + t.annual_usd.toFixed(2)), h('td', { class: 'num' }, t.seats), h('td', { class: 'num' }, counts(t.level)), h('td', { class: 'num' }, pgs(t.level)), h('td', {}, t.price_status), h('td', {}, '✓ unlocked'))))), h('p', { class: 'note' }, D.vat_note + ' Indicative ZAR at R ' + D.usd_zar_rate + ' / USD. List prices are editable under Super Admin → Parameters (Plan prices).'))); const users = SA.users.concat(Reg.data.engineers.filter(e => !SA.users.some(u => u.name === e.name)).map(e => ({ name: e.name, email: '-', role: 'Engineer', tier: 2, seat: 'Complete seat', status: 'active (register)' }))); m.append(card('Users & seats', h('div', { class: 'tbl-wrap' }, h('table', {}, h('tr', {}, ['User', 'E-mail', 'Role', 'Plan', 'Seat', 'Status'].map(c => h('th', {}, c))), users.map(u => h('tr', {}, h('td', {}, u.name), h('td', { class: 'ltr' }, u.email), h('td', {}, u.role), h('td', {}, 'L' + u.tier + ' ' + tierName(u.tier)), h('td', {}, u.seat), h('td', {}, u.status))))), h('p', { class: 'note' }, 'Seats are illustrative (Expert includes 3 seats).'))); m.append(releaseCard(), queueCard(), revisionCard()); m.append(card('Admin audit trail', h('div', { class: 'steps' }, S.audit.join('\n') || '-'))); const gp = h('div', {}); m.append(h('h2', { id: 'saGlobal' }, 'Global parameters (this browser)'), gp); if (window.FVSuperAdmin) FVSuperAdmin.page(gp).then(() => { const t = gp.querySelector('h1'); if (t) t.remove(); }).catch(e => gp.append(h('div', { class: 'flag error' }, e.message))); }; P.superadmin = m => P.admin(m); function releaseCard() { const lbl = h('b', {}); const sw = h('input', { type: 'checkbox', class: 'sa-switch', id: 'saRelease', checked: S.released, onchange: e => { const on = e.target.checked; if (on && !confirm('Release PlanScanFlow as "validated & released"? This removes the BETA badge in this build.')) { e.target.checked = false; return; } S.released = on; S.released_by = on ? 'Cristian' : ''; S.released_at = on ? nowStr() : ''; saAudit('PlanScanFlow "validated & released" switched ' + (on ? 'ON' : 'OFF')); paint(); nav(); } }); const paint = () => { lbl.textContent = S.released ? 'ON - validated & released by ' + S.released_by + ' (' + S.released_at + ')' : 'OFF - BETA (default)'; lbl.className = S.released ? 'ok-txt' : 'warn-txt'; }; paint(); return card('PlanScanFlow · "validated & released" switch', h('label', { class: 'sa-switch-row' }, sw, ' ', lbl), h('p', { class: 'note' }, 'Admin-only switch.')); } function queueCard() { const it = D.ingest.items, n = s => it.filter(x => x.status === s).length; return card('PlanScanFlow validation queue', h('div', { class: 'tbl-wrap' }, h('table', {}, h('tr', {}, ['Drawing', 'Engine', 'Items', 'Pending', 'Low confidence (<60 %)', 'Status', ''].map(c => h('th', {}, c))), h('tr', {}, h('td', {}, D.ingest.filename), h('td', {}, D.ingest.engine), h('td', { class: 'num' }, it.length), h('td', { class: 'num' }, n('pending')), h('td', { class: 'num' }, it.filter(x => x.confidence < 0.6).length), h('td', {}, S.released ? 'released' : 'beta - review'), h('td', {}, h('a', { class: 'btn small', href: '#ingest' }, 'Validate ›')))))); } function revisionCard() { const sch = h('select', { onchange: e => { S.rev_scheme = e.target.value; saveS(); saAudit('revision scheme set to ' + e.target.value); } }, h('option', { value: 'numeric', selected: S.rev_scheme === 'numeric' }, 'Rev 0, 1, 2 ...'), h('option', { value: 'alpha', selected: S.rev_scheme === 'alpha' }, 'Rev A, B, C ...')); return card('Revision control', h('p', {}, 'Revision scheme ', sch, ' ', h('button', { class: 'small', onclick: () => { if (confirm('Clear the revision log?')) { S.revisions = []; saAudit('cleared revision log'); render(); } } }, 'Clear log')), S.revisions.length ? h('div', { class: 'tbl-wrap' }, h('table', {}, h('tr', {}, ['Date', 'Module', 'Revision', 'Format', 'Project', 'Drawing', 'Engineer'].map(c => h('th', {}, c))), S.revisions.slice(0, 50).map(r => h('tr', {}, h('td', {}, r.date), h('td', {}, r.title), h('td', {}, r.rev), h('td', {}, r.format), h('td', {}, r.project), h('td', {}, r.drawing), h('td', {}, r.engineer))))) : h('p', { class: 'muted' }, 'No revisions yet - every export (PDF / Word / Excel / CSV) is logged here with its revision.')); } P.owner = m => FVOwner.page(m); P.revisions = m => { m.append(h('h1', {}, 'Revision control'), revisionCard()); }; /* ---------------- PlanScanFlow: real in-browser drawing import + manual validation ---------------- */ const IG = { docs: [], sel: -1, busy: '', filter: '' }; try { (JSON.parse(localStorage.getItem('fv.ingest.docs')) || []).forEach(d => IG.docs.push(d)); } catch (e) { } const igSave = () => { try { localStorage.setItem('fv.ingest.docs', JSON.stringify(IG.docs.map(d => Object.assign({}, d, { previews: [] })))); } catch (e) { } }; const igSaved = () => { try { return JSON.parse(localStorage.getItem('fv.ingest.saved')) || []; } catch (e) { return []; } }; P.ingest = m => { const title = modLabel(D.ext.ingest); m.append(h('h1', {}, title), h('p', { class: 'sub' }, 'Import a drawing (DXF, DWG, PDF vector or scanned, JPG / PNG / HEIC / TIFF photo or a live camera shot), read the text in the browser, then validate every item before it reaches the project.'), S.released ? h('div', { class: 'flag info' }, 'Released by ' + (S.released_by || 'admin') + '. Every extracted item still needs manual validation.') : h('div', { class: 'flag warning' }, 'BETA - visible to admins only until released in the Admin console. Every extracted item needs manual validation.')); const status = h('p', { class: 'note', id: 'igStatus' }, IG.busy || ''); const run = async file => { if (!file) return; IG.busy = 'Reading ' + file.name + '…'; status.textContent = IG.busy; try { const r = await FVIngest.extract(file, t => { IG.busy = t; const s = document.getElementById('igStatus'); if (s) s.textContent = file.name + ': ' + t; }); r.id = Date.now(); r.when = new Date().toISOString().slice(0, 16).replace('T', ' '); r.audit = [r.when + ' imported by ' + ((Ctx.engineer() || {}).name || 'admin')]; IG.docs.unshift(r); IG.sel = 0; IG.busy = ''; igSave(); render(); } catch (e) { console.warn(e); IG.busy = ''; const s = document.getElementById('igStatus'); if (s) { s.className = 'flag error'; s.textContent = file.name + ': ' + ((e && e.message) || String(e)); } } }; const inp = h('input', { type: 'file', accept: FVIngest.ACCEPT, id: 'igFile', style: 'display:none', onchange: e => run(e.target.files[0]) }); const drop = h('div', { class: 'ig-drop', ondragover: e => { e.preventDefault(); drop.classList.add('over'); }, ondragleave: () => drop.classList.remove('over'), ondrop: e => { e.preventDefault(); drop.classList.remove('over'); run(e.dataTransfer.files[0]); } }, h('b', {}, 'Drop a drawing here'), h('span', { class: 'muted' }, 'DXF · DWG · PDF (vector or scanned) · JPG · PNG · HEIC · TIFF'), h('div', { class: 'ig-btns' }, h('button', { class: 'primary', onclick: () => inp.click() }, 'Choose file'), h('button', { onclick: () => FVIngest.camera(run) }, '📷 Camera')), inp); m.append(card('Import', drop, status)); if (IG.docs.length) { const t = h('table', {}, h('tr', {}, ['File', 'Type', 'Reader', 'Pages', 'Items', 'Pending', 'Validated', 'Pushed', 'Time s', ''].map(c => h('th', {}, c)))); IG.docs.forEach((d, i) => { const n = s => d.items.filter(x => x.status === s).length; t.append(h('tr', { class: i === IG.sel ? 'sel' : '' }, h('td', {}, d.filename), h('td', {}, d.kind), h('td', {}, d.engine), h('td', { class: 'num' }, d.pages), h('td', { class: 'num' }, d.items.length), h('td', { class: 'num' }, n('pending')), h('td', { class: 'num' }, n('accepted') + n('edited')), h('td', { class: 'num' }, n('pushed')), h('td', { class: 'num' }, d.seconds), h('td', { class: 'nowrap' }, h('button', { class: 'small', onclick: () => { IG.sel = i; render(); } }, 'Open'), ' ', h('button', { class: 'small', onclick: () => { if (confirm('Remove ' + d.filename + ' from the queue?')) { IG.docs.splice(i, 1); IG.sel = -1; igSave(); render(); } } }, '✕')))); }); m.append(card('Queue', h('div', { class: 'tbl-wrap' }, t))); } const d = IG.docs[IG.sel]; if (!d) return; (d.notes || []).forEach(n => m.append(h('div', { class: 'flag info' }, n))); let page = 1; const st = d.items; let sel = -1; const stage = h('div', { class: 'ing-stage' }), ov = h('div', { class: 'ing-ov' }); const pv = (d.previews || [])[page - 1]; stage.append(pv ? h('img', { class: 'ing-img', src: pv, alt: 'preview' }) : h('p', { class: 'muted' }, 'Preview not kept after reload - re-import to see it.'), ov); const tbl = h('table', { class: 'ing-items' }), summ = h('p', { class: 'note' }), log = h('div', { class: 'steps' }); const filt = h('select', { onchange: () => { IG.filter = filt.value; draw(); } }, ['', 'pending', 'accepted', 'edited', 'rejected', 'pushed'].map(s => h('option', { value: s, selected: s === IG.filter }, s || 'all'))); const cat = h('select', { onchange: () => draw() }, [['', 'all categories'], ['title_block', 'Title block'], ['room', 'Rooms'], ['equipment', 'Equipment'], ['duct_size', 'Duct sizes'], ['pipe_size', 'Pipe sizes']].map(([v, l]) => h('option', { value: v }, l))); const eng = () => Ctx.engineer(); const act = (it, s, v) => { if (!eng()) { alert('Select the validating engineer in the project context first.'); return; } it.status = s; if (v != null) it.value = v; d.audit.unshift(new Date().toLocaleTimeString() + ' ' + eng().name + ': ' + it.field + ' = "' + it.value + '" -> ' + s); igSave(); draw(); }; const confCls = c => c >= 0.8 ? 'hi' : (c >= 0.6 ? 'mid' : 'lo'); function draw() { ov.innerHTML = ''; tbl.innerHTML = ''; st.forEach((it, i) => { if ((it.page || 1) !== page) return; const [x0, y0, x1, y1] = it.bbox; ov.append(h('div', { class: 'bb ' + it.status + (i === sel ? ' sel' : ''), title: it.field + ': ' + it.value, style: `left:${x0 * 100}%;top:${y0 * 100}%;width:${Math.max(x1 - x0, 0.004) * 100}%;height:${Math.max(y1 - y0, 0.004) * 100}%`, onclick: () => { sel = i; draw(); } })); }); tbl.append(h('tr', {}, ['Category', 'Field', 'Value', 'Detail', 'Confidence', 'State', ''].map(c => h('th', {}, c)))); st.forEach((it, i) => { if ((IG.filter && it.status !== IG.filter) || (cat.value && it.category !== cat.value)) return; const valIn = h('input', { class: 'ing-val', value: it.value }); const dd = it.data || {}; const det = dd.area_m2 != null ? dd.area_m2 + ' m²' + (dd.area_source ? ' (' + dd.area_source + ')' : '') : dd.duties ? dd.duties.map(x => x.value + ' ' + x.unit).join(', ') : dd.service || dd.via || ''; tbl.append(h('tr', { class: i === sel ? 'sel' : '', onclick: () => { if (sel !== i) { sel = i; draw(); } } }, h('td', {}, it.category.replace('_', ' ')), h('td', {}, it.field), h('td', {}, valIn), h('td', { class: 'muted' }, det), h('td', {}, h('span', { class: 'conf ' + confCls(it.confidence) }, Math.round(it.confidence * 100) + ' %')), h('td', {}, h('span', { class: 'pill st-' + it.status }, it.status)), h('td', { class: 'nowrap' }, h('button', { class: 'small ok', title: 'Accept', onclick: e => { e.stopPropagation(); act(it, valIn.value !== it.value ? 'edited' : 'accepted', valIn.value); } }, '✓'), h('button', { class: 'small', title: 'Reject', onclick: e => { e.stopPropagation(); act(it, 'rejected'); } }, '✕')))); }); const n = s => st.filter(x => x.status === s).length; summ.textContent = `${st.length} items · ${n('pending')} pending · ${n('accepted') + n('edited')} validated · ${n('rejected')} rejected · ${n('pushed')} pushed`; log.textContent = (d.audit || []).join('\n') || '-'; } const validated = cats => st.filter(x => (x.status === 'accepted' || x.status === 'edited') && cats.includes(x.category)); const push = async target => { if (!eng()) { alert('Select the validating engineer in the project context first.'); return; } try { let msg = ''; if (target === 'drawing') { const tb = {}; validated(['title_block']).forEach(x => { tb[x.field] = x.value; }); if (!tb.drawing_no) throw Error('validate the drawing number first'); const pr = Ctx.get('plantroom'); if (!pr) throw Error('select Client > Project > Sub-project > Area > Plantroom in the project context first'); const ex = Reg.data.drawings.find(x => x.plantroom_id === pr.id && String(x.number).toUpperCase() === tb.drawing_no.toUpperCase()); const f = { number: tb.drawing_no, title: tb.title || '', revision: tb.revision || '', scale: tb.scale || '', date: tb.date || '', notes: 'from ' + d.filename + (S.released ? '' : ' (BETA extraction)') + ', validated by ' + eng().name }; if (ex) Object.assign(ex, f); else Reg.data.drawings.push(Object.assign({ id: Math.max(0, ...Reg.data.drawings.map(x => x.id)) + 1, plantroom_id: pr.id }, f)); Reg.save(); validated(['title_block']).forEach(x => { x.status = 'pushed'; }); msg = 'Drawing ' + tb.drawing_no + (ex ? ' updated' : ' added') + ' in the register'; if (typeof renderCtx === 'function') renderCtx(); } else if (target === 'rooms') { const rs = validated(['room']); if (!rs.length) throw Error('no validated rooms'); await FVD.boot(); const p = FVD.project, def = (FVD.meta && FVD.meta.defaults && FVD.meta.defaults.room) || {}; rs.forEach(x => { const r = JSON.parse(JSON.stringify(def)); r.id = 'room_' + Math.random().toString(36).slice(2, 8); r.name = x.value; if (x.data && x.data.area_m2 != null) r.floor_area_m2 = x.data.area_m2; r.system_id = (p.systems[0] || {}).id || ''; r.notes = 'from drawing ' + d.filename; p.rooms.push(r); x.status = 'pushed'; }); FVD.touch(); msg = rs.length + ' room(s) added to the project - see CoolRooms'; } else { const cats = target === 'equipment' ? ['equipment'] : ['duct_size', 'pipe_size']; const sel2 = validated(cats); if (!sel2.length) throw Error('no validated ' + target + ' items'); const sv = igSaved(); sv.unshift({ when: new Date().toISOString().slice(0, 16).replace('T', ' '), file: d.filename, target, by: eng().name, beta: !S.released, context: Ctx.ids, items: sel2.map(x => ({ category: x.category, field: x.field, value: x.value, data: x.data })) }); localStorage.setItem('fv.ingest.saved', JSON.stringify(sv.slice(0, 50))); sel2.forEach(x => { x.status = 'pushed'; }); msg = sel2.length + ' ' + target + ' item(s) saved to the project context'; } d.audit.unshift(new Date().toLocaleTimeString() + ' ' + eng().name + ': ' + msg); igSave(); toast(msg); draw(); } catch (e) { toast(e.message, true); alert(e.message); } }; const pages = h('span', {}, d.pages > 1 ? ['Page ', h('select', { onchange: e => { page = +e.target.value; const im = stage.querySelector('img'); if (im && d.previews[page - 1]) im.src = d.previews[page - 1]; draw(); } }, Array.from({ length: d.pages }, (_, k) => h('option', { value: k + 1 }, k + 1)))] : ''); m.append(h('div', { class: 'ing-split' }, card(d.filename + ' · ' + d.kind + ' · ' + d.engine, pages, stage), card('Extracted items', h('p', {}, filt, ' ', cat, ' ', h('button', { onclick: () => { if (!eng()) { alert('Select the validating engineer in the project context first.'); return; } st.filter(x => x.status === 'pending' && x.confidence >= 0.8).forEach(x => { x.status = 'accepted'; }); d.audit.unshift(new Date().toLocaleTimeString() + ' ' + eng().name + ': accepted all pending items >= 80 % confidence'); igSave(); draw(); } }, 'Accept all ≥ 80 %')), summ, h('div', { class: 'tbl-wrap' }, tbl), h('p', {}, 'Push validated items: ', ['drawing', 'rooms', 'equipment', 'sizes'].map(t2 => [h('button', { class: 'small' + (t2 === 'rooms' ? ' primary' : ''), onclick: () => push(t2) }, t2 === 'drawing' ? 'Title block → register' : t2 === 'rooms' ? 'Rooms → project' : t2 === 'equipment' ? 'Equipment → project' : 'Duct / pipe sizes → project'), ' '])), h('p', { class: 'note' }, 'Only validated (accepted or edited) items are pushed. Nothing is pushed automatically.')))); m.append(card('Audit trail', log)); draw(); }; P.reports = m => { m.append(h('h1', {}, pageName(D.pages.find(p => p.id === 'reports'))), h('p', { class: 'sub' }, 'Company-branded exports with your owner profile, client logo, date, revision and project chain.')); m.append(card('Issued by', h('p', {}, window.FVOwner && FVOwner.isSet() ? FVOwner.line() : 'No owner profile yet.', ' ', h('a', { class: 'btn small', href: '#owner' }, window.FVOwner && FVOwner.isSet() ? 'Edit owner profile' : 'Set up owner profile')))); const pages = D.pages.filter(p => PAGE_KEYS[p.id]); m.append(card('Project report', h('p', {}, h('button', { class: 'primary', onclick: async () => { await FVD.boot(); exportFrom('page', 'dashboard', 'Project summary - ' + (FVD.project.info.name || '')); } }, '⤓ Export project summary')), h('div', { class: 'toolgrid' }, pages.map(p => h('div', { class: 'tool', onclick: async () => { await FVD.boot(); exportFrom('page', p.id, pageName(p)); } }, h('b', {}, p.product || p.name), h('div', { class: 'tsub' }, '⤓ PDF / Word / Excel / CSV')))), h('p', {}, h('button', { onclick: async () => { await FVD.boot(); FVD.save(); } }, 'Project file (.frescoventi.json)')), h('p', { class: 'note' }, 'Every tool also has an Export button above its inputs.'))); }; const banner = () => { let b = document.getElementById('engBanner'); if (FVPY.state !== 'error' && FVPY.state !== 'slow') { if (b) b.remove(); return; } if (!b) { b = h('div', { id: 'engBanner', class: 'eng-banner', role: 'alert' }); document.body.prepend(b); } b.innerHTML = ''; b.append(h('b', {}, FVPY.state === 'slow' ? '⏳ The calculation engine is taking long to start. ' : '⚠ The calculation engine could not start. '), FVPY.error + '. ', h('span', {}, 'The ' + WORKING.length + ' quick calculators (▶ JS) still work. For everything else open this file directly from disk in a current Chrome, Edge, Firefox or Safari (not inside an e-mail / chat preview).'), FVPY.state === 'error' ? h('button', { class: 'small', onclick: () => location.reload() }, 'Retry') : null, h('button', { class: 'small', onclick: () => b.remove() }, '✕')); }; FVPY.onChange(banner); FVPY.onChange(st => { const c = document.getElementById('engChip'); if (c) { c.textContent = st === 'ready' ? '✓ engine' : st === 'loading' ? '⏳ engine…' : '⚠ engine'; c.title = FVPY.error || ''; c.className = 'eng-chip' + (st === 'error' ? ' err' : ''); } }); setTimeout(() => { FVPY.ready().then(() => FVD.boot()).catch(e => console.warn(e)); }, 300); /* ---------------- Equipment catalogues (EquipCatalog) ---------------- */ P.catalogue = m => { const App = FVD; App.meta = App.meta || {}; App.meta.extensions = Object.assign({}, App.meta.extensions || {}, { catalogue_ingest: { enabled: true } }); App.meta.module_names = Object.assign({}, App.meta.module_names || {}, { 'new.catalogue_ingest': (D.ext && D.ext.catalogue) || { product: 'EquipCatalog', subtitle: 'Equipment catalogues' } }); if (typeof Pages !== 'undefined' && Pages.catalogue) { Promise.resolve(Pages.catalogue(m, App)).then(() => I18N.apply(m)).catch(e => m.append(h('div', { class: 'flag error' }, e.message))); return; } m.append(h('h1', {}, 'EquipCatalog'), h('p', { class: 'sub' }, T('catlg.intro') || '')); }; P.manufacturers = P.catalogue; /* ---------- Jurisdiction & data law (Settings) ---------- Two separate things: the PROJECT engineering profile follows the project's site country (same field as the site location selector); ORGANISATION privacy settings (where the business is set up, whose data, where hosted) are kept on this computer only (localStorage 'fv.org.privacy') and never sent anywhere. Informational - not legal advice. */ const JST = { label: { implemented: 'Suite checks implemented', reference_profile: 'Reference profile - no compliance assessment', unsupported: 'Unsupported - jurisdiction review needed' } }; const PRIV_KEY = 'fv.org.privacy'; const linkList = srcs => h('ul', { class: 'src-list' }, (srcs || []).map(s => h('li', {}, h('a', { href: s.url, target: '_blank', rel: 'noopener' }, s.title || s.url), s.checked ? h('span', { class: 'muted' }, ' · checked ' + s.checked) : null))); P.jurisdiction = async m => { m.append(h('h1', {}, 'Region, codes & data law'), h('p', { class: 'note' }, 'Informational only - not legal advice. Have a lawyer confirm before relying on it. The suite never assumes South African rules for another country and never claims compliance where no check exists.')); try { await FVD.boot(); } catch (e) { m.append(h('div', { class: 'flag error' }, 'Engine not ready: ' + e.message)); return; } if (window.FVLocalize) FVLocalize.engineeringCard(m, { title: '1 · Region, country codes and currency' }); const priv = h('div'); m.append(priv); let R, L; try { R = await api('/api/jurisdictions'); L = await api('/api/localization'); } catch (e) { priv.append(h('div', { class: 'flag error' }, e.message)); return; } const countries = Object.entries((L && L.countries) || R.countries).map(([c, v]) => [c, v.name || c]).sort((a, b) => a[1].localeCompare(b[1].name || b[1])); countries.sort((a, b) => a[1].localeCompare(b[1])); const PRIV_KEY = 'fv.org.privacy'; const linkList = srcs => h('ul', { class: 'src-list' }, (srcs || []).map(s => h('li', {}, h('a', { href: s.url, target: '_blank', rel: 'noopener' }, s.title || s.url), s.checked ? h('span', { class: 'muted' }, ' · checked ' + s.checked) : null))); let O; try { O = JSON.parse(localStorage.getItem(PRIV_KEY)) || {}; } catch (e) { O = {}; } const multi = (key, label) => h('label', { class: 'jmulti' }, label, h('select', { multiple: true, size: 6, id: 'jp_' + key, onchange: e => { O[key] = [...e.target.selectedOptions].map(o => o.value); save(); } }, countries.map(([c, n]) => h('option', { value: c, selected: (O[key] || []).includes(c) }, n)))); const chk = (key, label) => h('label', {}, h('input', { type: 'checkbox', checked: (O.purposes || []).includes(key), onchange: e => { O.purposes = (O.purposes || []).filter(x => x !== key).concat(e.target.checked ? [key] : []); save(); } }), ' ' + label); const out = h('div'); let seq = 0; const save = async () => { localStorage.setItem(PRIV_KEY, JSON.stringify(O)); const my = ++seq; const r = await api('/api/jurisdiction/privacy', O); if (my !== seq) return; out.innerHTML = ''; out.append(...r.notes.map(n => h('div', { class: 'flag warning' }, n)), r.laws.length ? h('div', { class: 'tbl-wrap' }, h('table', {}, h('tr', {}, h('th', {}, 'Law'), h('th', {}, 'Result'), h('th', {}, 'Why'), h('th', {}, 'In force / status'), h('th', {}, 'Key points'), h('th', {}, 'Sources')), r.laws.map(l => h('tr', {}, h('td', {}, l.name), h('td', {}, (r.levels || {})[l.level] ? h('span', { title: r.levels[l.level] }, l.level === 'candidate' ? 'Candidate' : 'Candidate - review') : l.level), h('td', {}, l.trigger), h('td', {}, l.in_force + ' · ' + l.status), h('td', {}, h('ul', {}, l.key_points.map(k => h('li', {}, k)))), h('td', {}, linkList(l.sources)))))) : h('p', { class: 'muted' }, 'Select where data subjects are to see which laws may apply.'), h('p', { class: 'note' }, r.disclaimer)); }; priv.append(h('div', { class: 'card' }, h('h2', {}, '2 · Organisation data protection (privacy law)'), h('p', { class: 'muted' }, 'Separate from the project. Privacy law follows where the business is set up, whose personal data it holds, where it is stored and why - not where the building is. Several laws can be candidates together. Results are candidates for review - not a legal determination, not exhaustive, not a compliance statement. Stored only on this computer.'), h('div', { class: 'row' }, multi('establishment', 'Business established / carried on in (leave empty if not yet specified) '), multi('data_subjects', ' People whose data is held are in '), multi('hosting', ' Data hosted / processed in ')), h('div', { class: 'row' }, h('label', {}, 'UAE free zone ', h('select', { onchange: e => { O.establishment_zones = e.target.value ? [e.target.value] : []; save(); } }, [['', 'none'], ['AE:DIFC', 'DIFC (Dubai)'], ['AE:ADGM', 'ADGM (Abu Dhabi)']].map(([v, t]) => h('option', { value: v, selected: ((O.establishment_zones || [])[0] || '') === v }, t)))), h('label', {}, ' US states (e.g. CA) ', h('input', { value: (O.us_states || []).join(','), onchange: e => { O.us_states = e.target.value.split(/[ ,]+/).filter(Boolean); save(); } }))), h('div', { class: 'row' }, h('span', { class: 'muted' }, 'California (CCPA) - ANY one of: '), [['revenue_over', 'gross revenue above $26,625,000 (2025 CPI figure)'], ['consumers_100k', 'buy/sell/share PI of 100,000+ consumers or households'], ['revenue_50pct', '50%+ revenue from selling/sharing PI']].map(([k, t]) => h('label', {}, ' ' + t + ' ', h('select', { id: 'jc_' + k, onchange: e => { O.ccpa = Object.assign({}, O.ccpa, { [k]: { y: true, n: false }[e.target.value] ?? null }); save(); } }, [['', 'unknown'], ['y', 'yes'], ['n', 'no']].map(([v, tt]) => h('option', { value: v, selected: v === ((O.ccpa || {})[k] === true ? 'y' : (O.ccpa || {})[k] === false ? 'n' : '') }, tt)))))), h('div', { class: 'row' }, h('label', {}, h('input', { type: 'checkbox', id: 'jOffers', checked: O.offers_services !== false, onchange: e => { O.offers_services = e.target.checked; save(); } }), ' We offer goods/services to these people'), h('label', {}, h('input', { type: 'checkbox', id: 'jMonitors', checked: !!O.monitors, onchange: e => { O.monitors = e.target.checked; save(); } }), ' We monitor their behaviour (tracking, profiling, analytics)')), h('div', { class: 'row' }, chk('hosting_for_clients', 'We store client project data for them'), chk('benchmarking', 'We use client data for our own trends / benchmarking')), out)); save(); }; /* ---------------- router ---------------- */ function render() { const main = $('#main'); main.innerHTML = ''; const hs = (location.hash || '#home').slice(1); let [pg, arg] = hs.split('/').length > 1 ? [hs.split('/')[0], hs.split('/').slice(1).join('/')] : [hs, null]; (P[pg] || P.dashboard)(main, arg); nav(); if (window.FVOwner) FVOwner.chrome(); if (window.FVClimate) FVClimate.chip(); I18N.apply(document); document.body.classList.remove('nav-open'); window.scrollTo(0, 0); } async function init() { Reg.load(); Ctx.load(); await I18N.init(); $('#langSlot').append(I18N.selector()); I18N.onChange(() => { renderCtx(); render(); }); $('#btnMenu').onclick = e => { e.stopPropagation(); document.body.classList.toggle('nav-open'); }; $('#main').addEventListener('click', () => document.body.classList.remove('nav-open')); window.addEventListener('hashchange', render); window.addEventListener('resize', () => document.documentElement.style.setProperty('--ctxh', $('#ctxbar').offsetHeight + 'px')); renderCtx(); render(); } window.FV_APP = { render, Ctx, Reg }; document.addEventListener('DOMContentLoaded', init); })();