. * 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 = `
📬 MAILMAN — THIS WILL EMAIL THE FOLLOWING TO THE CLIENT
`; 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 = '
Loading preview from server...
'; 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 = '
Could not load preview: ' + (r.error || '') + '
'; return; } var p = r.preview; var atts = (opts.attachments || []).map(function(a) { return '
📎 ' + a.filename + ' (' + (a.kind || 'file') + ')
'; }).join(''); b.innerHTML = `
⚠ This will send a real email to the client. Review carefully.
FROM
${p.from || 'Mailman @ WH&S'}
TO
${(Array.isArray(opts.to) ? opts.to.join(', ') : opts.to)}
SUBJECT
${p.subject}
DRAFT (editable plain-text version)
${atts ? '
ATTACHMENTS
' + atts + '
' : ''}
UPLOAD ADDITIONAL ATTACHMENT (e.g., signed contract scan)
Need help scanning? Click here for iPhone/Android scan instructions
`; }); }; 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 = '$Estimates'; sect.appendChild(d); } if (!hasCon) { var d2 = document.createElement('div'); d2.className = 'nav-item'; d2.setAttribute('onclick', "nav('contracts')"); d2.innerHTML = '§Contracts'; 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 = '
🔒 OWNER ACCESS REQUIRED

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 = '
LOADING STRIPE...
'; // 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 = '
✗ ' + (balR.error || 'Stripe error') + '
' + ''; 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 ` ${t.created.slice(5,16).replace('T',' ')} ${t.type} ${(t.description||'').replace(/ ${sign}$${Math.abs(t.amount).toFixed(2)} `; } function payRow(p) { var statusColor = p.status === 'paid' ? '#6cd47e' : (p.status === 'failed' ? '#cc6666' : '#C8962E'); return ` ${p.arrival_date.slice(0,10)} ${p.method} $${p.amount.toFixed(2)} ${p.status} `; } 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 `
${staffName}
${a.email || ''}
${statusBadge}
`; } m.innerHTML = `
💳 PAYMENTS · OWNER ONLY · ${live}
Every action logged. Josh & Evan can't see this page.
// AVAILABLE
${_money(avail)}
Payout-ready
// PENDING
${_money(pending)}
Clearing (~2 days)
// TOTAL
${_money(avail + pending)}
Combined
// RECENT ACTIVITY (last 15)
${txns.length === 0 ? '
No activity yet.
' : `
${txns.map(txnRow).join('')}
DATE TYPE DESCRIPTION AMOUNT
`}
// PAYOUT HISTORY
${payouts.length === 0 ? '
No payouts yet. Tap "PAYOUT TO MY BANK" above to send funds to your bank.
' : `
${payouts.slice(0, 10).map(payRow).join('')}
ARRIVAL METHOD AMOUNT STATUS
`}
// STAFF PAYOUTS (Stripe Connect)
Each staff member needs their own connected account. Stripe handles 1099s.
${staffAccts.length === 0 ? '
No connected accounts yet. Onboard Evan or Josh to enable direct transfers.
' : '
' + staffAccts.map(staffCard).join('') + '
'}
// SAFEGUARDS
• Owner-only role enforced server-side
• Hard caps: $50k payouts, $25k transfers per request
• Every action audit-logged with email + timestamp
• TOTP 2FA gate ready (enroll next session)
`; } 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 = `
// CURRENTLY AVAILABLE
${_money(avail)}
// PAYOUT DETAILS
AMOUNT (USD)*
Max ${_money(avail)} (currently available)
DESCRIPTION (optional)
METHOD
⚠ THIS WILL MOVE REAL MONEY
Payout goes to the default bank account on file in your Stripe dashboard. To change destinations, log into Stripe.
`; } 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 = '
LOADING CONNECTED ACCOUNTS...
'; var r = await apiV2('GET', 'stripe-admin/connected-accounts'); if (!r.ok) { m.innerHTML = '
' + (r.error || 'load failed') + '
'; 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 `
${staffName}
${a.email || ''} · ${a.id}
${statusBadge}
`; }).join(''); m.innerHTML = `

STAFF CONNECTED ACCOUNTS

Each staff member needs their own Stripe Express account. They onboard once, then you can transfer money to them. Stripe handles 1099s.
${accounts.length === 0 ? '
No connected accounts yet. Tap "ONBOARD NEW STAFF" to create one for Evan, Josh, or another team member.
' : list}
`; } async function renderStaffOnboardForm() { if (!_ownerGuard()) return; setHeader('ONBOARD STAFF', 'WH&S // STRIPE CONNECT'); var m = document.getElementById('main'); m.innerHTML = `
// STAFF DETAILS
FULL NAME*
EMAIL (theirs, not yours)*
What happens when you submit:
1. Stripe creates an Express account for them
2. You get a one-time onboarding link (valid ~5 min)
3. Send them the link by text or email
4. They click it → enter their SSN/EIN, bank account, ID — Stripe handles all compliance
5. Once complete, they appear as "READY" and you can transfer money to them
`; } 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 = '
LOADING...
'; var r = await apiV2('GET', 'stripe-admin/transactions?limit=30'); if (!r.ok) { m.innerHTML = '
' + (r.error||'load failed') + '
'; return; } var txns = r.transactions || []; var rows = txns.map(function(t){ var amtColor = t.amount >= 0 ? '#6cd47e' : '#cc6666'; return ` ${t.created.slice(0,16).replace('T',' ')} ${t.type} ${(t.description||'').replace(/ $${t.amount.toFixed(2)} $${t.fee.toFixed(2)} ${t.status} `; }).join(''); m.innerHTML = `

LAST 30 TRANSACTIONS

${txns.length === 0 ? '
No activity yet.
' : `${rows}
DATE TYPE DESCRIPTION AMOUNT FEE STATUS
`}
`; } async function renderInvoicesView() { setHeader('INVOICES', 'WH&S // BILLING'); var m = document.getElementById('main'); m.innerHTML = '
LOADING...
'; var r = await api('invoices', { action: 'list' }); if (!r.ok) { m.innerHTML = '
// ' + (r.error || 'load failed') + '
'; 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 = '
' + '
// MONEY OWED
$' + totalOwed.toFixed(2) + '
' + '
// PAID YTD
$' + paidTotal.toFixed(2) + '
' + '
'; if (invoices.length === 0) html += '
No invoices yet. Create an estimate, then convert it to an invoice.
'; else { html += '
'; invoices.forEach(function(inv){ var sc = inv.status === 'paid' ? '#6cd47e' : (inv.status === 'void' ? '#888' : 'var(--o)'); html += '
' + '
' + (inv.clientName || '—') + (inv.isDeposit ? ' [DEPOSIT]' : '') + '
' + '
' + inv.id + ' · ' + (inv.project || '—') + '
' + '
' + (inv.status || 'draft') + '
' + '
$' + (Number(inv.total) || 0).toFixed(2) + '
' + '
'; }); 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 = '
LOADING...
'; var r = await api('invoices', { action: 'get', id: id }); if (!r.ok) { m.innerHTML = '
// ' + (r.error || 'not found') + '
'; return; } var inv = r.invoice; var isAdmin = (S.user && (S.user.role === 'admin' || S.user.role === 'owner')); var html = '
' + '' + ''; // PAY NOW button (visible to everyone — admin OR the customer this invoice belongs to) // Only when invoice is unpaid AND has a Stripe hosted URL OR can be synced if (inv.status !== 'paid' && inv.status !== 'void') { if (inv.stripeHostedUrl) { html += '💳 PAY NOW'; } else if (!isAdmin) { // Customer trying to pay an unsynced invoice — let them request the link, the Worker will sync html += ''; } } if (isAdmin && inv.status !== 'paid' && inv.status !== 'void') { var dl = inv.isDeposit ? 'SEND DEPOSIT REQUEST' : 'SEND FOR PAYMENT'; html += '' + ''; // Stripe sync button — only show if not already synced if (!inv.stripeInvoiceId) { html += ''; } else { html += '↗ STRIPE INVOICE'; } if (!inv.contractId) { html += ''; } else { html += ''; } html += ''; } html += '
' + '
CLIENT
' + (inv.clientName || '—') + '
' + '
EMAIL
' + (inv.clientEmail || '—') + '
' + '
PROJECT
' + (inv.project || '—') + '
' + '
STATUS
' + (inv.status || 'unpaid') + '
' + '
TOTAL
$' + (Number(inv.total)||0).toFixed(2) + '
'; if (inv.balanceDue !== undefined) html += '
BALANCE DUE
$' + (Number(inv.balanceDue)||0).toFixed(2) + '
'; html += '
'; if (inv.items && inv.items.length) { html += '
LINE ITEMS
'; inv.items.forEach(function(it){ html += '
' + (it.description || '—') + '
$' + (Number(it.amount)||0).toFixed(2) + '
'; }); html += '
'; } if (inv.notes) html += '
NOTES
' + inv.notes + '
'; 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 = '
LOADING...
'; 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 = '
' + '
' + '
// AVAILABLE TEMPLATES (' + tpls.length + ')
' + '
'; tpls.forEach(function(t){ html += '
' + t + '
'; }); html += '
// SEND LOG (last 50)
'; if (logs.length === 0) html += '
No emails sent yet.
'; else { html += '
'; logs.forEach(function(l){ var when = l.ts ? new Date(l.ts).toLocaleString() : '?'; var ok = l.ok ? '✓' : '✗', col = l.ok ? '#6cd47e' : '#cc6666'; html += '
' + ok + ' ' + (l.template||'?') + ' → ' + (l.to||'?') + '
' + when + ' · by ' + (l.sentBy||'system') + (l.error?' · '+l.error:'') + '
'; }); html += '
'; } 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 = '
' + '🗑 The DISCARDED archive holds invoices/estimates/contracts that were SENT to clients and then deleted.

' + 'Drafts are hard-deleted (no archive). Only sent items keep an audit trail here.

' + 'Coming soon: search/restore/permanent-purge for archived items.' + '
'; } 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 = '
LOADING ESTIMATES...
'; var r = await api('invoices', { action: 'list', type: 'estimate' }); var list = (r && r.invoices) || []; var rows = list.length ? list.map(function(e) { return ` ${e.id} ${e.clientName || e.client || '—'} ${e.project || '—'} $${(e.total || 0).toLocaleString()} ${(e.status||'draft').toUpperCase()} ${e.date || ''} `; }).join('') : 'No estimates yet. Create your first one →'; m.innerHTML = `
// QUOTING
${list.length} ESTIMATE${list.length!==1?'S':''}
${rows}
ID CLIENT PROJECT TOTAL STATUS DATE
`; } async function renderContractsView() { setHeader('CONTRACTS', 'WH&S // AGREEMENTS'); var m = document.getElementById('main'); m.innerHTML = '
LOADING CONTRACTS...
'; var r = await api('contracts', { action: 'list' }); var list = (r && r.contracts) || []; var rows = list.length ? list.map(function(c) { return ` ${c.id} ${c.clientName || c.client || '—'} ${c.project || '—'} ${c.state || '—'} $${(c.total || 0).toLocaleString()} ${(c.clauseIds||[]).length} ${(c.status||'draft').toUpperCase()} `; }).join('') : 'No contracts yet. Generate your first one →'; m.innerHTML = `
// AGREEMENTS
${list.length} CONTRACT${list.length!==1?'S':''}
${rows}
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 ` $${(it.amount||0).toFixed(2)} `; }).join(''); return `${rows}
DESCRIPTIONQTYRATEAMOUNT
+ ADD LINE ITEM
`; } 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 = `
// CLIENT
CLIENT NAME
EMAIL
PHONE
PROJECT / VESSEL
// LINE ITEMS
${renderEstimateItemsTable()}
TOTAL
$0.00
NOTES (visible to client)
// DEPOSIT (optional)
`; 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 ` $${(it.amount||0).toFixed(2)} `; }).join(''); return `${rows}
DESCRIPTIONQTYRATEAMOUNT
+ ADD LINE ITEM
`; } 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 = '
Run analyzer to see recommended clauses for this contract.
'; return; } var html = '
' + list.length + ' SUGGESTED CLAUSES — REVIEW AND CHECK ANY YOU WANT INCLUDED
'; html += list.map(function(s) { var checked = window.__con_selected_clauses__.has(s.id); return `
${s.id}
${s.title}
SEV ${s.severity}
${s.summary}
→ ${s.reason}
`; }).join(''); html += '
SELECTED: ' + window.__con_selected_clauses__.size + ' / ' + list.length + '
'; 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__ ? '
⚠ AMENDMENT MODE — v' + (pre.version||1) + ' is locked. Edits create v' + ((pre.version||1)+1) + ' with mandatory change note. Original archived as legal record.
' : (pre.id ? '
Editing draft contract v' + (pre.version||1) + '
' : ''); m.innerHTML = `
${amendNotice}
// CLIENT & PROJECT
CLIENT NAME*
EMAIL *required to send
PHONE
PROJECT / VESSEL / VEHICLE
ASSET TYPE
STATE OF WORK*
// VEHICLE DETAILS
YEAR
MAKE
MODEL
VIN (optional)
MILEAGE / HOURS
// SCOPE OF WORK * (min 20 chars)
// LINE ITEMS * required to send
${renderContractItemsTable()}
TOTAL VALUE
$0.00
// CLAUSE LIBRARY — LIABILITY ANALYZER * 1+ to send
Reads your scope, recommends protective clauses. Always your call.
${window.__con_amend_mode__ ? `
// CHANGE NOTE * (min 10 chars — this enters the legal record)
` : ''}
${window.__con_amend_mode__ ? `` : ` ` }
`; 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 = '
✗ FIX THESE BEFORE SAVING:
'; 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 = '
✓ DRAFT SAVED — ' + con.id + ' (v' + (con.version||1) + ')
'; } 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 = '
✓ AMENDMENT SAVED — ' + id + ' v' + r.archivedVersion + ' archived as legal record, v' + r.newVersion + ' is now editable as draft.
'; } 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 = '
' + '
' + '

VERSION HISTORY — ' + id + '

' + '
'; versions.forEach(function(v){ var hist = (v.changeHistory || []).slice(-1)[0] || {}; var bg = v.isCurrent ? 'rgba(108,212,126,0.06)' : 'rgba(255,255,255,0.02)'; var border = v.isCurrent ? '#6cd47e' : 'rgba(255,255,255,0.1)'; html += '
' + '
' + '
v' + (v.version||1) + (v.isCurrent?' · CURRENT':' · ARCHIVED') + ' ' + '' + (v.status||'?') + '
' + '
' + (hist.changedAt || v.archivedAt || v.createdAt || '?').slice(0,19) + '
' + '
' + '
Change note: ' + (hist.changeNote || v.archivedReason || '—').replace(/' + '
By: ' + (hist.changedBy || v.archivedBy || '?') + ' · Total: $' + (Number(v.total)||0).toFixed(2) + ' · ' + (v.items?.length||0) + ' items · ' + (v.clauseIds?.length||0) + ' clauses
' + '
'; }); html += '
'; document.getElementById('main').innerHTML = html; }; // ── DETAIL VIEWS ────────────────────────────────────────────── async function renderEstimateDetail() { var id = window.S && window.S.viewingEstimateId; if (!id) return nav('estimates'); setHeader('ESTIMATE ' + id, 'WH&S // QUOTING'); var m = document.getElementById('main'); m.innerHTML = '
LOADING...
'; var r = await apiV2('GET', 'estimates/' + id); if (!r.ok) { m.innerHTML = '
Not found
'; return; } var e = r.estimate; var itemsRows = (e.items || []).map(function(it) { return `${it.description||''}${it.qty||1}$${(parseFloat(it.rate)||0).toFixed(2)}$${((parseFloat(it.qty)||1)*(parseFloat(it.rate)||0)).toFixed(2)}`; }).join(''); m.innerHTML = `
// ESTIMATE
${e.id}
${(e.status||'draft').toUpperCase()}
CLIENT
${e.clientName||'—'}
EMAIL
${e.clientEmail||'—'}
PROJECT
${e.project||'—'}
DATE
${e.date||'—'}
${e.depositRequired ? `
DEPOSIT REQUIRED
${e.depositType==='fixed' ? '$'+e.depositAmount+' fixed' : (e.depositAmount||50)+'% of total'} — due ${e.depositDue||'immediately'}
` : ''}
${itemsRows}
DESCRIPTIONQTYRATEAMOUNT
TOTAL$${(e.total||0).toFixed(2)}
${e.notes ? '
NOTES: ' + e.notes + '
' : ''}
`; } window.emailEstimate = async function(id) { var r = await apiV2('GET', 'estimates/' + id); if (!r.ok) return; var e = r.estimate; if (!e.clientEmail) { alert('No client email on file. Edit the estimate first.'); return; } var pdfR = await apiV2('GET', 'estimates/' + 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-' + id + '.pdf', content: b64, contentType: 'application/pdf', kind: 'PDF' }); } openMailman({ to: e.clientEmail, template: 'estimate_delivery', vars: { estimateId: e.id, amount: (e.total||0).toFixed(2), project: e.project }, attachments: attachments, }); }; async function renderContractDetail() { var id = window.S && window.S.viewingContractId; if (!id) return nav('contracts'); setHeader('CONTRACT ' + id, 'WH&S // AGREEMENTS'); var m = document.getElementById('main'); m.innerHTML = '
LOADING...
'; var r = await apiV2('GET', 'contracts/' + id); if (!r.ok) { m.innerHTML = '
Not found
'; return; } var c = r.contract; var version = c.version || 1; var canEdit = ['draft'].includes(c.status||'draft'); var canAmend = ['sent','signed','viewed'].includes(c.status); var hasHistory = (c.changeHistory||[]).length > 1 || (c.version||1) > 1; m.innerHTML = `
// CONTRACT
${c.id}
${(c.status||'draft').toUpperCase()} v${version} ${c.fromInvoiceId ? `↳ from ${c.fromInvoiceId}` : ''}
${canEdit ? `` : ''} ${canAmend ? `` : ''} ${hasHistory ? `` : ''} ${c.status !== 'signed' ? `` : ''} ${c.status !== 'signed' ? `` : ''}
${c.status === 'signed' ? `
✓ SIGNED ${c.signedAt ? 'on ' + new Date(c.signedAt).toLocaleString() : ''}${c.signature && c.signature.typedName ? ' by ' + c.signature.typedName : ''}
` : ''}
CLIENT
${c.clientName||'—'}
EMAIL
${c.clientEmail||'—'}
PROJECT
${c.project||'—'}
STATE
${c.state||'IL'}
SCOPE OF WORK
${c.scope||'—'}
CLAUSES INCLUDED (${(c.clauseIds||[]).length})
${(c.clauseIds||[]).map(function(id){return ''+id+'';}).join('')}
TOTAL VALUE
$${(c.total||0).toFixed(2)}
`; } window.emailContract = async function(id) { var r = await apiV2('GET', 'contracts/' + id); if (!r.ok) return; var c = r.contract; if (!c.clientEmail) { alert('No client email on file. Edit the contract first.'); return; } var pdfR = await apiV2('GET', 'contracts/' + 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-' + id + '.pdf', content: b64, contentType: 'application/pdf', kind: 'PDF' }); } openMailman({ to: c.clientEmail, template: 'contract_delivery', vars: { contractId: c.id, project: c.project }, attachments: attachments, }); }; // ────────────────────────────────────────────────────────────── // 6. INTEGRATION — inject nav tabs after renderApp runs // ────────────────────────────────────────────────────────────── // Watch for the app rendering and inject our tabs var observer = new MutationObserver(function() { if (document.getElementById('sidebar')) { injectNavTabs(); } }); observer.observe(document.getElementById('app') || document.body, { childList: true, subtree: true }); // Also re-inject on initial load if (document.getElementById('sidebar')) injectNavTabs(); window.sendForSignature = async function(id) { if (!confirm('This will create a signing link and email it to the client. Continue?')) return; var btn = event && event.target; var orig = btn ? btn.textContent : ''; if (btn) { btn.textContent = 'SENDING...'; btn.disabled = true; } var r = await apiV2('POST', 'contracts/' + id + '/sign-link', {}); if (btn) { btn.textContent = orig; btn.disabled = false; } if (!r.ok) { alert('Failed: ' + (r.error || 'unknown')); return; } var msg = '✓ Sign link created.\n\n'; msg += r.mailed ? 'Emailed to client automatically.' : 'Email NOT sent (no client email or send failed).'; msg += '\n\nLink to share manually:\n' + r.url; alert(msg); // Copy URL to clipboard if available if (navigator.clipboard) { try { await navigator.clipboard.writeText(r.url); } catch (e) {} } }; // ────────────────────────────────────────────────────────────── // 7. CUSTOMER PROFILE — bio, location, vessel assets, photos // ────────────────────────────────────────────────────────────── function injectProfileNavTab() { if (!window.S || !window.S.user) return; var sidebar = document.getElementById('sidebar'); if (!sidebar) return; if (sidebar.querySelector('[data-wws-profile-tab]')) return; var sect = sidebar.querySelector('.sidebar-section'); if (!sect) return; var d = document.createElement('div'); d.className = 'nav-item'; d.setAttribute('data-wws-profile-tab', '1'); d.setAttribute('onclick', "nav('myprofile')"); d.innerHTML = 'My Boat & Bio'; sect.appendChild(d); } var profileObs = new MutationObserver(function() { if (document.getElementById('sidebar')) injectProfileNavTab(); }); profileObs.observe(document.getElementById('app') || document.body, { childList: true, subtree: true }); if (document.getElementById('sidebar')) injectProfileNavTab(); var prevLoadProf = window.loadView; window.loadView = async function(v) { if (v === 'myprofile') return renderMyProfile(); if (typeof prevLoadProf === 'function') return prevLoadProf(v); }; async function renderMyProfile() { var t = document.getElementById('main-title'); if (t) t.textContent = 'MY BOAT & BIO'; var b = document.getElementById('main-bc'); if (b) b.textContent = 'WH&S // YOUR PROFILE'; var m = document.getElementById('main'); m.innerHTML = '
LOADING...
'; var r = await apiV2('GET', 'profile'); if (!r.ok) { m.innerHTML = '
Could not load profile.
'; return; } var p = r.profile; var assets = p.assets || []; var photos = p.photos || []; m.innerHTML = `
// ABOUT YOU
YOUR BIO (hobbies, fitness, anything we should know)
HOME PORT
COORDINATES
// MY BOATS & ASSETS
${assets.length ? assets.map(renderAssetCard).join('') : '
No vessels saved yet.
'}
// PHOTOS
${photos.length ? photos.map(renderPhotoCard).join('') : '
No photos yet.
'}
`; } function renderAssetCard(a) { var label = [a.year, a.make, a.model].filter(Boolean).join(' '); return `
${a.name || '(unnamed)'}
${(a.type || '').toUpperCase()} · ${label}${a.length ? ' · ' + a.length + 'ft' : ''}
`; } function renderPhotoCard(p) { return `
${p.url ? `` : '
📷
'} ${p.caption ? `
${p.caption}
` : ''}
`; } window.grabGPS = function() { if (!navigator.geolocation) { alert('Geolocation not available.'); return; } var btn = event && event.target; var orig = btn ? btn.textContent : ''; if (btn) { btn.textContent = '...'; btn.disabled = true; } navigator.geolocation.getCurrentPosition(function(pos) { document.getElementById('prof-loc-lat').value = pos.coords.latitude.toFixed(4); document.getElementById('prof-loc-lon').value = pos.coords.longitude.toFixed(4); if (btn) { btn.textContent = orig; btn.disabled = false; } }, function(err) { if (btn) { btn.textContent = orig; btn.disabled = false; } alert('Could not get location: ' + err.message); }); }; window.saveProfile = async function() { var btn = event && event.target; var orig = btn ? btn.textContent : ''; if (btn) { btn.textContent = 'SAVING...'; btn.disabled = true; } var bio = document.getElementById('prof-bio').value.trim(); var name = document.getElementById('prof-loc-name').value.trim(); var lat = parseFloat(document.getElementById('prof-loc-lat').value); var lon = parseFloat(document.getElementById('prof-loc-lon').value); var location = (name || (!isNaN(lat) && !isNaN(lon))) ? { name: name, lat: isNaN(lat)?null:lat, lon: isNaN(lon)?null:lon } : null; var r = await apiV2('PUT', 'profile', { bio: bio, location: location }); if (btn) { btn.textContent = orig; btn.disabled = false; } if (r.ok) { if (btn) btn.textContent = '✓ SAVED'; setTimeout(function(){ if (btn) btn.textContent = orig; }, 1500); } else alert('Save failed: ' + (r.error || 'unknown')); }; window.showAddAsset = function() { var html = `
⚓ ADD A VESSEL
VESSEL NAME
TYPE
YEAR
MAKE
MODEL
LENGTH (ft)
ENGINE
NOTES
`; var c = document.createElement('div'); c.innerHTML = html; document.body.appendChild(c.firstElementChild); }; window.closeAssetModal = function() { var m = document.getElementById('asset-modal'); if (m) m.remove(); }; window.addAsset = async function() { var data = { name: document.getElementById('ast-name').value.trim(), type: document.getElementById('ast-type').value, year: parseInt(document.getElementById('ast-year').value) || null, make: document.getElementById('ast-make').value.trim(), model: document.getElementById('ast-model').value.trim(), length: parseFloat(document.getElementById('ast-length').value) || null, engine: document.getElementById('ast-engine').value.trim(), notes: document.getElementById('ast-notes').value.trim(), }; if (!data.name) { alert('Vessel name required.'); return; } var r = await apiV2('POST', 'profile/asset', data); if (r.ok) { closeAssetModal(); renderMyProfile(); } else alert('Add failed: ' + (r.error || 'unknown')); }; window.deleteAsset = async function(id) { if (!confirm('Remove this vessel?')) return; var r = await apiV2('DELETE', 'profile/asset/' + id); if (r.ok) renderMyProfile(); }; window.showAddPhoto = function() { var html = `
📷 ADD PHOTO
Photo upload via R2 coming soon. For now, paste a public URL.
URL
CAPTION
`; var c = document.createElement('div'); c.innerHTML = html; document.body.appendChild(c.firstElementChild); }; window.closePhotoModal = function() { var m = document.getElementById('photo-modal'); if (m) m.remove(); }; window.addPhoto = async function() { var url = document.getElementById('pho-url').value.trim(); if (!url) { alert('URL required.'); return; } var caption = document.getElementById('pho-caption').value.trim(); var r = await apiV2('POST', 'profile/photo', { url: url, caption: caption }); if (r.ok) { closePhotoModal(); renderMyProfile(); } else alert('Add failed: ' + (r.error || 'unknown')); }; window.deletePhoto = async function(id) { if (!confirm('Remove this photo?')) return; var r = await apiV2('DELETE', 'profile/photo/' + id); if (r.ok) renderMyProfile(); }; // ── DELETE PROJECT (admin) ── window.deleteProject = async function(id, displayName) { if (!id) return; var label = displayName || id; if (!confirm('DELETE PROJECT?\n\n' + label + '\n\nThis cannot be undone. Type OK on the next prompt to confirm.')) return; var confirmText = prompt('Type DELETE in all caps to confirm:'); if (confirmText !== 'DELETE') { alert('Cancelled — must type DELETE exactly.'); return; } var btn = event && event.target; var orig = btn ? btn.textContent : ''; if (btn) { btn.textContent = 'DELETING...'; btn.disabled = true; } try { var r = await api('projects', { action: 'delete', id: id }); // Worker may not have action:'delete' shim yet — fallback to REST if (!r.ok) { var rr = await fetch('/api/projects/' + id, { method: 'DELETE', headers: { 'Authorization': 'Bearer ' + ((window.S && window.S.token) || localStorage.getItem('wws_token') || '') } }); var rrj = await rr.json().catch(function(){ return { ok: rr.ok }; }); r = rrj; } if (r.ok || r.deleted) { alert('✓ Project deleted: ' + label); if (typeof nav === 'function') { try { nav('projects'); } catch (e) { location.reload(); } } else { location.reload(); } } else { if (btn) { btn.textContent = orig; btn.disabled = false; } alert('Delete failed: ' + (r.error || 'unknown error')); } } catch (e) { if (btn) { btn.textContent = orig; btn.disabled = false; } alert('Delete error: ' + e.message); } }; // ── INJECT DELETE BUTTON onto project detail views via observer ── var delObs = new MutationObserver(function() { if (!window.S || !window.S.user) return; if ((window.S.user.role !== 'admin' && window.S.user.role !== 'owner')) return; // Find any "project-detail-actions" container or detail header missing the delete btn var detailHeaders = document.querySelectorAll('[data-project-detail-actions]'); detailHeaders.forEach(function(el) { if (el.querySelector('[data-wws-delete-btn]')) return; var pid = el.getAttribute('data-project-id'); var name = el.getAttribute('data-project-name') || pid; var btn = document.createElement('button'); btn.setAttribute('data-wws-delete-btn','1'); btn.className = 'btn btn-ghost'; btn.style.cssText = 'color:#ff6666;border-color:#8C1D1D;'; btn.textContent = '× DELETE PROJECT'; btn.onclick = function(){ deleteProject(pid, name); }; el.appendChild(btn); }); // Also: project list cards — add a small delete X document.querySelectorAll('.project-card:not([data-wws-del-injected])').forEach(function(card){ card.setAttribute('data-wws-del-injected','1'); var pid = card.getAttribute('data-project-id') || card.dataset.projectId; if (!pid) return; var nameEl = card.querySelector('.project-name, .project-title, h3, h4'); var name = nameEl ? nameEl.textContent.trim() : pid; var x = document.createElement('button'); x.style.cssText = 'position:absolute;top:8px;right:8px;background:rgba(140,29,29,0.15);border:1px solid #8C1D1D;color:#ff6666;width:24px;height:24px;cursor:pointer;font-size:0.7rem;border-radius:3px;z-index:5;'; x.textContent = '×'; x.title = 'Delete project'; x.onclick = function(ev){ ev.stopPropagation(); deleteProject(pid, name); }; if (getComputedStyle(card).position === 'static') card.style.position = 'relative'; card.appendChild(x); }); }); delObs.observe(document.body, { childList: true, subtree: true }); // ── Deposit form helpers ── window.toggleDepositFields = function() { var on = document.getElementById('est-deposit-required').checked; document.getElementById('est-deposit-fields').style.display = on ? '' : 'none'; if (on) recalcDepositPreview(); }; window.recalcDepositPreview = function() { var typ = document.getElementById('est-deposit-type').value; var lbl = document.getElementById('est-deposit-amount-label'); var amt = Number(document.getElementById('est-deposit-amount').value) || 0; var total = (typeof recalcEstimateTotal === 'function') ? recalcEstimateTotal() : 0; var preview = document.getElementById('est-deposit-preview'); if (lbl) lbl.textContent = typ === 'fixed' ? 'AMOUNT IN $' : 'PERCENT (default 50%)'; if (preview) { var deposit = typ === 'fixed' ? amt : Math.round((total * amt / 100) * 100) / 100; var balance = Math.round((total - deposit) * 100) / 100; preview.textContent = total > 0 ? '→ Deposit: $' + deposit.toFixed(2) + ' · Balance on completion: $' + balance.toFixed(2) + ' (Total: $' + total.toFixed(2) + ')' : 'Add line items to preview deposit/balance breakdown'; } }; // ── Convert estimate to invoice (admin) ── window.convertEstimate = async function(id) { if (!confirm('Convert this estimate to an invoice?\n\nIf the estimate has a deposit configured, this will create TWO invoices:\n\n1. The deposit invoice (immediately payable)\n2. The full invoice (balance due on completion)\n\nIf no deposit is set, just one invoice is created.')) return; var btn = event && event.target; var orig = btn ? btn.textContent : ''; if (btn) { btn.textContent = 'CONVERTING...'; btn.disabled = true; } try { var r = await apiV2('POST', 'estimates/' + id + '/convert', {}); if (!r.ok) { if (btn) { btn.textContent = orig; btn.disabled = false; } alert('Convert failed: ' + (r.error || 'unknown')); return; } var msg = '✓ Invoice created: ' + r.invoice.id; if (r.depositInvoice) { msg += '\n✓ Deposit invoice: ' + r.depositInvoice.id + ' ($' + (r.depositInvoice.total||0).toFixed(2) + ')'; } alert(msg); try { nav('invoices'); } catch (e) { location.reload(); } } catch (e) { if (btn) { btn.textContent = orig; btn.disabled = false; } alert('Error: ' + e.message); } }; // ── Dynamic crew dropdown — fetch from /api/crew, fall back to hardcoded ── async function refreshCrewRow() { var row = document.getElementById('np-crew-row'); if (!row) return; // Form not currently rendered try { var r = await apiV2('GET', 'crew'); if (!r.ok || !Array.isArray(r.crew) || !r.crew.length) return; // Preserve currently checked IDs var checked = {}; row.querySelectorAll('input[type=checkbox]').forEach(function(cb){ var id = cb.id.replace(/^np/, '').toLowerCase(); checked[id] = cb.checked; }); // Rebuild row.innerHTML = r.crew.map(function(c) { var idCap = (c.id || c.name || '').charAt(0).toUpperCase() + (c.id || c.name || '').slice(1); var defChecked = (c.id === 'brad') || (checked[c.id] === true); return ''; }).join(''); } catch (e) { console.warn('[WWS] crew fetch failed:', e); } } // Wrap original showNewProject so refresh fires after the form mounts if (typeof window.showNewProject === 'function') { var _orig_show_new_project = window.showNewProject; window.showNewProject = function() { var ret = _orig_show_new_project.apply(this, arguments); setTimeout(refreshCrewRow, 50); return ret; }; } // Update createProject to dynamically read all checkboxes in the row if (typeof window.createProject === 'function') { var _orig_create_project = window.createProject; window.createProject = async function() { // Pre-mutate the crew array gather: read every checked checkbox in #np-crew-row // by injecting a global hook. Simpler: hijack the document.getElementById calls // via wrapping the function. But we can't easily — so instead, pre-set fallback // checkboxes if user is on a non-rebuilt form. // The safest path: read crew from #np-crew-row directly here, then call the // existing createProject which will look at the same checkboxes. var row = document.getElementById('np-crew-row'); if (row) { // Make sure each checkbox has a known id pattern - already true // The original createProject reads npBrad/npJosh/npEvan only — extend it to // also push any id starting with 'np' that's checked AND not Brad/Josh/Evan. // Read AFTER the original runs by intercepting the api call... actually // simpler: just patch the crew array in the body before send. } return await _orig_create_project.apply(this, arguments); }; // Better: directly patch the crew gather lines via re-defining createProject inline below. } // Override createProject more directly — gather crew from ALL checkboxes in np-crew-row window.createProject = async function() { console.log('[WWS] createProject() v2 fired'); var btn = event && event.target; var orig = btn ? btn.textContent : ''; if (btn) { btn.textContent = 'INITIALIZING...'; btn.disabled = true; } var addr = document.getElementById('npAddr')?.value.trim(); var cust = document.getElementById('npCust')?.value.trim(); var custName = document.getElementById('npCustName')?.value.trim(); var notes = document.getElementById('npNotes')?.value.trim(); var crew = []; var row = document.getElementById('np-crew-row'); if (row) { row.querySelectorAll('input[type=checkbox]').forEach(function(cb){ if (cb.checked) { var id = cb.id.replace(/^np/, '').toLowerCase(); if (id && !crew.includes(id)) crew.push(id); } }); } else { // Fallback to hardcoded if (document.getElementById('npBrad')?.checked) crew.push('brad'); if (document.getElementById('npJosh')?.checked) crew.push('josh'); if (document.getElementById('npEvan')?.checked) crew.push('evan'); } var err = document.getElementById('np-err'); if (!addr) { if (btn) { btn.textContent = orig; btn.disabled = false; } if (err) { err.textContent = '// ERROR: SITE ADDRESS REQUIRED'; err.style.display = 'block'; } return; } if (err) err.style.display = 'none'; try { var r = await api('projects', { action: 'create', address: addr, customer: cust, customerName: custName, notes: notes, crew: crew, status: 'active', created: Date.now() }); console.log('[WWS] createProject response:', r); if (r.ok) { if (btn) { btn.textContent = '✓ CREATED — ' + ((r.project && r.project.id) || 'OK'); btn.style.background = '#2F6B3F'; btn.style.color = '#fff'; } try { var ok = document.getElementById('np-err'); if (ok) { ok.className = 'msg-box'; ok.style.cssText = 'display:block;background:rgba(47,107,63,0.15);border:1px solid #2F6B3F;color:#6cd47e;padding:12px 14px;margin-bottom:14px;font-family:var(--fm,monospace);font-size:0.75rem;letter-spacing:1px;'; ok.textContent = '✓ PROJECT CREATED — ID: ' + ((r.project && r.project.id) || '?') + ' — Click MY WORK in the sidebar to view it.'; } } catch (e) {} setTimeout(function() { try { if (typeof nav === 'function' && S && S.user) nav('projects'); } catch (e) { console.error('[WWS] nav() threw:', e); } }, 1200); } else { if (btn) { btn.textContent = orig; btn.disabled = false; } if (err) { err.textContent = '// ERROR: ' + (r.error || 'CREATE FAILED'); err.style.display = 'block'; } } } catch (e) { if (btn) { btn.textContent = orig; btn.disabled = false; } if (err) { err.textContent = '// ERROR: ' + e.message; err.style.display = 'block'; } } }; // ── Send Invoice for Payment via Mailman ── window.sendInvoiceForPayment = async function(id) { var btn = event && event.target; var orig = btn ? btn.textContent : ''; if (btn) { btn.textContent = 'LOADING...'; btn.disabled = true; } try { var r = await api('invoices', { action: 'get', id: id }); if (!r.ok) { alert('Could not load invoice: ' + (r.error||'unknown')); if(btn){btn.textContent=orig;btn.disabled=false;} return; } var inv = r.invoice; if (!inv.clientEmail && !inv.customer) { alert('No client email on this invoice. Edit the invoice and add a client email first.'); if (btn) { btn.textContent = orig; btn.disabled = false; } return; } // Get PDF if possible var attachments = []; try { var pdfR = await apiV2('GET', 'invoices/' + id + '/pdf'); 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-Invoice-' + id + '.pdf', content: b64, contentType: 'application/pdf', kind: 'PDF' }); } } catch (e) {} var subj = inv.isDeposit ? 'Deposit invoice for ' + (inv.project || inv.clientName || 'your project') + ' — ' + id : 'Invoice for ' + (inv.project || inv.clientName || 'your project') + ' — ' + id; var stripeLink = (inv.payUrl) || 'https://buy.stripe.com/6oU3cw4rQbS92Ed92kfnO01'; var amt = (inv.total || 0).toFixed(2); var balanceLine = (!inv.isDeposit && inv.depositRequired) ? '\n\nNote: $' + (inv.depositAmount||0).toFixed(2) + ' deposit invoice (' + (inv.depositType==='fixed'?'fixed':'percent') + ') was issued separately. The balance shown above is what remains after the deposit clears.' : ''; var depositLine = inv.isDeposit ? '\n\nThis is the DEPOSIT invoice. The balance ($' + ((inv.parentInvoice && inv.parentInvoice.balanceDue) || '?') + ') will be billed on completion.' : ''; var body = 'Howdy ' + (inv.clientName || 'there') + ' — the Mailman from Wolfe, Hoover & Sons Engineering here.\n\n' + 'Your invoice is attached and ready for payment.\n\n' + 'Total: $' + amt + balanceLine + depositLine + '\n\n' + 'Pay securely online: ' + stripeLink + '\n\n' + 'You can also pay by Venmo, Zelle, cash, or check — reply to this email and let me know what works.\n\n' + 'Questions? Just reply.'; // Open Mailman popup if (typeof openMailman === 'function') { openMailman({ to: inv.clientEmail || inv.customer, template: 'custom', vars: { subject: subj, text: body }, attachments: attachments, }); } else { alert('Mailman popup not available — please send manually.\n\nTo: ' + (inv.clientEmail || inv.customer) + '\nSubject: ' + subj + '\n\n' + body); } } catch (e) { alert('Error: ' + e.message); } finally { if (btn) { btn.textContent = orig; btn.disabled = false; } } }; // ── Generate onsite passcode for in-person signing ── window.generateOnsitePasscode = async function(contractId) { var btn = event && event.target; var orig = btn ? btn.textContent : ''; if (btn) { btn.textContent = 'GENERATING...'; btn.disabled = true; } try { var r = await apiV2('POST', 'contracts/' + contractId + '/onsite-passcode', {}); if (btn) { btn.textContent = orig; btn.disabled = false; } if (!r.ok) { alert('Failed: ' + (r.error||'unknown')); return; } var msg = '🔢 ONSITE PASSCODE: ' + r.passcode + '\n\n' + '⏱ Valid for 15 minutes\n\n' + 'Hand the customer your phone, navigate to:\n' + r.url + '\n\n' + 'Customer enters passcode ' + r.passcode + ' and signs on screen.\n\n' + 'OR: tap OK to copy the URL to your clipboard.'; if (confirm(msg)) { if (navigator.clipboard) { try { await navigator.clipboard.writeText(r.url); alert('URL copied to clipboard. Passcode: ' + r.passcode); } catch (e) {} } } } catch (e) { if (btn) { btn.textContent = orig; btn.disabled = false; } alert('Error: ' + e.message); } }; console.log('[WWS-PATCH] v2026.04.28 loaded — mobile + estimates + contracts + mailman'); })();
🤖
WALTER · WH&S AI
Howdy. I'm scoped to WH&S operations — invoices, contracts, ad copy, marine/auto/RV technical. What do you need?
Ready · Haiku 4.5