. * Idempotent: safe to load multiple times (uses window flag). */ (function(){ if (window.__WWS_PATCH_V2_LOADED__) return; window.__WWS_PATCH_V2_LOADED__ = true; // ────────────────────────────────────────────────────────────── // 1. MOBILE CSS (≤600px) // ────────────────────────────────────────────────────────────── var css = ` /* WH&S MOBILE PATCH v2026.04.29 — full scroll rewrite */ /* All ancestors had height:100vh/100% and overflow:hidden, creating a nested-scroll trap that hid content behind the fixed bottom-nav. On mobile, undo every fixed height and let the body scroll naturally. */ @media (max-width: 768px) { /* Everything in the layout chain becomes flow content */ html, body, .app, #app { height: auto !important; min-height: 100vh !important; max-height: none !important; overflow-x: hidden !important; overflow-y: visible !important; -webkit-overflow-scrolling: touch !important; } .body, .main-wrap, .main-content, .container, .panel, .two-col { height: auto !important; max-height: none !important; overflow: visible !important; display: block !important; } /* Topbar stays at top, scrolls with page (not sticky on mobile to save space) */ .topbar, .main-header { position: relative !important; flex-shrink: 0 !important; } /* Sidebar collapses by default on mobile, only shown when toggled */ .sidebar { display: none !important; } .sidebar.open, .sidebar.show, .sidebar.active { display: flex !important; position: fixed !important; top: 0; left: 0; right: 0; bottom: 0; z-index: 9000 !important; overflow-y: auto !important; height: 100vh !important; } /* Constrain content width so it doesn't bleed off the right side */ .main-content, .container, .panel, .proj-detail-head { width: 100% !important; max-width: 100% !important; box-sizing: border-box !important; padding-left: 14px !important; padding-right: 14px !important; } /* Bottom nav: stays fixed, takes ~70px. Content gets enough bottom padding so the last item isn't hidden behind it. */ .mobile-nav, .bottom-nav, .mob-nav { position: fixed !important; bottom: 0; left: 0; right: 0; z-index: 100 !important; height: 70px !important; } /* Page-level bottom padding to clear the fixed bottom nav */ .main-content { padding-bottom: 110px !important; } /* Inside grids/tables stop forcing horizontal layouts */ .two-col, .proj-detail-head, [style*="grid-template-columns"] { display: block !important; grid-template-columns: 1fr !important; } /* Tables that go off-screen — let them scroll horizontally on their own */ table, .table, .all-projects-table { display: block !important; overflow-x: auto !important; max-width: 100% !important; } /* Sticky save bars at bottom of forms — keep above nav */ .form-sticky-foot { position: sticky; bottom: 60px; background: var(--bg, #060A12); padding: 10px 0; border-top: 1px solid rgba(255,255,255,0.08); margin-top: 12px; z-index: 50; } } @media (max-width: 600px) { .topbar { padding: 8px 12px !important; flex-wrap: wrap !important; gap: 8px !important; } .top-sector, .top-status { display: none !important; } .top-clock { font-size: 0.5rem !important; padding: 4px 8px !important; } .top-logo-wws { font-size: 1.1rem !important; } .body { display: block !important; } .sidebar { display: none !important; } .main-wrap { width: 100% !important; padding-bottom: 70px !important; } .main-header { padding: 12px !important; flex-wrap: wrap !important; } .main-title { font-size: 1.1rem !important; } .main-content { padding: 12px !important; } .mobile-nav { display: flex !important; } /* KPI cards stack 2x2 */ .main-content > div[style*="grid-template-columns:repeat(4"] { grid-template-columns: 1fr 1fr !important; } .main-content > div[style*="grid-template-columns:1fr 1fr 340px"] { grid-template-columns: 1fr !important; } .main-content > div[style*="grid-template-columns:1fr 1fr"] { grid-template-columns: 1fr !important; } .kpi-card { padding: 10px !important; } .kpi-value { font-size: 1.4rem !important; } .kpi-label { font-size: 0.4rem !important; } .kpi-sub { font-size: 0.42rem !important; } /* Tables become card-like on mobile */ .main-content table { font-size: 0.7rem !important; } .main-content table th, .main-content table td { padding: 6px 4px !important; } /* Buttons larger touch targets */ .btn { min-height: 40px !important; padding: 8px 14px !important; } /* Input fields */ .field-input, input, select, textarea { font-size: 16px !important; min-height: 44px !important; } } /* Mobile nav - bottom bar always there but hidden on desktop */ .mobile-nav { display: none; position: fixed; bottom: 0; left: 0; right: 0; height: 60px; background: var(--bg, #060A12); border-top: 1px solid var(--bd, #1f2533); z-index: 100; padding: 0 4px; } .mobile-nav-item { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 4px; cursor: pointer; color: var(--dim, #5a6378); font-family: var(--fm, monospace); } .mobile-nav-item.active { color: var(--g, #00ff88); } .mobile-nav-ico { font-size: 1.2rem; line-height: 1; margin-bottom: 2px; } .mobile-nav-label { font-size: 0.4rem; letter-spacing: 1px; text-transform: uppercase; } /* WH&S BRAND TOKENS for new components */ :root { --wws-navy: #1B3A4B; --wws-gold: #C8962E; --wws-cream: #FFF8E7; --wws-success: #2F6B3F; --wws-danger: #8C1D1D; } /* Mailman modal */ .mm-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.78); display: none; align-items: center; justify-content: center; z-index: 9999; padding: 20px; } .mm-overlay.show { display: flex; } .mm-modal { background: var(--bg, #060A12); border: 1px solid var(--wws-gold); border-radius: 6px; max-width: 720px; width: 100%; max-height: 90vh; overflow: hidden; display: flex; flex-direction: column; box-shadow: 0 10px 60px rgba(200,150,46,0.2); } .mm-head { background: var(--wws-navy); color: var(--wws-gold); padding: 14px 18px; border-bottom: 2px solid var(--wws-gold); display: flex; align-items: center; gap: 10px; } .mm-head-title { font-family: var(--fh, sans-serif); letter-spacing: 2px; font-size: 0.95rem; flex: 1; } .mm-head-x { background: none; border: 1px solid var(--wws-gold); color: var(--wws-gold); width: 28px; height: 28px; cursor: pointer; font-size: 1rem; } .mm-body { padding: 16px 18px; overflow-y: auto; flex: 1; color: var(--c, #d8dde4); } .mm-row { margin-bottom: 12px; } .mm-label { font-family: var(--fm, monospace); font-size: 0.5rem; letter-spacing: 2px; color: var(--dim, #5a6378); text-transform: uppercase; margin-bottom: 4px; } .mm-value { background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08); padding: 8px 10px; font-family: var(--fb, sans-serif); color: var(--c, #d8dde4); font-size: 0.85rem; } .mm-textarea { width: 100%; min-height: 140px; background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08); padding: 10px; color: var(--c, #d8dde4); font-family: var(--fb, sans-serif); font-size: 0.85rem; line-height: 1.5; resize: vertical; } .mm-attach { padding: 10px; background: rgba(200,150,46,0.06); border: 1px dashed var(--wws-gold); margin-bottom: 8px; } .mm-attach-item { display: flex; align-items: center; gap: 8px; padding: 6px 0; font-size: 0.8rem; color: var(--c, #d8dde4); } .mm-foot { padding: 12px 18px; border-top: 1px solid rgba(255,255,255,0.08); display: flex; gap: 8px; justify-content: flex-end; } .mm-btn { padding: 9px 16px; border: 1px solid var(--wws-gold); background: transparent; color: var(--wws-gold); cursor: pointer; font-family: var(--fm, monospace); letter-spacing: 1.5px; text-transform: uppercase; font-size: 0.6rem; } .mm-btn.primary { background: var(--wws-gold); color: var(--wws-navy); font-weight: 700; } .mm-btn:hover { opacity: 0.85; } .mm-warn { color: var(--wws-gold); font-size: 0.75rem; padding: 8px 10px; background: rgba(200,150,46,0.06); border-left: 3px solid var(--wws-gold); margin-bottom: 12px; } /* Clause picker */ .cl-card { background: rgba(255,255,255,0.02); border: 1px solid rgba(255,255,255,0.06); padding: 10px; margin-bottom: 6px; border-radius: 3px; cursor: pointer; transition: border 0.1s; } .cl-card:hover { border-color: var(--wws-gold); } .cl-card.checked { border-color: var(--wws-gold); background: rgba(200,150,46,0.08); } .cl-card-head { display: flex; align-items: center; gap: 8px; } .cl-cb { width: 18px; height: 18px; flex-shrink: 0; } .cl-id { font-family: var(--fm, monospace); font-size: 0.55rem; color: var(--wws-gold); letter-spacing: 1.5px; } .cl-title { flex: 1; font-weight: 600; color: var(--c, #d8dde4); font-size: 0.85rem; } .cl-sev { font-family: var(--fm, monospace); font-size: 0.5rem; padding: 2px 6px; border-radius: 2px; letter-spacing: 1px; } .cl-sev.s1 { background: rgba(0,255,136,0.1); color: #00ff88; } .cl-sev.s2 { background: rgba(255,184,0,0.1); color: #ffb800; } .cl-sev.s3 { background: rgba(140,29,29,0.15); color: #ff6666; } .cl-summary { font-size: 0.75rem; color: var(--dim, #aab1bd); margin-top: 4px; line-height: 1.4; padding-left: 26px; } .cl-reason { font-size: 0.65rem; color: var(--wws-gold); margin-top: 2px; padding-left: 26px; font-style: italic; } /* Line items editor */ .li-table { width: 100%; border-collapse: collapse; margin-bottom: 10px; } .li-table th { text-align: left; font-family: var(--fm, monospace); font-size: 0.5rem; letter-spacing: 2px; color: var(--dim, #5a6378); padding: 6px 4px; border-bottom: 1px solid rgba(255,255,255,0.08); } .li-table td { padding: 4px; } .li-table input { width: 100%; background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08); padding: 8px; color: var(--c, #d8dde4); font-size: 0.85rem; } .li-table input[type=number] { text-align: right; } .li-amt { text-align: right; font-weight: 600; padding: 8px; color: var(--g, #00ff88); } .li-add { font-family: var(--fm, monospace); font-size: 0.6rem; letter-spacing: 1.5px; color: var(--wws-gold); cursor: pointer; padding: 6px 10px; border: 1px dashed var(--wws-gold); text-align: center; margin-top: 4px; } .li-rm { background: none; border: 1px solid var(--wws-danger); color: var(--wws-danger); cursor: pointer; padding: 4px 8px; font-size: 0.7rem; } /* Status badges (Square-style pills) */ .st { display: inline-block; padding: 3px 8px; border-radius: 10px; font-family: var(--fm, monospace); font-size: 0.5rem; letter-spacing: 1.5px; text-transform: uppercase; } .st.draft { background: rgba(170,177,189,0.15); color: #aab1bd; } .st.sent { background: rgba(0,180,255,0.15); color: #00b4ff; } .st.viewed { background: rgba(255,184,0,0.15); color: #ffb800; } .st.unpaid { background: rgba(140,29,29,0.15); color: #ff6666; } .st.paid { background: rgba(0,255,136,0.15); color: #00ff88; } .st.overdue { background: rgba(140,29,29,0.25); color: #ff6666; font-weight: 700; } .st.cancelled { background: rgba(170,177,189,0.1); color: #5a6378; text-decoration: line-through; } .st.signed { background: rgba(47,107,63,0.2); color: #6cd47e; } `; var styleEl = document.createElement('style'); styleEl.id = 'wws-patch-v2'; styleEl.textContent = css; document.head.appendChild(styleEl); // ────────────────────────────────────────────────────────────── // 2. apiV2 — REST helper for new endpoints // ────────────────────────────────────────────────────────────── window.apiV2 = async function(method, path, body) { var token = (window.S && window.S.token) || localStorage.getItem('wws_token') || ''; var init = { method: method, headers: { 'Authorization': 'Bearer ' + token } }; if (body) { init.headers['Content-Type'] = 'application/json'; init.body = JSON.stringify(body); } try { var r = await fetch('/api/' + path, init); var ct = r.headers.get('content-type') || ''; if (ct.indexOf('application/pdf') >= 0 || ct.indexOf('application/octet-stream') >= 0) { return { ok: r.ok, _status: r.status, _blob: await r.blob() }; } var text = await r.text(); try { var data = JSON.parse(text); data._status = r.status; return data; } catch (e) { return { ok: false, error: text.substring(0, 200), _status: r.status }; } } catch (e) { return { ok: false, error: 'Network error: ' + e.message, _status: 0 }; } }; // ────────────────────────────────────────────────────────────── // 3. PDF download helper // ────────────────────────────────────────────────────────────── window.downloadPDF = async function(kind, id) { var btn = event && event.target; var orig = btn ? btn.textContent : ''; if (btn) { btn.textContent = 'GENERATING...'; btn.disabled = true; } try { var r = await apiV2('GET', kind + '/' + id + '/pdf'); if (!r.ok || !r._blob) { alert('PDF generation failed: ' + (r.error || 'unknown error')); return; } var url = URL.createObjectURL(r._blob); var a = document.createElement('a'); a.href = url; a.download = 'WWS-' + kind + '-' + id + '.pdf'; document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(function(){ URL.revokeObjectURL(url); }, 5000); } finally { if (btn) { btn.textContent = orig; btn.disabled = false; } } }; // ────────────────────────────────────────────────────────────── // 4. MAILMAN POPUP — "this will email the following" // ────────────────────────────────────────────────────────────── function ensureMailmanOverlay() { if (document.getElementById('mm-overlay')) return; var ov = document.createElement('div'); ov.id = 'mm-overlay'; ov.className = 'mm-overlay'; ov.innerHTML = `
`; document.body.appendChild(ov); } window.closeMailman = function() { var ov = document.getElementById('mm-overlay'); if (ov) ov.classList.remove('show'); window.__mm_state__ = null; }; window.openMailman = function(opts) { // opts = { to, template, vars, attachments?, subject?, body? } ensureMailmanOverlay(); window.__mm_state__ = opts; var b = document.getElementById('mm-body'); b.innerHTML = '
'; document.getElementById('mm-overlay').classList.add('show'); apiV2('POST', 'mail/preview', { template: opts.template, vars: opts.vars || {} }).then(function(r) { if (!r.ok) { b.innerHTML = '
'; return; } var p = r.preview; var atts = (opts.attachments || []).map(function(a) { return '
'; }).join(''); b.innerHTML = `
${atts ? '
' : ''}
`; }); }; window.sendMailman = async function() { var s = window.__mm_state__; if (!s) return; var btn = event ? event.target : null; var orig = btn ? btn.textContent : ''; if (btn) { btn.textContent = 'SENDING...'; btn.disabled = true; } var attachments = (s.attachments || []).slice(); // Add any user-uploaded file var fileInput = document.getElementById('mm-upload'); if (fileInput && fileInput.files && fileInput.files[0]) { var f = fileInput.files[0]; var b64 = await new Promise(function(resolve) { var fr = new FileReader(); fr.onload = function() { var dataUrl = fr.result; resolve(dataUrl.split(',')[1]); }; fr.readAsDataURL(f); }); attachments.push({ filename: f.name, content: b64, contentType: f.type }); } // Edit user changes var bodyEdit = document.getElementById('mm-body-edit'); var vars = Object.assign({}, s.vars); if (bodyEdit && s.template === 'custom') { vars.text = bodyEdit.value; vars.body = bodyEdit.value; } var r = await apiV2('POST', 'mail/send', { to: s.to, template: s.template, vars: vars, attachments: attachments, }); if (btn) { btn.textContent = orig; btn.disabled = false; } if (r.ok) { alert('✓ Email sent to ' + (Array.isArray(s.to) ? s.to.join(', ') : s.to)); closeMailman(); } else { alert('Email failed: ' + (r.error || 'unknown')); } }; // ────────────────────────────────────────────────────────────── // 5. ESTIMATES + CONTRACTS VIEWS // ────────────────────────────────────────────────────────────── // Add tabs to admin nav (called after renderApp) and intercept loadView. function injectNavTabs() { if (!window.S || !window.S.user || (window.S.user.role !== 'admin' && window.S.user.role !== 'owner')) return; var sidebar = document.getElementById('sidebar'); if (!sidebar) return; var navItems = sidebar.querySelectorAll('.sidebar-section .nav-item'); var hasEst = false, hasCon = false; navItems.forEach(function(n) { if (n.textContent.indexOf('Estimates') >= 0) hasEst = true; if (n.textContent.indexOf('Contracts') >= 0) hasCon = true; }); var sect = sidebar.querySelector('.sidebar-section'); if (!sect) return; if (!hasEst) { var d = document.createElement('div'); d.className = 'nav-item'; d.setAttribute('onclick', "nav('estimates')"); d.innerHTML = '$'; sect.appendChild(d); } if (!hasCon) { var d2 = document.createElement('div'); d2.className = 'nav-item'; d2.setAttribute('onclick', "nav('contracts')"); d2.innerHTML = '§'; sect.appendChild(d2); } } // Wrap loadView so we add new view handlers var originalLoadView = window.loadView; window.loadView = async function(v) { if (v === 'estimates') return renderEstimatesView(); if (v === 'contracts') return renderContractsView(); if (v === 'invoices') return renderInvoicesView(); if (v === 'mail') return renderMailmanView(); if (v === 'discarded') return renderDiscardedView(); if (v === 'estimate-new') return renderEstimateForm(); if (v === 'contract-new') return renderContractForm(); if (v === 'estimate-detail') return renderEstimateDetail(); if (v === 'contract-detail') return renderContractDetail(); if (v === 'invoice-detail') return renderInvoiceDetailV2(); if (v === 'contract-history') return viewContractHistory(S.viewingContractId); if (v === 'vault') return renderVaultDashboard(); if (v === 'vault-payout') return renderPayoutForm(); if (v === 'vault-staff') return renderStaffPayoutsView(); if (v === 'vault-onboard-staff') return renderStaffOnboardForm(); if (v === 'vault-transactions') return renderTransactionsView(); if (typeof originalLoadView === 'function') return originalLoadView(v); }; // ════════════════════════════════════════════════════════════════ // VAULT — owner-only (Brad). Stripe balance, payouts, transfers. // ════════════════════════════════════════════════════════════════ function _ownerGuard() { if (!window.S || !window.S.isOwner) { var m = document.getElementById('main'); if (m) m.innerHTML = '
This area is restricted to Brad. If you believe you should have access, contact Brad directly.
'; return false; } return true; } function _money(amt, currency) { var n = Number(amt) || 0; return '$' + n.toLocaleString('en-US', {minimumFractionDigits: 2, maximumFractionDigits: 2}); } async function renderVaultDashboard() { if (!_ownerGuard()) return; setHeader('PAYMENTS', 'WH&S // STRIPE — OWNER ONLY'); var m = document.getElementById('main'); m.innerHTML = '
'; // Parallel fetch: balance + recent transactions + payouts + connected accounts var [balR, txnR, payR, acctR] = await Promise.all([ apiV2('GET', 'stripe-admin/balance'), apiV2('GET', 'stripe-admin/transactions?limit=15'), apiV2('GET', 'stripe-admin/payouts'), apiV2('GET', 'stripe-admin/connected-accounts'), ]); if (!balR.ok) { m.innerHTML = '
' + ''; return; } var bal = balR.balance || {}; var avail = (bal.available || []).reduce(function(s,b){ return s + (b.amount||0); }, 0); var pending = (bal.pending || []).reduce(function(s,b){ return s + (b.amount||0); }, 0); var live = bal.livemode ? '● LIVE MODE' : '○ test mode'; var txns = (txnR.ok ? txnR.transactions : []) || []; var payouts = (payR.ok ? payR.payouts : []) || []; var staffAccts = (acctR.ok ? acctR.accounts : []) || []; function txnRow(t) { var amtColor = t.amount >= 0 ? '#6cd47e' : '#cc6666'; var sign = t.amount >= 0 ? '+' : ''; return `
`; } function payRow(p) { var statusColor = p.status === 'paid' ? '#6cd47e' : (p.status === 'failed' ? '#cc6666' : '#C8962E'); return `
`; } function staffCard(a) { var ready = a.charges_enabled && a.payouts_enabled; var statusBadge = ready ? '● READY' : '⚠ PENDING'; var staffName = (a.metadata && a.metadata.wws_staff_name) || a.email || a.id; return `
`; } m.innerHTML = `
| DATE | TYPE | DESCRIPTION | AMOUNT |
|---|
| ARRIVAL | METHOD | AMOUNT | STATUS |
|---|
`; } async function renderPayoutForm() { if (!_ownerGuard()) return; setHeader('PAYOUT TO YOUR BANK', 'WH&S // VAULT'); var m = document.getElementById('main'); // Pull live balance to show context var bal = await apiV2('GET', 'stripe-admin/balance'); var avail = (bal.balance && bal.balance.available || []).reduce(function(s,b){ return s + (b.amount||0); }, 0); m.innerHTML = `
`; } window.executePayout = async function() { var amount = Number(document.getElementById('payout-amount').value); var desc = document.getElementById('payout-description').value.trim(); var method = document.getElementById('payout-method').value; var errs = []; if (!amount || amount <= 0) errs.push('Amount required and must be positive'); if (amount > 50000) errs.push('Amount exceeds $50,000 hard cap (use Stripe dashboard for larger)'); if (errs.length) { showValidationErrors(errs, 'vault-validation'); return; } if (!confirm('Initiate $' + amount.toFixed(2) + ' payout to your bank?\n\nMethod: ' + method + '\nThis CANNOT be reversed via the portal — you would need to log into Stripe.\n\nProceed?')) return; var r = await apiV2('POST', 'stripe-admin/payout', { amount: amount, description: desc, method: method }); if (!r.ok) { showValidationErrors([r.error || 'Payout failed', r.stripeError ? JSON.stringify(r.stripeError) : ''].filter(Boolean), 'vault-validation'); return; } alert('✓ Payout initiated\n\nPayout ID: ' + r.payout.id + '\nAmount: $' + r.payout.amount.toFixed(2) + '\nStatus: ' + r.payout.status + '\nArrival: ' + r.payout.arrival_date.slice(0,10)); nav('vault'); }; async function renderStaffPayoutsView() { if (!_ownerGuard()) return; setHeader('STAFF PAYOUTS', 'WH&S // VAULT — STRIPE CONNECT'); var m = document.getElementById('main'); m.innerHTML = '
'; var r = await apiV2('GET', 'stripe-admin/connected-accounts'); if (!r.ok) { m.innerHTML = '
'; return; } var accounts = r.accounts || []; var list = accounts.map(function(a){ var ready = a.charges_enabled && a.payouts_enabled; var statusBadge = ready ? '● READY' : '⚠ INCOMPLETE ONBOARDING'; var staffName = (a.metadata && a.metadata.wws_staff_name) || a.email || a.id; return `
`; }).join(''); m.innerHTML = `
`; } async function renderStaffOnboardForm() { if (!_ownerGuard()) return; setHeader('ONBOARD STAFF', 'WH&S // STRIPE CONNECT'); var m = document.getElementById('main'); m.innerHTML = `
`; } window.submitStaffOnboard = async function() { var name = document.getElementById('onboard-name').value.trim(); var email = document.getElementById('onboard-email').value.trim(); var errs = []; if (!name || name.length < 2) errs.push('Name required'); if (!email || !email.includes('@')) errs.push('Valid email required'); if (errs.length) { showValidationErrors(errs, 'onboard-validation'); return; } var r = await apiV2('POST', 'stripe-admin/connected-accounts/onboard', { staff_name: name, staff_email: email }); if (!r.ok) { showValidationErrors([r.error || 'failed'], 'onboard-validation'); return; } var msg = '✓ Account created!\n\nAccount: ' + r.account_id + '\nExpires: ' + r.expires_at.slice(0,16).replace('T',' ') + '\n\nSend this URL to ' + email + ':\n\n' + r.onboarding_url; alert(msg); if (navigator.clipboard) { try { await navigator.clipboard.writeText(r.onboarding_url); alert('Onboarding URL copied to clipboard. Paste in Messages/Email to send to them.'); } catch(e){} } nav('vault-staff'); }; window.transferToStaff = async function(acctId, staffName) { var amt = prompt('Transfer to ' + staffName + '\n\nAmount (USD):'); if (!amt) return; var amount = Number(amt); if (!amount || amount <= 0) { alert('Invalid amount'); return; } if (amount > 25000) { alert('Hard cap: $25,000 per transfer'); return; } var desc = prompt('Description (optional):', 'WH&S draw — ' + new Date().toISOString().slice(0,10)) || ''; if (!confirm('Transfer $' + amount.toFixed(2) + ' to ' + staffName + '?\n\nFunds leave WH&S balance and arrive in their connected account immediately. They can payout from there to their bank.\n\nProceed?')) return; var r = await apiV2('POST', 'stripe-admin/transfer', { destination: acctId, amount: amount, description: desc }); if (!r.ok) { alert('Transfer failed: ' + (r.error || 'unknown') + (r.stripeError ? '\n\n' + JSON.stringify(r.stripeError) : '')); return; } alert('✓ Transfer complete!\n\nID: ' + r.transfer.id + '\nAmount: $' + r.transfer.amount.toFixed(2) + '\nDestination: ' + staffName); nav('vault-staff'); }; async function renderTransactionsView() { if (!_ownerGuard()) return; setHeader('RECENT ACTIVITY', 'WH&S // VAULT'); var m = document.getElementById('main'); m.innerHTML = '
'; var r = await apiV2('GET', 'stripe-admin/transactions?limit=30'); if (!r.ok) { m.innerHTML = '
'; return; } var txns = r.transactions || []; var rows = txns.map(function(t){ var amtColor = t.amount >= 0 ? '#6cd47e' : '#cc6666'; return `
`; }).join(''); m.innerHTML = `
| DATE | TYPE | DESCRIPTION | AMOUNT | FEE | STATUS |
|---|
`; } async function renderInvoicesView() { setHeader('INVOICES', 'WH&S // BILLING'); var m = document.getElementById('main'); m.innerHTML = '
'; var r = await api('invoices', { action: 'list' }); if (!r.ok) { m.innerHTML = '
'; return; } var items = r.invoices || r.list || []; var invoices = items.filter(function(i){ return i.type === 'invoice' || !i.type; }); var totalOwed = invoices.filter(function(i){ return ['unpaid','partial','overdue','sent'].includes(i.status); }).reduce(function(s,i){ return s + (Number(i.balanceDue) || Number(i.total) || 0); }, 0); var paidTotal = invoices.filter(function(i){ return i.status === 'paid'; }).reduce(function(s,i){ return s + (Number(i.total) || 0); }, 0); var html = '
'; if (invoices.length === 0) html += '
'; else { html += '
'; } m.innerHTML = html; } window.viewInvoice = async function(id) { S.currentInvoice = id; nav('invoice-detail'); }; async function renderInvoiceDetailV2() { var id = S.currentInvoice; if (!id) return nav('invoices'); setHeader('INVOICE ' + id, 'WH&S // BILLING'); var m = document.getElementById('main'); m.innerHTML = '
'; var r = await api('invoices', { action: 'get', id: id }); if (!r.ok) { m.innerHTML = '
'; return; } var inv = r.invoice; var isAdmin = (S.user && (S.user.role === 'admin' || S.user.role === 'owner')); var html = '
'; if (inv.items && inv.items.length) { html += '
'; } if (inv.notes) html += '
'; m.innerHTML = html; } window.deleteInvoice = async function(id) { if (!confirm('Delete this invoice?\n\nDrafts: PERMANENTLY deleted.\nSent invoices: moved to DISCARDED archive (audit trail kept).\n\nProceed?')) return; var r = await apiV2('DELETE', 'invoices/' + id, null); if (!r.ok) { alert('Delete failed: ' + (r.error||'unknown')); return; } alert(r.archived ? 'Sent invoice archived to DISCARDED.' : 'Draft permanently deleted.'); nav('invoices'); }; // Push an invoice to Stripe (creates Customer + Invoice + line items + finalizes + sends) window.syncToStripe = async function(id) { if (!confirm('Push this invoice to Stripe?\n\nThis will:\n• Create or update the customer in your Stripe dashboard\n• Create the invoice with line items\n• Finalize it (locks monetary fields)\n• Email the customer a Stripe-hosted pay link\n\n7-day payment window. Proceed?')) return; var btn = event && event.target; if (btn) { btn.disabled = true; btn.textContent = 'SYNCING...'; } var r = await apiV2('POST', 'invoices/' + id + '/sync-to-stripe', {}); if (!r.ok) { alert('Sync failed: ' + (r.error || 'unknown') + (r.stripeError ? '\n\nStripe says: ' + JSON.stringify(r.stripeError) : '')); if (btn) { btn.disabled = false; btn.textContent = '⇧ SYNC TO STRIPE'; } return; } alert('✓ Synced to Stripe!\n\nStripe invoice: ' + r.stripeInvoiceId + '\nStatus: ' + r.status + '\n\nThe customer will get an email with a pay link.'); renderInvoiceDetailV2(); }; // Customer requests a pay link for an unsynced invoice (triggers admin to sync) window.requestPayLink = async function(id) { var r = await apiV2('GET', 'invoices/' + id + '/pay-link'); if (r.ok && r.hostedInvoiceUrl) { window.open(r.hostedInvoiceUrl, '_blank', 'noopener'); return; } alert('This invoice is not yet ready for online payment. Please contact WH&S — we\'ll send you a pay link shortly.'); }; window.markInvoicePaid = async function(id) { if (!confirm('Mark this invoice as PAID?')) return; var r = await api('invoices', { action: 'update', id: id, status: 'paid', paidAt: new Date().toISOString() }); if (r.ok) { alert('Marked as paid.'); nav('invoices'); } else alert('Failed: ' + (r.error||'unknown')); }; async function renderMailmanView() { setHeader('MAILMAN', 'WH&S // EMAIL CENTER'); var m = document.getElementById('main'); m.innerHTML = '
'; var [tplR, logR] = await Promise.all([apiV2('GET', 'mail/templates'), apiV2('GET', 'mail/log')]); var tpls = (tplR.ok ? tplR.templates : []) || []; var logs = (logR.ok ? logR.logs : []) || []; var html = '
' + '
'; m.innerHTML = html; } window.composeNewMail = function() { if (typeof openMailman === 'function') openMailman({ to: '', template: 'custom', vars: { subject: '', text: '' } }); else alert('Mailman compose UI not yet ready.'); }; async function renderDiscardedView() { setHeader('DISCARDED', 'WH&S // ARCHIVE'); var m = document.getElementById('main'); m.innerHTML = '
'; } function setHeader(title, bc) { var t = document.getElementById('main-title'); if (t) t.textContent = title; var b = document.getElementById('main-bc'); if (b) b.textContent = bc; } async function renderEstimatesView() { setHeader('ESTIMATES', 'WH&S // QUOTING'); var m = document.getElementById('main'); m.innerHTML = '
'; var r = await api('invoices', { action: 'list', type: 'estimate' }); var list = (r && r.invoices) || []; var rows = list.length ? list.map(function(e) { return `
`; }).join('') : '
'; m.innerHTML = `
| ID | CLIENT | PROJECT | TOTAL | STATUS | DATE |
|---|
`; } async function renderContractsView() { setHeader('CONTRACTS', 'WH&S // AGREEMENTS'); var m = document.getElementById('main'); m.innerHTML = '
'; var r = await api('contracts', { action: 'list' }); var list = (r && r.contracts) || []; var rows = list.length ? list.map(function(c) { return `
`; }).join('') : '
'; m.innerHTML = `
| ID | CLIENT | PROJECT | STATE | VALUE | CLAUSES | STATUS |
|---|
`; } // ── ESTIMATE FORM (Square-style) ────────────────────────────── window.__est_items__ = [{ description: '', qty: 1, rate: 0, amount: 0 }]; function recalcEstimateTotal() { var total = 0; window.__est_items__.forEach(function(it) { it.amount = (parseFloat(it.qty) || 0) * (parseFloat(it.rate) || 0); total += it.amount; }); var t = document.getElementById('est-total'); if (t) t.textContent = '$' + total.toFixed(2); return total; } function renderEstimateItemsTable() { var rows = window.__est_items__.map(function(it, i) { return `
`; }).join(''); return `
| DESCRIPTION | QTY | RATE | AMOUNT |
|---|
`; } window.recalcEstTotal = recalcEstimateTotal; window.refreshEstItems = function() { var c = document.getElementById('est-items-wrap'); if (c) c.innerHTML = renderEstimateItemsTable(); recalcEstimateTotal(); }; function renderEstimateForm() { setHeader('NEW ESTIMATE', 'WH&S // QUOTING'); window.__est_items__ = [{ description: '', qty: 1, rate: 0, amount: 0 }]; var m = document.getElementById('main'); m.innerHTML = `
`; recalcEstimateTotal(); } window.saveEstimate = async function(status) { var depositRequired = document.getElementById('est-deposit-required')?.checked || false; var data = { clientName: document.getElementById('est-client-name').value.trim(), clientEmail: document.getElementById('est-client-email').value.trim(), clientPhone: document.getElementById('est-client-phone').value.trim(), project: document.getElementById('est-project').value.trim(), items: window.__est_items__.filter(function(i){ return i.description; }), total: recalcEstimateTotal(), notes: document.getElementById('est-notes').value.trim(), status: status, depositRequired: depositRequired, depositType: depositRequired ? document.getElementById('est-deposit-type').value : 'percent', depositAmount: depositRequired ? Number(document.getElementById('est-deposit-amount').value) || 50 : 0, depositDue: depositRequired ? document.getElementById('est-deposit-due').value : 'immediate', }; if (!data.clientName) { alert('Client name is required.'); return; } if (!data.items.length) { alert('Add at least one line item.'); return; } if (status === 'sent' && !data.clientEmail) { alert('Client email required to send.'); return; } var r = await api('invoices', Object.assign({ action: 'create', type: 'estimate' }, data)); if (!r.ok) { alert('Save failed: ' + (r.error || 'unknown')); return; } var est = r.invoice; if (status === 'sent') { // Open Mailman popup with PDF attached var pdfR = await apiV2('GET', 'invoices/' + est.id + '/pdf'); var attachments = []; if (pdfR.ok && pdfR._blob) { var b64 = await new Promise(function(res) { var fr = new FileReader(); fr.onload = function(){ res(fr.result.split(',')[1]); }; fr.readAsDataURL(pdfR._blob); }); attachments.push({ filename: 'WWS-Estimate-' + est.id + '.pdf', content: b64, contentType: 'application/pdf', kind: 'PDF' }); } openMailman({ to: data.clientEmail, template: 'estimate_delivery', vars: { estimateId: est.id, amount: data.total.toFixed(2), project: data.project }, attachments: attachments, }); } else { alert('✓ Estimate saved as draft (' + est.id + ')'); nav('estimates'); } }; // ── CONTRACT FORM (with clause picker) ──────────────────────── window.__con_items__ = [{ description: '', qty: 1, rate: 0, amount: 0 }]; window.__con_suggestions__ = []; window.__con_selected_clauses__ = new Set(); function recalcContractTotal() { var total = 0; window.__con_items__.forEach(function(it) { it.amount = (parseFloat(it.qty) || 0) * (parseFloat(it.rate) || 0); total += it.amount; }); var t = document.getElementById('con-total'); if (t) t.textContent = '$' + total.toFixed(2); return total; } function renderContractItemsTable() { var rows = window.__con_items__.map(function(it, i) { return `
`; }).join(''); return `
| DESCRIPTION | QTY | RATE | AMOUNT |
|---|
`; } window.recalcConTotal = recalcContractTotal; window.refreshConItems = function() { var c = document.getElementById('con-items-wrap'); if (c) c.innerHTML = renderContractItemsTable(); recalcContractTotal(); }; window.runClauseAnalyzer = async function() { var btn = document.getElementById('analyze-btn'); if (btn) { btn.textContent = 'ANALYZING...'; btn.disabled = true; } var scope = document.getElementById('con-scope').value.trim(); var state = document.getElementById('con-state').value; var total = recalcContractTotal(); var items = window.__con_items__.filter(function(i){ return i.description; }); var r = await apiV2('POST', 'clauses/suggest', { scope: scope, items: items, state: state, totalAmount: total, isMarine: true, hasDeposit: false, isClientSuppliedParts: /owner.?supplied|client.?provided/i.test(scope), }); if (btn) { btn.textContent = 'RE-RUN ANALYZER'; btn.disabled = false; } if (!r.ok) { alert('Analyzer failed: ' + (r.error || 'unknown')); return; } window.__con_suggestions__ = r.suggestions; // Pre-select the auto:true clauses window.__con_selected_clauses__ = new Set(); r.suggestions.forEach(function(s) { if (s.auto) window.__con_selected_clauses__.add(s.id); }); renderClauseList(); }; function renderClauseList() { var list = window.__con_suggestions__ || []; var c = document.getElementById('clause-list'); if (!c) return; if (!list.length) { c.innerHTML = '
'; return; } var html = '
'; html += list.map(function(s) { var checked = window.__con_selected_clauses__.has(s.id); return `
`; }).join(''); html += '
'; c.innerHTML = html; } window.toggleClause = function(id) { if (window.__con_selected_clauses__.has(id)) window.__con_selected_clauses__.delete(id); else window.__con_selected_clauses__.add(id); renderClauseList(); }; function renderContractForm(prefill) { setHeader('NEW CONTRACT', 'WH&S // AGREEMENTS'); var pre = prefill || {}; window.__con_items__ = (pre.items && pre.items.length) ? JSON.parse(JSON.stringify(pre.items)) : [{ description: '', qty: 1, rate: 0, amount: 0 }]; window.__con_suggestions__ = []; window.__con_selected_clauses__ = new Set(pre.clauseIds || []); window.__con_editing_id__ = pre.id || null; window.__con_editing_version__ = pre.version || null; window.__con_amend_mode__ = !!(pre.id && ['sent','signed','viewed'].includes(pre.status)); var m = document.getElementById('main'); var amendNotice = window.__con_amend_mode__ ? '
' : (pre.id ? '
' : ''); m.innerHTML = `
`; recalcContractTotal(); renderClauseList(); } window.toggleVehicleFields = function() { var sel = document.getElementById('con-asset-type'); var blk = document.getElementById('con-vehicle-block'); if (sel && blk) blk.style.display = (sel.value === 'auto' || sel.value === 'rv') ? 'block' : 'none'; }; // Show inline validation errors — never use alert() for this function showValidationErrors(errs, panelId) { var panel = document.getElementById(panelId || 'con-validation'); if (!panel) return; if (!errs || errs.length === 0) { panel.style.display = 'none'; return; } panel.className = 'form-validation-panel'; panel.style.display = 'block'; panel.innerHTML = '
'; panel.scrollIntoView({behavior:'smooth', block:'start'}); } function gatherContractData(status) { var assetType = document.getElementById('con-asset-type')?.value || 'marine'; var data = { clientName: document.getElementById('con-client-name').value.trim(), clientEmail: document.getElementById('con-client-email').value.trim(), clientPhone: document.getElementById('con-client-phone').value.trim(), project: document.getElementById('con-project').value.trim(), scope: document.getElementById('con-scope').value.trim(), state: document.getElementById('con-state').value, assetType: assetType, items: window.__con_items__.filter(function(i){ return i.description; }), total: recalcContractTotal(), clauseIds: Array.from(window.__con_selected_clauses__), status: status, }; if (assetType === 'auto' || assetType === 'rv') { data.vehicleYear = document.getElementById('con-veh-year')?.value || ''; data.vehicleMake = document.getElementById('con-veh-make')?.value.trim() || ''; data.vehicleModel = document.getElementById('con-veh-model')?.value.trim() || ''; data.vehicleVin = (document.getElementById('con-veh-vin')?.value || '').toUpperCase().trim(); data.vehicleMileage = document.getElementById('con-veh-mileage')?.value || ''; } return data; } // Apply field-level error highlighting based on Worker validation response function highlightFieldErrors(errs) { // Clear existing document.querySelectorAll('.is-error').forEach(function(el){ el.classList.remove('is-error'); }); var keywords = { 'Client name': 'con-client-name', 'Scope': 'con-scope', 'State': 'con-state', 'client email': 'con-client-email', 'line item': null, // line items wrap is harder to highlight 'clause': null }; errs.forEach(function(e){ Object.keys(keywords).forEach(function(k){ if (e.toLowerCase().indexOf(k.toLowerCase()) >= 0 && keywords[k]) { var el = document.getElementById(keywords[k]); if (el) el.classList.add('is-error'); } }); }); } window.saveContract = async function(status) { var data = gatherContractData(status); // Client-side preflight (catch obvious things fast) var clientErrs = []; if (!data.clientName || data.clientName.length < 2) clientErrs.push('Client name required (min 2 chars)'); if (!data.scope || data.scope.length < 20) clientErrs.push('Scope of work required (min 20 chars — be specific)'); if (!data.state) clientErrs.push('State of work required'); if (status === 'sent') { if (!data.clientEmail || !data.clientEmail.includes('@')) clientErrs.push('Valid client email required to send'); if (!data.items || data.items.length === 0) clientErrs.push('At least 1 line item required to send'); if (!data.clauseIds || data.clauseIds.length === 0) clientErrs.push('Run the analyzer + select 1+ protective clauses before sending'); } if (clientErrs.length > 0) { showValidationErrors(clientErrs); highlightFieldErrors(clientErrs); return; } showValidationErrors([]); var r = await apiV2('POST', 'contracts', data); if (!r.ok) { // Server-side validation — surface errors inline if (r.validation && r.validation.length) { showValidationErrors(r.validation); highlightFieldErrors(r.validation); return; } showValidationErrors(['Save failed: ' + (r.error || 'unknown')]); return; } var con = r.contract; if (status === 'sent') { var pdfR = await apiV2('GET', 'contracts/' + con.id + '/pdf'); var attachments = []; if (pdfR.ok && pdfR._blob) { var b64 = await new Promise(function(res){ var fr = new FileReader(); fr.onload = function(){ res(fr.result.split(',')[1]); }; fr.readAsDataURL(pdfR._blob); }); attachments.push({ filename: 'WWS-Contract-' + con.id + '.pdf', content: b64, contentType: 'application/pdf', kind: 'PDF' }); } openMailman({ to: data.clientEmail, template: 'contract_delivery', vars: { contractId: con.id, project: data.project }, attachments: attachments, }); } else { // Draft saved — show success panel inline, navigate var p = document.getElementById('con-validation'); if (p) { p.className = 'form-validation-panel success'; p.style.display = 'block'; p.innerHTML = '
'; } setTimeout(function(){ nav('contracts'); }, 1000); } }; // Save an amendment to a sent/signed contract — requires changeNote window.saveAmendment = async function() { var id = window.__con_editing_id__; if (!id) { alert('No contract selected to amend'); return; } var noteEl = document.getElementById('con-change-note'); var note = noteEl ? noteEl.value.trim() : ''; if (!note || note.length < 10) { showValidationErrors(['Change note required (min 10 chars). This becomes part of the legal record — describe what changed and why.']); if (noteEl) noteEl.classList.add('is-error'); return; } showValidationErrors([]); var changes = gatherContractData('draft'); delete changes.status; // amendment goes back to draft, server enforces var r = await apiV2('POST', 'contracts/' + id + '/amend', { changeNote: note, changes: changes }); if (!r.ok) { if (r.validation && r.validation.length) { showValidationErrors(r.validation); return; } showValidationErrors(['Amendment failed: ' + (r.error || 'unknown')]); return; } var p = document.getElementById('con-validation'); if (p) { p.className = 'form-validation-panel success'; p.style.display = 'block'; p.innerHTML = '
'; } setTimeout(function(){ S.viewingContractId = id; nav('contract-detail'); }, 1500); }; // Edit existing contract (loads form pre-populated) window.editContract = async function(id) { var r = await apiV2('GET', 'contracts/' + id); if (!r.ok) { alert('Could not load contract: ' + (r.error||'unknown')); return; } setHeader('EDIT CONTRACT', 'WH&S // AGREEMENTS'); renderContractForm(r.contract); }; // Convert invoice → contract window.convertInvoiceToContract = async function(invoiceId) { if (!confirm('Generate a new contract pre-filled from this invoice?\n\nThe contract will start in DRAFT mode. You can review, edit, run the clause analyzer, then send for signature.\n\nProceed?')) return; var r = await apiV2('POST', 'invoices/' + invoiceId + '/to-contract', { state: 'IL', clauseIds: ['LIA-001','DIS-001'] }); if (!r.ok) { alert('Failed: ' + (r.error||'unknown')); return; } alert('✓ Contract draft created: ' + r.contract.id + '\n\nLoading editor...'); S.viewingContractId = r.contract.id; setHeader('EDIT CONTRACT', 'WH&S // AGREEMENTS'); renderContractForm(r.contract); }; // Show full version history of a contract window.viewContractHistory = async function(id) { var r = await apiV2('GET', 'contracts/' + id + '/history'); if (!r.ok) { alert('Failed to load history: ' + (r.error||'unknown')); return; } var versions = r.versions || []; var html = '
| DESCRIPTION | QTY | RATE | AMOUNT |
|---|---|---|---|
| TOTAL | $${(e.total||0).toFixed(2)} | ||